Skip to content

Latest commit

 

History

History
750 lines (625 loc) · 14.8 KB

File metadata and controls

750 lines (625 loc) · 14.8 KB

04. Todo 앱 만들기 - 종합 프로젝트

프로젝트 개요

지금까지 배운 모든 내용을 활용하여 완전한 기능을 가진 Todo 앱을 만들어봅니다.

구현할 기능

✅ Todo 추가 ✅ Todo 완료 토글 ✅ Todo 삭제 ✅ 필터링 (전체/완료/미완료) ✅ LocalStorage에 데이터 저장 ✅ TypeScript 타입 정의

1. 프로젝트 설정

# 프로젝트 생성
npm create vite@latest todo-app -- --template react-ts
cd todo-app
npm install
npm run dev

2. 타입 정의

먼저 Todo 데이터 구조를 TypeScript로 정의합니다.

// src/types/todo.ts
export interface Todo {
  id: number;
  text: string;
  completed: boolean;
  createdAt: Date;
}

export type FilterType = "all" | "active" | "completed";

3. App 컴포넌트 구조

// 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;

4. TodoInput 컴포넌트

// 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;

5. TodoList 컴포넌트

// 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;

6. TodoItem 컴포넌트

// 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;

7. TodoFilter 컴포넌트

// 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;

8. 스타일링

App.css

/* 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;
}

TodoInput.css

/* 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);
}

TodoList.css

/* 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;
}

TodoItem.css

/* 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);
}

TodoFilter.css

/* 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;
}

index.css (전역 스타일)

/* 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;
}

9. 추가 기능 구현하기

전체 삭제 기능

// App.tsx에 추가
const clearCompleted = () => {
  setTodos(todos.filter(todo => !todo.completed));
};

// JSX에 버튼 추가
<button onClick={clearCompleted} className="clear-button">
  완료된 항목 삭제
</button>

Todo 편집 기능

// 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);
});

10. 성능 최적화

useMemo로 필터링 최적화

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]);

useCallback으로 함수 메모이제이션

import { useCallback } from "react";

const toggleTodo = useCallback((id: number) => {
  setTodos(prev => prev.map(todo =>
    todo.id === id
      ? { ...todo, completed: !todo.completed }
      : todo
  ));
}, []);

11. 완성된 프로젝트 구조

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

12. 테스트 및 디버깅

브라우저 개발자 도구에서 확인

// 디버깅용 useEffect 추가
useEffect(() => {
  console.log("현재 Todos:", todos);
  console.log("현재 필터:", filter);
}, [todos, filter]);

LocalStorage 확인

브라우저 개발자 도구 → Application → Local Storage에서 todos 키 확인

실습 과제

다음 기능들을 추가로 구현해보세요:

1. 우선순위 기능

// TODO: Todo에 우선순위 추가 (높음/중간/낮음)
// - 우선순위별로 다른 색상 배지 표시
// - 우선순위별 정렬 옵션

2. 카테고리 기능

// TODO: Todo에 카테고리 추가 (업무/개인/쇼핑 등)
// - 카테고리 선택 드롭다운
// - 카테고리별 필터링

3. 마감일 기능

// TODO: Todo에 마감일 추가
// - 날짜 선택기 (input type="date")
// - 마감일이 지난 항목 하이라이트

4. 검색 기능

// 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)