프로젝트

React 강좌 14강 — 프로젝트①: Glassmorphism Todo App 완전 구현

🎯 학습 목표

  • 실제 프로젝트 파일 구조(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 플러그인을 추가하면 오프라인에서도 동작하는 설치 가능한 앱으로 만들 수 있습니다.