웹 프로그래밍

간단한 TODO 만들기

nextjs
Published

December 8, 2025

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:3000

1.2 Task 타입 정의

  1. app/types.ts 파일을 생성합니다.
  2. Task 인터페이스를 정의합니다.
export interface Task {
  id: string;
  text: string;
  completed: boolean;
  children: Task[];
}

1.3 기본 목록 표시

  1. 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 추가 기능

  1. 입력 필드와 추가 버튼을 추가합니다.
  2. 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("");
  }
};
  1. 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 완료 표시 기능

  1. 각 Task에 체크박스를 추가합니다.
  2. handleToggle 함수를 구현합니다.
const handleToggle = (id: string) => {
  setTasks((prevTasks) =>
    prevTasks.map((task) =>
      task.id === id
        ? { ...task, completed: !task.completed }
        : task
    )
  );
};
  1. 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 구조

  1. app/components/TaskItem.tsx 파일을 생성합니다.
  2. 재귀적으로 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>
  );
}
  1. 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 추가 기능

  1. app/page.tsxhandleAddChild 함수를 추가합니다.
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);
  });
};
  1. 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 완료 시 부모 취소선 표시

  1. TaskItem 컴포넌트에서 모든 하위 Task가 완료되었는지 확인하는 로직을 추가합니다.
const allChildrenCompleted =
  task.children.length > 0 &&
  task.children.every((child) => child.completed);

const shouldShowStrikethrough = task.completed || allChildrenCompleted;
  1. 취소선 표시 로직을 적용합니다.
<span
  className={
    shouldShowStrikethrough
      ? "line-through text-gray-500"
      : ""
  }
>
  {task.text}
</span>
  1. 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);
};
  1. TaskItemonToggle prop을 추가하고 체크박스를 추가합니다.
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 편집 기능

  1. app/page.tsxhandleUpdateText 함수를 추가합니다.
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);
};
  1. 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 삭제 기능

  1. app/page.tsxhandleDelete 함수를 추가합니다.
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),
    }));
  });
};
  1. TaskItem 컴포넌트에 삭제 버튼을 추가합니다.
<button
  onClick={() => onDelete(task.id)}
  className="px-2 py-1 text-sm text-red-600"
>
  삭제
</button>

5. Local Storage 저장 기능

  1. 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 체크박스 제거

  1. TaskItem 컴포넌트에서 level prop을 사용하여 최상위 Task에는 체크박스를 표시하지 않도록 수정합니다.
{level > 0 && (
  <input
    type="checkbox"
    checked={task.completed}
    onChange={() => onToggle(task.id)}
    className="w-4 h-4 cursor-pointer"
  />
)}

추가 도전 과제

  1. 드래그 앤 드롭: Task 순서를 변경할 수 있는 기능 추가
  2. 날짜/시간: Task에 마감일 추가
  3. 우선순위: Task에 우선순위 설정 기능 추가
  4. 검색 기능: Task 검색 기능 추가
  5. 필터링: 완료/미완료 Task 필터링 기능 추가
  6. 카테고리: Task 카테고리 분류 기능 추가

참고 자료