지금까지 배운 모든 내용을 활용하여 완전한 기능을 가진 Todo 앱을 만들어봅니다.
✅ Todo 추가 ✅ Todo 완료 토글 ✅ Todo 삭제 ✅ 필터링 (전체/완료/미완료) ✅ LocalStorage에 데이터 저장 ✅ TypeScript 타입 정의
# 프로젝트 생성
npm create vite@latest todo-app -- --template react-ts
cd todo-app
npm install
npm run dev먼저 Todo 데이터 구조를 TypeScript로 정의합니다.
// src/types/todo.ts
export interface Todo {
id: number;
text: string;
completed: boolean;
createdAt: Date;
}
export type FilterType = "all" | "active" | "completed";// src/App.tsx
import { useState, useEffect } from "react";
import { Todo, FilterType } from "./types/todo";
import TodoInput from "./components/TodoInput";
import TodoList from "./components/TodoList";
import TodoFilter from "./components/TodoFilter";
import "./App.css";
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<FilterType>("all");
// LocalStorage에서 불러오기
useEffect(() => {
const savedTodos = localStorage.getItem("todos");
if (savedTodos) {
const parsed = JSON.parse(savedTodos);
// Date 객체 복원
const todosWithDates = parsed.map((todo: any) => ({
...todo,
createdAt: new Date(todo.createdAt)
}));
setTodos(todosWithDates);
}
}, []);
// LocalStorage에 저장
useEffect(() => {
if (todos.length > 0) {
localStorage.setItem("todos", JSON.stringify(todos));
}
}, [todos]);
// Todo 추가
const addTodo = (text: string) => {
const newTodo: Todo = {
id: Date.now(),
text,
completed: false,
createdAt: new Date()
};
setTodos([newTodo, ...todos]);
};
// Todo 토글
const toggleTodo = (id: number) => {
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
));
};
// Todo 삭제
const deleteTodo = (id: number) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// 필터링된 Todo 목록
const filteredTodos = todos.filter(todo => {
if (filter === "active") return !todo.completed;
if (filter === "completed") return todo.completed;
return true;
});
// 통계
const totalCount = todos.length;
const activeCount = todos.filter(t => !t.completed).length;
const completedCount = todos.filter(t => t.completed).length;
return (
<div className="app">
<header className="app-header">
<h1>📝 My Todo App</h1>
<p className="stats">
전체: {totalCount} | 완료: {completedCount} | 남은 일: {activeCount}
</p>
</header>
<main className="app-main">
<TodoInput onAddTodo={addTodo} />
<TodoFilter currentFilter={filter} onFilterChange={setFilter} />
<TodoList
todos={filteredTodos}
onToggleTodo={toggleTodo}
onDeleteTodo={deleteTodo}
/>
</main>
</div>
);
}
export default App;// src/components/TodoInput.tsx
import { useState } from "react";
import "./TodoInput.css";
interface TodoInputProps {
onAddTodo: (text: string) => void;
}
function TodoInput({ onAddTodo }: TodoInputProps) {
const [input, setInput] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (input.trim()) {
onAddTodo(input.trim());
setInput("");
}
};
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="할 일을 입력하세요..."
className="todo-input-field"
/>
<button type="submit" className="todo-input-button">
추가
</button>
</form>
);
}
export default TodoInput;// src/components/TodoList.tsx
import { Todo } from "../types/todo";
import TodoItem from "./TodoItem";
import "./TodoList.css";
interface TodoListProps {
todos: Todo[];
onToggleTodo: (id: number) => void;
onDeleteTodo: (id: number) => void;
}
function TodoList({ todos, onToggleTodo, onDeleteTodo }: TodoListProps) {
if (todos.length === 0) {
return (
<div className="todo-list-empty">
<p>할 일이 없습니다! 🎉</p>
</div>
);
}
return (
<ul className="todo-list">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggleTodo}
onDelete={onDeleteTodo}
/>
))}
</ul>
);
}
export default TodoList;// src/components/TodoItem.tsx
import { Todo } from "../types/todo";
import "./TodoItem.css";
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
const handleDelete = () => {
if (confirm("정말 삭제하시겠습니까?")) {
onDelete(todo.id);
}
};
// 날짜 포맷팅
const formatDate = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "방금 전";
if (minutes < 60) return `${minutes}분 전`;
if (hours < 24) return `${hours}시간 전`;
return `${days}일 전`;
};
return (
<li className={`todo-item ${todo.completed ? "completed" : ""}`}>
<div className="todo-item-content">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className="todo-checkbox"
/>
<div className="todo-text-wrapper">
<span className="todo-text">{todo.text}</span>
<span className="todo-date">{formatDate(todo.createdAt)}</span>
</div>
</div>
<button
onClick={handleDelete}
className="todo-delete-button"
aria-label="삭제"
>
🗑️
</button>
</li>
);
}
export default TodoItem;// src/components/TodoFilter.tsx
import { FilterType } from "../types/todo";
import "./TodoFilter.css";
interface TodoFilterProps {
currentFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
}
function TodoFilter({ currentFilter, onFilterChange }: TodoFilterProps) {
const filters: { value: FilterType; label: string }[] = [
{ value: "all", label: "전체" },
{ value: "active", label: "미완료" },
{ value: "completed", label: "완료" }
];
return (
<div className="todo-filter">
{filters.map(filter => (
<button
key={filter.value}
onClick={() => onFilterChange(filter.value)}
className={`filter-button ${
currentFilter === filter.value ? "active" : ""
}`}
>
{filter.label}
</button>
))}
</div>
);
}
export default TodoFilter;/* src/App.css */
.app {
max-width: 600px;
margin: 0 auto;
padding: 20px;
min-height: 100vh;
}
.app-header {
text-align: center;
margin-bottom: 30px;
}
.app-header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
color: #333;
}
.stats {
color: #666;
font-size: 0.9rem;
}
.app-main {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}/* src/components/TodoInput.css */
.todo-input {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.todo-input-field {
flex: 1;
padding: 12px 16px;
font-size: 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
outline: none;
transition: border-color 0.2s;
}
.todo-input-field:focus {
border-color: #4a90e2;
}
.todo-input-button {
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
color: white;
background-color: #4a90e2;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.todo-input-button:hover {
background-color: #357abd;
}
.todo-input-button:active {
transform: translateY(1px);
}/* src/components/TodoList.css */
.todo-list {
list-style: none;
padding: 0;
margin: 0;
}
.todo-list-empty {
text-align: center;
padding: 40px 20px;
color: #999;
}
.todo-list-empty p {
font-size: 1.2rem;
}/* src/components/TodoItem.css */
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
transition: background-color 0.2s;
}
.todo-item:hover {
background-color: #f9f9f9;
}
.todo-item:last-child {
border-bottom: none;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}
.todo-item-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.todo-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
}
.todo-text-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.todo-text {
font-size: 16px;
color: #333;
}
.todo-date {
font-size: 12px;
color: #999;
}
.todo-delete-button {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 8px;
opacity: 0.6;
transition: opacity 0.2s, transform 0.2s;
}
.todo-delete-button:hover {
opacity: 1;
transform: scale(1.2);
}/* src/components/TodoFilter.css */
.todo-filter {
display: flex;
gap: 10px;
margin-bottom: 20px;
padding: 10px;
background-color: #f5f5f5;
border-radius: 8px;
}
.filter-button {
flex: 1;
padding: 10px;
font-size: 14px;
font-weight: 600;
color: #666;
background-color: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.filter-button:hover {
background-color: #e0e0e0;
}
.filter-button.active {
color: white;
background-color: #4a90e2;
}/* src/index.css */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
button {
font-family: inherit;
}
input {
font-family: inherit;
}// App.tsx에 추가
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.completed));
};
// JSX에 버튼 추가
<button onClick={clearCompleted} className="clear-button">
완료된 항목 삭제
</button>// TodoItem.tsx에 편집 모드 추가
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
const handleSave = () => {
if (editText.trim()) {
onEdit(todo.id, editText.trim());
setIsEditing(false);
}
};
return (
<li className="todo-item">
{isEditing ? (
<input
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
autoFocus
/>
) : (
<>
<span onDoubleClick={() => setIsEditing(true)}>
{todo.text}
</span>
</>
)}
</li>
);// App.tsx에 정렬 State 추가
const [sortBy, setSortBy] = useState<"date" | "text">("date");
// 정렬된 Todo 목록
const sortedTodos = [...filteredTodos].sort((a, b) => {
if (sortBy === "date") {
return b.createdAt.getTime() - a.createdAt.getTime();
}
return a.text.localeCompare(b.text);
});import { useMemo } from "react";
const filteredTodos = useMemo(() => {
return todos.filter(todo => {
if (filter === "active") return !todo.completed;
if (filter === "completed") return todo.completed;
return true;
});
}, [todos, filter]);import { useCallback } from "react";
const toggleTodo = useCallback((id: number) => {
setTodos(prev => prev.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
));
}, []);todo-app/
├── src/
│ ├── components/
│ │ ├── TodoInput.tsx
│ │ ├── TodoInput.css
│ │ ├── TodoList.tsx
│ │ ├── TodoList.css
│ │ ├── TodoItem.tsx
│ │ ├── TodoItem.css
│ │ ├── TodoFilter.tsx
│ │ └── TodoFilter.css
│ ├── types/
│ │ └── todo.ts
│ ├── App.tsx
│ ├── App.css
│ ├── main.tsx
│ └── index.css
├── package.json
└── tsconfig.json
// 디버깅용 useEffect 추가
useEffect(() => {
console.log("현재 Todos:", todos);
console.log("현재 필터:", filter);
}, [todos, filter]);브라우저 개발자 도구 → Application → Local Storage에서 todos 키 확인
다음 기능들을 추가로 구현해보세요:
// TODO: Todo에 우선순위 추가 (높음/중간/낮음)
// - 우선순위별로 다른 색상 배지 표시
// - 우선순위별 정렬 옵션// TODO: Todo에 카테고리 추가 (업무/개인/쇼핑 등)
// - 카테고리 선택 드롭다운
// - 카테고리별 필터링// TODO: Todo에 마감일 추가
// - 날짜 선택기 (input type="date")
// - 마감일이 지난 항목 하이라이트// TODO: Todo 검색 기능
// - 검색창 추가
// - 실시간 필터링React의 핵심 개념을 모두 활용하여 완전한 Todo 앱을 만들었습니다!
- ✅ 컴포넌트 설계와 Props
- ✅ State 관리 (useState)
- ✅ Side Effect 처리 (useEffect)
- ✅ 이벤트 핸들링
- ✅ 조건부 렌더링
- ✅ 리스트 렌더링
- ✅ LocalStorage 연동
- ✅ TypeScript 타입 정의
- ✅ CSS 스타일링
- React Router (페이지 라우팅)
- Context API (전역 상태 관리)
- React Query (서버 상태 관리)
- 커스텀 Hooks
- 성능 최적화 (memo, useMemo, useCallback)
- 테스트 작성 (Jest, React Testing Library)