웹 프로그래밍
간단한 TODO 만들기
nextjs
Abstract
간단한 Todo 앱을 만들고, 각 단계별로 재귀적 구조와 상태관리를 익히는 실습입니다.
Next.js와 React를 사용하여 재귀적 구조를 가진 Todo 앱을 점진적으로 만들어보는 실습입니다. 각 단계를 순서대로 완료하면서 React의 상태 관리, 재귀 컴포넌트, Local Storage 등 다양한 개념을 학습할 수 있습니다. 완성된 페이지는 practice-nextjs-todo 를 참고하세요.
1. 프로젝트 설정 및 기본 구조
1.1 프로젝트 생성
$ npx create-next-app@latest practice-nextjs-todo --yes
$ cd practice-nextjs-todo
$ npm run dev
> practice-nextjs-todo@0.1.0 dev
> next dev
▲ Next.js 16.0.6 (Turbopack)
- Local: http://localhost:3000
- Network: http://192.168.0.39:30001.2 Task 타입 정의
app/types.ts파일을 생성합니다.- Task 인터페이스를 정의합니다.
export interface Task {
id: string;
text: string;
completed: boolean;
children: Task[];
}
1.3 기본 목록 표시
app/page.tsx를 수정하여 하드코딩된 Task 목록을 표시합니다.
"use client";
import { useState } from "react";
import { Task } from "./types";
export default function Home() {
const [tasks] = useState<Task[]>([
{
id: "1",
text: "첫 번째 작업",
completed: false,
children: [],
},
{
id: "2",
text: "두 번째 작업",
completed: true,
children: [],
},
]);
return (
<div className="min-h-screen bg-zinc-50 dark:bg-black py-8 px-4">
<div className="max-w-3xl mx-auto">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6">
<h1 className="text-3xl font-bold mb-6 text-black dark:text-zinc-50">
재귀적 Todo 앱
</h1>
<div className="space-y-2">
{tasks.map((task) => (
<div key={task.id} className="p-2 border-b">
<span>{task.text}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
2. 기본 CRUD 기능
2.1 Task 추가 기능
- 입력 필드와 추가 버튼을 추가합니다.
handleAddTask함수를 구현합니다.
const [tasks, setTasks] = useState<Task[]>([]);
const [newTaskText, setNewTaskText] = useState("");
const handleAddTask = () => {
if (newTaskText.trim()) {
const newTask: Task = {
id: `${Date.now()}-${Math.random()}`,
text: newTaskText.trim(),
completed: false,
children: [],
};
setTasks((prevTasks) => [...prevTasks, newTask]);
setNewTaskText("");
}
};
- JSX에 입력 필드와 버튼을 추가합니다.
<div className="mb-6 flex gap-2">
<input
type="text"
value={newTaskText}
onChange={(e) => setNewTaskText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddTask();
}
}}
placeholder="새 작업 추가..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg"
/>
<button
onClick={handleAddTask}
className="px-6 py-2 bg-blue-600 text-white rounded-lg"
>
추가
</button>
</div>
2.2 Task 완료 표시 기능
- 각 Task에 체크박스를 추가합니다.
handleToggle함수를 구현합니다.
const handleToggle = (id: string) => {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task
)
);
};
- Task 렌더링 부분을 수정합니다.
{tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 py-1">
<input
type="checkbox"
checked={task.completed}
onChange={() => handleToggle(task.id)}
className="w-4 h-4 cursor-pointer"
/>
<span
className={
task.completed
? "line-through text-gray-500"
: ""
}
>
{task.text}
</span>
</div>
))}
3. 재귀적 구조 및 하위 Task 관리
3.1 재귀적 하위 Task 구조
app/components/TaskItem.tsx파일을 생성합니다.- 재귀적으로 Task를 렌더링하는 컴포넌트를 작성합니다.
"use client";
import { Task } from "../types";
interface TaskItemProps {
task: Task;
level?: number;
}
export default function TaskItem({
task,
level = 0,
}: TaskItemProps) {
return (
<div className="ml-4">
<div className="flex items-center gap-2 py-1">
<span>{task.text}</span>
</div>
{task.children.length > 0 && (
<div className="ml-4 border-l-2 border-gray-200 pl-2">
{task.children.map((child) => (
<TaskItem
key={child.id}
task={child}
level={level + 1}
/>
))}
</div>
)}
</div>
);
}
app/page.tsx에서 TaskItem 컴포넌트를 사용합니다.
import TaskItem from "./components/TaskItem";
// 하드코딩된 데이터로 테스트
const [tasks] = useState<Task[]>([
{
id: "1",
text: "프로젝트 계획",
completed: false,
children: [
{
id: "1-1",
text: "요구사항 분석",
completed: false,
children: [],
},
{
id: "1-2",
text: "디자인 설계",
completed: false,
children: [],
},
],
},
]);
// 렌더링
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
3.2 하위 Task 추가 기능
app/page.tsx에handleAddChild함수를 추가합니다.
const handleAddChild = (parentId: string, text: string) => {
const newTask: Task = {
id: `${Date.now()}-${Math.random()}`,
text,
completed: false,
children: [],
};
setTasks((prevTasks) => {
const updateTask = (tasks: Task[]): Task[] => {
return tasks.map((task) => {
if (task.id === parentId) {
return {
...task,
children: [...task.children, newTask],
};
}
return {
...task,
children: updateTask(task.children),
};
});
};
return updateTask(prevTasks);
});
};
TaskItem컴포넌트에 하위 Task 추가 기능을 추가합니다.
interface TaskItemProps {
task: Task;
onAddChild: (parentId: string, text: string) => void;
level?: number;
}
export default function TaskItem({
task,
onAddChild,
level = 0,
}: TaskItemProps) {
const [isAddingChild, setIsAddingChild] = useState(false);
const [newChildText, setNewChildText] = useState("");
const handleAddChild = () => {
if (newChildText.trim()) {
onAddChild(task.id, newChildText.trim());
setNewChildText("");
setIsAddingChild(false);
}
};
return (
<div className="ml-4">
<div className="flex items-center gap-2 py-1">
<span>{task.text}</span>
<button
onClick={() => setIsAddingChild(true)}
className="px-2 py-1 text-sm text-blue-600"
>
+ 하위 작업
</button>
</div>
{isAddingChild && (
<div className="ml-6 mb-2 flex items-center gap-2">
<input
type="text"
value={newChildText}
onChange={(e) => setNewChildText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddChild();
}
}}
placeholder="하위 작업 입력..."
className="flex-1 px-2 py-1 border rounded"
autoFocus
/>
<button onClick={handleAddChild}>추가</button>
<button onClick={() => setIsAddingChild(false)}>취소</button>
</div>
)}
{task.children.length > 0 && (
<div className="ml-4 border-l-2 pl-2">
{task.children.map((child) => (
<TaskItem
key={child.id}
task={child}
onAddChild={onAddChild}
level={level + 1}
/>
))}
</div>
)}
</div>
);
}
3.3 하위 Task 완료 시 부모 취소선 표시
TaskItem컴포넌트에서 모든 하위 Task가 완료되었는지 확인하는 로직을 추가합니다.
const allChildrenCompleted =
task.children.length > 0 &&
task.children.every((child) => child.completed);
const shouldShowStrikethrough = task.completed || allChildrenCompleted;
- 취소선 표시 로직을 적용합니다.
<span
className={
shouldShowStrikethrough
? "line-through text-gray-500"
: ""
}
>
{task.text}
</span>
app/page.tsx에서handleToggle함수를 수정하여 하위 Task에도 전달합니다.
const handleToggle = (id: string) => {
const updateTask = (tasks: Task[]): Task[] => {
return tasks.map((task) => {
if (task.id === id) {
return { ...task, completed: !task.completed };
}
return {
...task,
children: updateTask(task.children),
};
});
};
setTasks(updateTask);
};
TaskItem에onToggleprop을 추가하고 체크박스를 추가합니다.
interface TaskItemProps {
task: Task;
onToggle: (id: string) => void;
onAddChild: (parentId: string, text: string) => void;
level?: number;
}
// 체크박스 추가
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
className="w-4 h-4 cursor-pointer"
/>
4. Task 편집 및 삭제
4.1 Task 편집 기능
app/page.tsx에handleUpdateText함수를 추가합니다.
const handleUpdateText = (id: string, text: string) => {
const updateTask = (tasks: Task[]): Task[] => {
return tasks.map((task) => {
if (task.id === id) {
return { ...task, text };
}
return {
...task,
children: updateTask(task.children),
};
});
};
setTasks(updateTask);
};
TaskItem컴포넌트에 편집 기능을 추가합니다.
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(task.text);
const handleUpdateText = () => {
if (editText.trim()) {
onUpdateText(task.id, editText.trim());
setIsEditing(false);
}
};
// 렌더링 부분
{isEditing ? (
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={handleUpdateText}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUpdateText();
} else if (e.key === "Escape") {
setIsEditing(false);
setEditText(task.text);
}
}}
className="flex-1 px-2 py-1 border rounded"
autoFocus
/>
) : (
<span
onClick={() => setIsEditing(true)}
className="flex-1 cursor-pointer"
>
{task.text}
</span>
)}
4.2 Task 삭제 기능
app/page.tsx에handleDelete함수를 추가합니다.
const handleDelete = (id: string) => {
setTasks((prevTasks) => {
// 최상위 Task 삭제
const filtered = prevTasks.filter((task) => task.id !== id);
if (filtered.length !== prevTasks.length) {
return filtered;
}
// 하위 Task 삭제
const deleteTaskRecursive = (tasks: Task[]): Task[] => {
return tasks
.filter((task) => task.id !== id)
.map((task) => ({
...task,
children: deleteTaskRecursive(task.children),
}));
};
return prevTasks.map((task) => ({
...task,
children: deleteTaskRecursive(task.children),
}));
});
};
TaskItem컴포넌트에 삭제 버튼을 추가합니다.
<button
onClick={() => onDelete(task.id)}
className="px-2 py-1 text-sm text-red-600"
>
삭제
</button>
5. Local Storage 저장 기능
app/page.tsx에 Local Storage 저장/불러오기 기능을 추가합니다.
import { useState, useEffect } from "react";
const STORAGE_KEY = "todo-tasks";
export default function Home() {
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
// localStorage에서 tasks 불러오기
useEffect(() => {
try {
const savedTasks = localStorage.getItem(STORAGE_KEY);
if (savedTasks) {
const parsedTasks = JSON.parse(savedTasks);
setTasks(parsedTasks);
}
} catch (error) {
console.error("Failed to load tasks from localStorage:", error);
} finally {
setIsLoaded(true);
}
}, []);
// tasks가 변경될 때마다 localStorage에 저장
useEffect(() => {
if (isLoaded) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
} catch (error) {
console.error("Failed to save tasks to localStorage:", error);
}
}
}, [tasks, isLoaded]);
}
6. 최종 정리 및 개선
6.1 최상위 Task 체크박스 제거
TaskItem컴포넌트에서levelprop을 사용하여 최상위 Task에는 체크박스를 표시하지 않도록 수정합니다.
{level > 0 && (
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
className="w-4 h-4 cursor-pointer"
/>
)}
추가 도전 과제
- 드래그 앤 드롭: Task 순서를 변경할 수 있는 기능 추가
- 날짜/시간: Task에 마감일 추가
- 우선순위: Task에 우선순위 설정 기능 추가
- 검색 기능: Task 검색 기능 추가
- 필터링: 완료/미완료 Task 필터링 기능 추가
- 카테고리: Task 카테고리 분류 기능 추가