Search

8월

8/10

type coercion
암묵적 형 변환 ( 자동 형변환)
== 에서는 일어나고 ===에서는 일어나지 않음

8/17 페어프로그래밍 연습 - todolist

tailwindcss 전역에서 임포트해야함 index.css에 넣기
import { useState, type ChangeEventHandler } from "react"; import Header from "./components/Header"; import data from "./constants/dummyData"; import type { Todo } from "./types/todo"; function App() { const [input, setInput] = useState(""); const onInput: ChangeEventHandler<HTMLInputElement> = (e) => { setInput(e.currentTarget.value); }; return ( <> <div className="flex flex-col"> <Header /> <div className="mx-auto mt-16 w-full max-w-2xl rounded-lg border border-gray-200 p-6 shadow-sm"> <input placeholder="please enter your todos!" value={input} onChange={onInput} className="w-full rounded-md border border-gray-400 p-2 placeholder:text-gray-400" /> <div> {data.map((el: Todo) => ( <div key={el.id}>{el.title}</div> ))} </div> </div> </div> </> ); } export default App;
TypeScript
복사

e.target

실제로 이벤트가 발생한 요소를 가리킴.
즉, 사용자가 클릭/입력/호버 등 행동을 한 가장 안쪽(깊은) DOM 요소.

e.currentTarget

이벤트 핸들러가 붙은 요소를 가리킴.
즉, 이벤트 리스너가 지금 실행되고 있는 그 컨텍스트의 요소.

순서

