🎯 학습 목표
실제 프로젝트 파일 구조(components/, hooks/, types/)를 직접 설계할 수 있다
Todo CRUD(추가/완료토글/삭제/수정) 전체를 구현한다
localStorage로 데이터를 영속화하는 패턴을 익힌다
필터링(전체/진행중/완료) 기능을 구현한다
컴포넌트를 역할에 따라 분리하는 실전 경험을 쌓는다
📖 핵심 개념 1 — 프로젝트 파일 구조
src/
├── components/
│ ├── TodoInput.jsx // 새 할 일 입력 폼
│ ├── TodoItem.jsx // 개별 할 일 항목
│ ├── TodoFilter.jsx // 필터 버튼 그룹
│ └── TodoList.jsx // 필터링된 목록 렌더링
├── hooks/
│ ├── useTodos.js // 할 일 CRUD 로직
│ └── useLocalStorage.js // localStorage 동기화
├── types/
│ └── todo.js // Todo 타입/상수 정의
└── App.jsx // 최상위 컴포넌트
📖 핵심 개념 2 — 타입과 상수 정의
// src/types/todo.js
export const FILTER_TYPES = {
ALL: 'all',
ACTIVE: 'active',
COMPLETED: 'completed',
};
// Todo 객체 구조 (JSDoc으로 타입 힌트)
/**
* @typedef {Object} Todo
* @property {string} id - 고유 ID (crypto.randomUUID())
* @property {string} text - 할 일 내용
* @property {boolean} done - 완료 여부
* @property {string} createdAt - 생성 시각 (ISO 문자열)
*/
📖 핵심 개념 3 — useLocalStorage 커스텀 훅
// src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
console.warn(`localStorage 읽기 실패: ${key}`);
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
console.warn(`localStorage 쓰기 실패: ${key}`);
}
}, [key, value]);
return [value, setValue];
}
📖 핵심 개념 4 — useTodos 커스텀 훅 (전체 CRUD)
// src/hooks/useTodos.js
import { useCallback } from 'react';
import { useLocalStorage } from './useLocalStorage';
export function useTodos() {
const [todos, setTodos] = useLocalStorage('todos', []);
// 추가
const addTodo = useCallback((text) => {
if (!text.trim()) return;
setTodos(prev => [
...prev,
{
id: crypto.randomUUID(),
text: text.trim(),
done: false,
createdAt: new Date().toISOString(),
},
]);
}, [setTodos]);
// 완료 토글
const toggleTodo = useCallback((id) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
}, [setTodos]);
// 삭제
const deleteTodo = useCallback((id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, [setTodos]);
// 수정
const editTodo = useCallback((id, newText) => {
if (!newText.trim()) return;
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, text: newText.trim() } : todo
)
);
}, [setTodos]);
// 완료된 항목 전체 삭제
const clearCompleted = useCallback(() => {
setTodos(prev => prev.filter(todo => !todo.done));
}, [setTodos]);
// 전체 토글
const toggleAll = useCallback(() => {
const allDone = todos.every(t => t.done);
setTodos(prev => prev.map(todo => ({ ...todo, done: !allDone })));
}, [todos, setTodos]);
return { todos, addTodo, toggleTodo, deleteTodo, editTodo, clearCompleted, toggleAll };
}
💻 코드 예제 — 전체 컴포넌트 구현
// src/components/TodoInput.jsx
import { useState } from 'react';
export function TodoInput({ onAdd }) {
const [text, setText] = useState('');
function handleSubmit(e) {
e.preventDefault();
onAdd(text);
setText('');
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px' }}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="할 일을 입력하세요..."
style={{ flex: 1, padding: '8px 12px', fontSize: '16px' }}
autoFocus
/>
<button type="submit" disabled={!text.trim()}>추가</button>
</form>
);
}
// src/components/TodoItem.jsx
import { useState } from 'react';
export function TodoItem({ todo, onToggle, onDelete, onEdit }) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
function handleEditSubmit(e) {
e.preventDefault();
onEdit(todo.id, editText);
setIsEditing(false);
}
function handleEditKeyDown(e) {
if (e.key === 'Escape') {
setEditText(todo.text); // 변경 취소
setIsEditing(false);
}
}
return (
<li style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 0' }}>
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
{isEditing ? (
<form onSubmit={handleEditSubmit} style={{ flex: 1 }}>
<input
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={handleEditSubmit}
autoFocus
style={{ width: '100%' }}
/>
</form>
) : (
<span
onDoubleClick={() => setIsEditing(true)} // 더블클릭으로 수정 모드
style={{
flex: 1,
textDecoration: todo.done ? 'line-through' : 'none',
color: todo.done ? '#999' : 'inherit',
cursor: 'pointer',
}}
>
{todo.text}
</span>
)}
<button
onClick={() => setIsEditing(true)}
style={{ background: 'none', border: '1px solid #ccc', cursor: 'pointer' }}
>
✏️
</button>
<button
onClick={() => onDelete(todo.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'red' }}
>
🗑️
</button>
</li>
);
}
// src/components/TodoFilter.jsx
import { FILTER_TYPES } from '../types/todo';
export function TodoFilter({ current, onChange, activeCount, onClearCompleted }) {
const filters = [
{ key: FILTER_TYPES.ALL, label: '전체' },
{ key: FILTER_TYPES.ACTIVE, label: `진행중 (${activeCount})` },
{ key: FILTER_TYPES.COMPLETED, label: '완료' },
];
return (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{filters.map(({ key, label }) => (
<button
key={key}
onClick={() => onChange(key)}
style={{ fontWeight: current === key ? 'bold' : 'normal' }}
>
{label}
</button>
))}
<button onClick={onClearCompleted} style={{ marginLeft: 'auto', color: 'red' }}>
완료 항목 삭제
</button>
</div>
);
}
// src/App.jsx — 최상위 조합
import { useState, useMemo } from 'react';
import { useTodos } from './hooks/useTodos';
import { TodoInput } from './components/TodoInput';
import { TodoItem } from './components/TodoItem';
import { TodoFilter } from './components/TodoFilter';
import { FILTER_TYPES } from './types/todo';
function App() {
const [filter, setFilter] = useState(FILTER_TYPES.ALL);
const { todos, addTodo, toggleTodo, deleteTodo, editTodo, clearCompleted, toggleAll } = useTodos();
const filteredTodos = useMemo(() => {
switch (filter) {
case FILTER_TYPES.ACTIVE: return todos.filter(t => !t.done);
case FILTER_TYPES.COMPLETED: return todos.filter(t => t.done);
default: return todos;
}
}, [todos, filter]);
const activeCount = todos.filter(t => !t.done).length;
return (
<div style={{ maxWidth: '600px', margin: '40px auto', padding: '0 20px' }}>
<h1>할 일 목록</h1>
<TodoInput onAdd={addTodo} />
{todos.length > 0 && (
<>
<div style={{ margin: '16px 0' }}>
<button onClick={toggleAll}>
{todos.every(t => t.done) ? '전체 미완료' : '전체 완료'}
</button>
</div>
<TodoFilter
current={filter}
onChange={setFilter}
activeCount={activeCount}
onClearCompleted={clearCompleted}
/>
{filteredTodos.length === 0 ? (
<p style={{ textAlign: 'center', color: '#999', padding: '20px' }}>
해당 항목이 없습니다.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={toggleTodo}
onDelete={deleteTodo}
onEdit={editTodo}
/>
))}
</ul>
)}
<p style={{ color: '#666', fontSize: '14px' }}>
총 {todos.length}개 | 완료 {todos.length - activeCount}개 | 진행중 {activeCount}개
</p>
</>
)}
{todos.length === 0 && (
<p style={{ textAlign: 'center', color: '#999', padding: '40px' }}>
할 일을 추가해보세요! 🎯
</p>
)}
</div>
);
}
export default App;
⚠️ 흔한 실수 (よくあるミス)
localStorage 직접 읽기/쓰기 : 컴포넌트 안에서 localStorage.setItem을 직접 호출하면 useEffect 없이 렌더링 중 side effect가 발생합니다. 반드시 useEffect 또는 커스텀 훅을 통해 동기화하세요.
JSON.parse 에러 처리 누락 : localStorage 값이 유효하지 않은 JSON이면 파싱이 실패합니다. try-catch로 감싸세요.
수정 중 외부 클릭 처리 누락 : 편집 모드에서 다른 곳을 클릭했을 때 자동 저장되도록 onBlur를 처리하면 UX가 개선됩니다.
필터링을 렌더링마다 계산 : todos.filter(...)는 useMemo로 감싸서 todos나 filter가 변경될 때만 재계산하세요.
💡 실무 팁
드래그 앤 드롭 순서 변경 : @dnd-kit/core 라이브러리로 드래그로 할 일 순서를 변경하는 기능을 추가할 수 있습니다.
마감일 기능 추가 : dueDate 필드를 추가하고 <input type="date">로 마감일을 설정하면 실용적인 Todo 앱이 됩니다.
PWA로 전환 : Vite PWA 플러그인을 추가하면 오프라인에서도 동작하는 설치 가능한 앱으로 만들 수 있습니다.
▶ 예제 실행하기