1.
npm create vite@latest하고
2.
tailwindcss적용하고
3.
input ui만들고
4.
card들 만들고
5.
todo 생성기능 만들기 = localstorage에 저장 uuid로
초기 로드: useState의 lazy initializer에서 JSON.parse(+ try/catch)로 Todo[] 복원
저장: toDos가 바뀔 때만 localStorage에 반영
onCreate에서는 상태만 갱신
6.
수정 삭제 기능
7.
custom hook만들기
function TodoItem({ id, title, completed }: Todo) { const [checked, setChecked] = useState(completed)
TypeScript
복사
이게 안티패턴임.. state를 받아서 다시 state로 만드는 경우
단일 소스(상태)는 Todolist.tsx에서만 관리
localStorage 저장도 부모(src/components/Todolist.tsx)의 useEffect 한 곳에서만 수행
자식(src/components/TodoItem.tsx)은 props로 내려온 completed를 그대로 쓰는 “완전 제어 컴포넌트”
변경은 onToggle 콜백으로 부모에 통지
성능: onToggle은 useCallback으로 안정화, TodoItem은 React.memo로 메모이제이션 → 변경된 아이템만 리렌더
type Props = Todo & { onToggle: (id: string, completed: boolean) => void } function TodoItem({ id, title, completed, onToggle }: Props) { const onCheck: ChangeEventHandler<HTMLInputElement> = (e) => { onToggle(id,e.currentTarget.checked) }; return ( <div className="flex w-full justify-between px-4 py-2 border rounded-xl"> <div key={id} className="text-2xl"> {title} </div> <input type="checkbox" className="w-6" checked={completed} onChange={onCheck} /> </div> ); } export default memo(TodoItem);
TypeScript
복사

중요

todos state는 todolist.tsx에서만 관리해야한다
localStorage저장도 부모에서만 수행해야한다.
자식은 props로 내려온 것들을 쓰기만하는 controlled component여야 함
변경사항이 발생하면 onToggle을 호출해서 부모가 리렌더링을 수행하도록 해야함
onToggle은 항상 같은 역할을 하는 함수이니 리렌더링될때마다 재생성할 필요가 없고 useCallback으로 감싸고 [] deps 비워놓으면 됨
TodoItem 각각은 리렌더링 되어야하는 것들과 되지 않아도 되는것들이 있다. ⇒ memo로 감싸놓으면 props들의 얕은 비교 수행후에 같으면 그냥 둔다. 변경되어야 할 애들만 변경한다는것
useCallback은 함수 메모이제이션
그럼 useMemo는?
memo는 컴포넌트를 감싸서 리렌더링을 막아주는 도구이다. 부모가 리렌더되더라도 value가 바뀌지 않았따면 child는 다시 안그린다
useMemo는 함수의 리턴값을 기억해놓는것이다, 의존성배열에 있는 값이 바뀌지 않는한은 계산결과가 바뀔 리도 없으므로 비싼연산을 할때 useMemo로 함수를 감싸는것
useCallback은 함수 자체를 기억해놔서 함수 참조가 바뀌지 않도록 함. REact.memo와 함께 쓰이는 경우가 많음 자식 컴포넌트에 함수를 props로 넘길때
toDos만 변경해야하는거지, localStorage를 여러곳에서 변경하면 안된다 자동으로 변경되도록 해야지
filter는 조건이 참인것들만 골라서 반환하는것
map은 새 배열을 return하는것이기 때문에 직접 변경하면 안된다
forEach를쓰면 가능하다 근데 굳이?
key는 부모에만 있으면 됨!

delete기능 만들기

아이콘은 lucide로 하기
아이콘들은 absolute relative해서 영향 안받게 하기

update기능 만들기

편집중인지, 제목 input저장을 state로 관리하기

dark mode

tailwind에서는 darkmode를 하려면 className에 dark: 라고 한다음 작성하면 다크모드일때 적용됨
다크모드 로직 작성 ⇒ html에 dark를 토글로 넣어야함
document.documentElement.classList.toggle("dark");
헤더에 토글버튼 추가해서 토글버튼 누르면 위에거 들어가도록 하기
zustand로 theme을 관리해야함
import { create } from "zustand"; type Theme = "light" | "dark"; type ThemeState = { theme: Theme; toggleTheme: () => void; }; export const useThemeStore = create<ThemeState>((set) => ({ theme: "light", toggleTheme: () => { set((state) => ({ theme: state.theme === "light" ? "dark" : "light", })); }, }));
TypeScript
복사
documentElement는 html을 가리키는것.
import { Moon, Sun } from "lucide-react"; import { useThemeStore } from "../store/themeStore"; function Header() { const { theme, toggleTheme } = useThemeStore(); const onToggle = () => { document.documentElement.classList.toggle("dark"); toggleTheme(); }; return ( <> <div className="text-3xl text-black bg-amber-300 flex justify-center p-4"> Todolist <button onClick={onToggle}> {theme === "dark" ? <Moon /> : <Sun />} </button> </div> </> ); } export default Header;
TypeScript
복사
@custom-variant dark (&:where(.dark, .dark *)); 이거 index.css에 꼭 넣어야한다.. 이게문제였다
이제 tailwind.config.js는 필요없다 v4부터는

theme만들기

—color-primary index.css에서 선언해놓으면 자동으로 bg-primary이런식으로 사용가능하다

모달 만들기

아래와같이 모달에 들어갈 제목 메세지 그리고 confirm시에 실시할 함수를 전달하면 어디서든 쓸 수 있다
import { create } from "zustand"; type ModalState = { open: boolean; openModal: () => void; closeModal: () => void; }; export const useModalStore = create<ModalState>((set) => ({ open: false, openModal: () => set({ open: true }), closeModal: () => set({ open: false }), }));
JavaScript
복사
외부를 클릭했을때 닫히게 하려면 event propagation도 주의해야한다.
모달 컴포넌트에서 모든 작업을 수행할 필요 없다. 지금 생각한건 todos를 전역으로 만들고, App.tsx에서 <Modal/> 컴포넌트 하나만 띄운다음에 거기서 todos를 접근하는것이었는데, 그건 비효율적이다
<Modal/>컴포넌트에서 직접 삭제 로직을 수행하는게 아니라 <Modal/> 컴포넌트를 필요한 곳에 삽입하고, modal의 열림 닫힘 상태만 전역으로 관리해서 컴포넌트 안에서 setState함수를 건네줘서 그걸 수행하게 하는게 훨씬 효율적
import Button from "./Button"; type ModalProps = { open: boolean; // 모달 열림/닫힘 상태 onConfirm: () => void; // 삭제(확인) 시 실행할 콜백 onCancel: () => void; // 취소 시 실행할 콜백 }; function Modal({ open, onConfirm, onCancel }: ModalProps) { if (!open) return null; // 안 열렸으면 렌더 안 함 return ( <div className="fixed inset-0 flex justify-center items-center z-50" onClick={onCancel} // 바깥(배경) 클릭 → 닫힘 > {/* 배경 */} <div className="bg-black opacity-40 absolute inset-0"></div> {/* 다이얼로그 */} <div className="relative w-1/3 h-1/3 bg-white rounded-2xl z-10 flex flex-col justify-between p-6" onClick={(e) => e.stopPropagation()} // 내부 클릭은 이벤트 버블링 막기 > <div className="text-center"> <p className="text-2xl mb-2"> Do you really want to delete this Todo? </p> <p className="text-xl text-gray-600">This action cannot be undone.</p> </div> <div className="flex justify-around"> <Button type="warn" text="Delete" onClick={onConfirm} /> <Button type="confirm" text="Cancel" onClick={onCancel} /> </div> </div> </div> ); } export default Modal;
JavaScript
복사
todolist에서 사용예시
function Todolist() { const [input, setInput] = useState(""); const [toDos, setToDos] = useState<Todo[]>(() => { try { const saved = localStorage.getItem("todos"); return saved ? (JSON.parse(saved) as Todo[]) : []; } catch { return []; } }); // 🔥 모달용 상태 const [open, setOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const onInput: ChangeEventHandler<HTMLInputElement> = (e) => { setInput(e.currentTarget.value); }; const onCreate = () => { const newTodos = [ ...toDos, { id: uuidv4(), title: input.trim(), completed: false, }, ]; setToDos(newTodos); setInput(""); }; const onToggle = useCallback((id: string, completed: boolean) => { setToDos((prev) => prev.map((todo) => (todo.id === id ? { ...todo, completed } : todo)) ); }, []); // ❌ onDelete는 모달 열기만 const onDelete = useCallback((id: string) => { setDeleteTarget(id); setOpen(true); }, []); const confirmDelete = useCallback(() => { if (deleteTarget) { setToDos((prev) => prev.filter((todo) => todo.id !== deleteTarget)); } setDeleteTarget(null); setOpen(false); }, [deleteTarget]); const onUpdate = useCallback((id: string, updated: string) => { setToDos((prev) => prev.map((todo) => (todo.id === id ? { ...todo, title: updated } : todo)) ); }, []); useEffect(() => { localStorage.setItem("todos", JSON.stringify(toDos)); }, [toDos]); return ( <div className="mx-auto mt-16 w-full max-w-2xl rounded-lg border border-gray-200 p-6 shadow-sm"> <input placeholder="please enter your todos!" value={input} onChange={onInput} className="w-full rounded-md border border-gray-400 p-2 placeholder:text-gray-400" /> <button className={`w-full bg-amber-200 rounded-2xl p-2 text-xl mt-2`} onClick={onCreate} disabled={input.length === 0} > Add To List! </button> <div className="mt-6 flex flex-col gap-4"> {toDos.map((el: Todo) => ( <TodoItem key={el.id} id={el.id} completed={el.completed} title={el.title} onToggle={onToggle} onDelete={onDelete} onUpdate={onUpdate} /> ))} </div> {/* 🔥 삭제 확인 모달 */} <Modal open={open} onConfirm={confirmDelete} onCancel={() => setOpen(false)} /> </div> ); } export default Todolist;
JavaScript
복사

1. 설치

npm install -g json-server
Bash
복사

2. db.json 파일 만들기

{ "users": [ { "id": 1, "name": "Hank" }, { "id": 2, "name": "Alice" } ], "posts": [ { "id": 1, "title": "Hello", "author": "Hank" } ] }
JSON
복사

3. 실행

json-server --watch db.json --port 3001
Bash
복사
json server써도 괜찮겠다!

API, routing, 테스트코드, optimization 페어프로그래밍

useEffect(() => { const fetchData = async () => { try { const res = await axios.get( "https://www.themealdb.com/api/json/v1/1/search.php?s=Arrabiata" ); console.log(res.data); } catch { console.log("err"); } }; fetchData(); }, []);
TypeScript
복사
useEffect안에서 async함수를 직접 구현한 후에 호출을 하거나
밖에서 만든 다음 useEffect안에서도 await을 써야함
function Meals() { const fetchData = async () => { const res = await axios.get("https://www.themealdb.com/api/json/v1/1/search.php?s=Arrabiata"); return res.data; }; useEffect(() => { const load = async () => { try { const data = await fetchData(); console.log(data); } catch { console.log("err"); } }; load(); }, []);
JavaScript
복사
편한건 then으로 체이닝하는거긴 한데..

index signature

export type Meal = { idMeal: string; strMeal: string; strMealThumb: string; [key: string]: any; // 추가 필드가 있어도 무시 };
JavaScript
복사

flex basis

한줄에 두개만 오게 하고싶으면 자식 요소에 flex-basis주기 1/2로

optimization

1. Prefetch (다음 페이지 미리 불러오기)

React Query 기준 예시:
import { useQuery, useQueryClient } from "@tanstack/react-query"; function MealsList({ page }: { page: number }) { const queryClient = useQueryClient(); // 현재 페이지 데이터 const { data, isLoading } = useQuery({ queryKey: ["meals", page], queryFn: () => fetch(`/api/meals?page=${page}`).then((res) => res.json()), }); // 👇 현재 페이지 로드 끝나면 다음 페이지를 미리 fetch useEffect(() => { queryClient.prefetchQuery({ queryKey: ["meals", page + 1], queryFn: () => fetch(`/api/meals?page=${page + 1}`).then((res) => res.json()), }); }, [page, queryClient]); if (isLoading) return <p>Loading...</p>; return ( <ul> {data.meals.map((meal: any) => ( <li key={meal.idMeal}>{meal.strMeal}</li> ))} </ul> ); }
TypeScript
복사

2. 코드 스플리팅 (라우터별 코드 분리)

React Router + React.lazy, Suspense 예시:
import { Suspense, lazy } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom"; // 👇 페이지 단위로 분리 const HomePage = lazy(() => import("./pages/HomePage")); const MealsPage = lazy(() => import("./pages/MealsPage")); const AboutPage = lazy(() => import("./pages/AboutPage")); function App() { return ( <BrowserRouter> <Suspense fallback={<p>Loading page...</p>}> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/meals" element={<MealsPage />} /> <Route path="/about" element={<AboutPage />} /> </Routes> </Suspense> </BrowserRouter> ); }
TypeScript
복사
이렇게 하면 각 페이지는 필요할 때만 JS 번들이 로드됨.

3. Dynamic import / Tree-shaking

Dynamic import: 특정 기능이 필요할 때만 모듈 불러오기
async function handleExport() { // 👇 버튼 눌렀을 때만 라이브러리 import const { exportToCsv } = await import("./utils/exportToCsv"); exportToCsv(); }
TypeScript
복사
Tree-shaking: 번들러(Webpack, Vite)가 안 쓰는 코드 제거
기본적으로 ES Module(import { fn } from 'lib')을 쓰면 자동 지원.
예: lodash 전부 import
import _ from "lodash"; // 전체 로드 → X
TypeScript
복사
import debounce from "lodash/debounce"; // 필요한 함수만 → O
TypeScript
복사

4. 이미지 최적화 (Lazy loading)

// HTML5 <img> 기본 속성 <img src="https://www.themealdb.com/images/media/meals/llcbn01574260722.jpg" alt="Meal" loading="lazy" width={300} height={200} /> // React + Tailwind <imgsrc={meal.strMealThumb} alt={meal.strMeal} loading="lazy" className="rounded shadow" />
TypeScript
복사
loading="lazy" 붙이면 브라우저가 뷰포트에 보일 때만 이미지 로드함.

정리

Prefetch: React Query prefetchQuery로 다음 페이지 미리 불러오기
코드 스플리팅: React.lazy + Suspense로 라우트 단위 번들 분리
Dynamic import / Tree-shaking: 필요할 때만 import, 안 쓰는 코드는 번들러가 제거
Lazy loading 이미지: <img loading="lazy" />

일반적인 폴더구조

src/ ├── components/ ├── hooks/ ├── services/ ├── types/ ├── pages/ └── utils/

1. 왜 fixed inset-0이 화면 전체를 차지하나?

fixed → 뷰포트(window) 기준으로 요소를 고정시킴. (스크롤해도 항상 같은 위치에 있음)
inset-0top: 0; right: 0; bottom: 0; left: 0; 을 한꺼번에 적용하는 Tailwind 단축 속성.
그래서 결과적으로, fixed inset-0 = 화면 전체를 덮는 풀스크린 레이어가 되는 것.

2. 스피너 구현 원리

사실 굉장히 단순해.

단계별

1.
divborder를 줘서 원형(rounded-full) 테두리 생성.
border: 4px solid gray; border-radius: 9999px;
CSS
복사
2.
하지만 모든 선이 같은 색이면 돌려도 티가 안 남음 →
border-top만 다른 색을 줌 (border-t-amber-500).
3.
이제 원 모양의 일부만 색깔이 있는 상태가 됨.
4.
animate-spin을 주면 Tailwind 기본 애니메이션(무한 회전)이 적용됨.

결과

마치 바퀴살이 돌아가는 것처럼 보이는 로딩 애니메이션이 만들어짐.

3. 핵심 요약

fixed inset-0 → 화면 전체를 차지하는 투명 레이어.
flex items-center justify-center → 그 레이어 중앙에 스피너 정렬.
스피너 = “테두리 있는 원” + “한쪽만 색 다르게” + “무한 회전 애니메이션”.

로딩스피너

function LoadingSpinner() { return ( <div className="fixed inset-0 flex items-center justify-center"> <div className="animate-spin h-24 w-24 rounded-full border-4 border-gray-300 border-t-amber-500"></div> </div> ); } export default LoadingSpinner;
JavaScript
복사

inset-0 원리

1. 일반적인 요소

보통은 요소는 widthheight를 직접 주거나, w-full, h-full 같은 걸 줘야 크기가 생김.

2. position이 바뀌면

absolute, fixed, relative 같은 걸 주면, top/right/bottom/left 속성으로 위치와 크기를 강제로 정의할 수 있음.
position: fixed → 뷰포트(window) 기준으로 위치를 잡음.
top: 0; right: 0; bottom: 0; left: 0; → 상하좌우 모두 0에 붙임.
이 네 개가 동시에 들어가면, 남는 영역은 화면 전체밖에 없음.
즉, fixed + top:0; right:0; bottom:0; left:0;width: 100vw; height: 100vh; 효과가 됨.

3. Tailwind 단축 속성

inset-0 = top: 0; right: 0; bottom: 0; left: 0;
따라서 fixed inset-0 = 화면 전체 덮는 레이어

1. fixed inset-0

position: fixed; top: 0; right: 0; bottom: 0; left: 0;
CSS
복사
뷰포트 기준으로 상하좌우 0에 딱 붙음 → 화면 전체 차지. (스크롤해도 항상 고정)

2. absolute inset-0

position: absolute; top: 0; right: 0; bottom: 0; left: 0;
CSS
복사
부모 요소의 경계를 꽉 채움.
만약 부모가 relative이고 w-64 h-64면 그 안을 다 덮음.
부모가 body라면 결국 화면 전체 덮는 효과가 날 수도 있음.

3. relative inset-0

position: relative; top: 0; right: 0; bottom: 0; left: 0;
CSS
복사
relative는 자기 원래 자리 기준으로 이동만 하는 속성이야.
top:0; left:0; → 이동 안 함, 따라서 크기 변화 없음.
즉, relative inset-0은 사실상 의미 없는 코드.

유사배열객체

유사 배열 객체(array-like object)는 말 그대로 배열처럼 보이지만 진짜 배열은 아닌 객체를 말해.

유사 배열 객체의 특징

1.
length 프로퍼티가 있음
const obj = { length: 3 };
JavaScript
복사
→ 길이가 3이라는 정보만 가진 객체.
2.
숫자 인덱스를 키로 가질 수 있음
const obj = { 0: 'a', 1: 'b', 2: 'c', length: 3 };
JavaScript
복사
→ 배열처럼 obj[0] 이런 접근이 가능.
3.
하지만 진짜 배열 메서드(map, filter, forEach 등)를 직접 쓸 수 없음.
obj.map // ❌ TypeError
JavaScript
복사

왜 쓰냐?

자바스크립트에서 DOM APIarguments 객체 같은 게 배열이 아니라 "유사 배열 객체"라서 변환할 필요가 있음.
예:
function test(a, b, c) { console.log(arguments); // {0: a, 1: b, 2: c, length: 3} console.log(Array.isArray(arguments)); // false (배열 아님) }
JavaScript
복사

Array.from의 역할

Array.from은 이런 유사 배열 객체를 진짜 배열로 바꿔줌.
const obj = { 0: 'a', 1: 'b', 2: 'c', length: 3 }; const arr = Array.from(obj); console.log(arr); // ['a', 'b', 'c'] console.log(Array.isArray(arr)); // true
JavaScript
복사

slice함수

arr.slice(0,8)이면 0번에서 7번까지 8개

페이지네이션

테스트

react testing library검색해서 들어가서 복붙하면 된다
vite는 vitest쓰기 설정이 복잡하다 cra는 jest설정이 이미 되어있는데..
vitest는
"test": "vitest”
package.json에 넣고
import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], test: { environment: "jsdom", globals: true, setupFiles: "./src/setupTests.ts", }, });
TypeScript
복사
이렇게 jsdom으로 설정해놓으면 에러가 안난다
npm test로 실행하면 끝
react testing library는 jest인지 vitest인지 상관없음
vite도 jest도 API는 거의동일함 describe, it, expect같은것들
둘다 테스트 러너 프레임워크임
react testing library는 jest나 vitest위에서 동작하는 도우미 라이브러리
render, screen.getByText같은 api제공해서 버튼 텍스트 같은것들 찾아주고 UI를 체크하기 위한 도구임
사용자관점 테스트를 위한 도구
그래서 jest or vitest + rtl이렇게 테스팅을 하는것임

test 파일 위치

components폴더 아래에 __ tests __ 라고 만들고 거기에 pagination.test.tsx 이런식으로 하면 된다
import { render, screen, fireEvent } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import Counter from "./Counter"; describe("Counter Component", () => { it("초기 값은 0이어야 한다", () => { render(<Counter />); expect(screen.getByText(/Count: 0/)).toBeInTheDocument(); }); it("버튼 클릭 시 1 증가한다", () => { render(<Counter />); const button = screen.getByText("Increment"); fireEvent.click(button); expect(screen.getByText(/Count: 1/)).toBeInTheDocument(); }); });
JavaScript
복사
describe는 같은 주제/ 컴포넌트를 묶는 블록이고 it은 테스트케이스
expect로 RTL에서 screen에서 dom요소를 가져온것들을 테스트
redner로 테스트할 react컴포넌트를 가상으로 렌더링
fireEvent vs userEvent
fireEvent: 단순 이벤트 발생 (빠름, 단순)
userEvent: 실제 유저의 입력/클릭을 더 현실적으로 시뮬레이션 keydown focus blur등

1. setupTests.ts 역할

여기에 공통 세팅을 넣음 (매번 import 안 해도 되게)
import "@testing-library/jest-dom"; // toBeInTheDocument 같은 matcher 추가
TypeScript
복사
→ 이거 없으면 toBeInTheDocument() 같은 확장 matchers를 못 씀.
getBy...: 반드시 있어야 할 요소 (없으면 에러).
queryBy...: 있을 수도 없을 수도 (없으면 null).
findBy...: 비동기 요소 기다림 (await 필요).
expect(screen.getByText("Increment")).toBeInTheDocument(); // 반드시 있음 expect(screen.queryByText("NotExist")).toBeNull(); // 없어야 함 expect(await screen.findByText("Loaded")).toBeInTheDocument(); // fetch 후
TypeScript
복사
API 호출하는 경우 mock 필요.
Jest: jest.fn, jest.mock
Vitest: vi.fn, vi.mock
import { vi } from "vitest"; const mockFn = vi.fn(); it("calls API", () => { mockFn(); expect(mockFn).toHaveBeenCalled(); });
TypeScript
복사

Vitest가 인식하는 기본 테스트 파일 규칙

*/*.test.{js,ts,jsx,tsx}
*/*.spec.{js,ts,jsx,tsx}
*/__tests__/**/*.{js,ts,jsx,tsx}
즉 폴더 이름이 __tests__여도 되고, 파일 이름에 .test.tsx.spec.tsx만 붙어 있어도 자동으로 찾아줌.