리액트로 Todo 앱 만들기 #1 시작과 추가/삭제

13 분 소요

리액트 기초 강좌(#1~#15)를 마치신 분들을 위한 실전 빌드 시리즈를 시작합니다. 첫 프로젝트는 모든 프레임워크 입문자가 한 번씩은 만들어보는 Todo 앱입니다. 작아 보이지만 컴포넌트 분해, state 관리, 이벤트, 폼, 리스트 렌더링, 영속화까지 리액트의 거의 모든 기본기가 자연스럽게 녹아드는 좋은 연습 주제입니다.

5편으로 나눠 점진적으로 기능을 쌓아갑니다.

  • #1 시작과 추가/삭제 ← 이번 글
  • #2 완료 토글과 통계
  • #3 필터링
  • #4 편집 기능
  • #5 영속화와 마무리

요구사항 정의

뭐든 만들기 전에 무엇을 만들 건지부터 명확히 적는 습관이 중요합니다. 머릿속에서만 굴리지 말고 한두 줄이라도 써두세요.

이번 시리즈가 끝나면 우리 앱은 다음을 할 수 있습니다.

  • 새 할 일 입력하고 추가
  • 할 일 목록 표시
  • 항목 삭제
  • 항목 완료 표시(체크박스)
  • 남은 개수 / 전체 개수 표시
  • 전체 / 미완료 / 완료로 필터링
  • 일괄 처리(전부 완료, 완료 항목 삭제)
  • 항목 인라인 편집
  • 새로고침해도 데이터 유지(localStorage)

이번 글에서는 그중 추가/목록/삭제까지만 다룹니다. 나머지는 다음 글들에서 차근차근 쌓아갑니다.

컴포넌트 트리 설계

코드를 짜기 전에 컴포넌트를 어떻게 나눌지도 미리 그려보면 좋습니다. 너무 잘게 쪼개려고 욕심내지 말고, 처음에는 큰 그림 위주로 단순하게.

컴포넌트 트리 (1편 기준)
App
└── TodoApp
    ├── TodoForm        — 입력 폼
    └── TodoList        — 목록 컨테이너
        └── TodoItem    — 개별 항목 (반복)

state는 어디에 둘까요? 입력값(TodoForm)은 그 폼만 알면 되니 거기에 두고, 할 일 목록 자체는 TodoApp에 둡니다. 폼이 항목을 추가하거나, 항목이 자기를 삭제하려면 결국 같은 목록을 건드려야 하므로 #11에서 배운 상태 끌어올리기 패턴이 자연스럽게 적용됩니다.

프로젝트 시작

기초 강좌 #2에서 만든 Vite 프로젝트가 있다면 그걸 그대로 써도 되고, 새로 만들어도 됩니다.

새 프로젝트로 시작할 경우
npm create vite@latest todo-app
cd todo-app
npm install
npm run dev

옵션은 React + JavaScript로 선택합니다. 시작 후 기본 보일러플레이트는 다 지우고 빈 App.jsx에서 시작하겠습니다.

src/App.jsx (초기 상태)
function App() {
  return <h1>Todo 앱</h1>;
}
 
export default App;

src/App.csssrc/index.css도 비워두거나 최소한만 남겨두세요. 이번 시리즈는 스타일을 인라인 style로 처리해 코드를 단순하게 유지합니다 (실전에선 CSS 모듈, Tailwind, styled-components 등을 쓰겠지만 지금은 핵심 로직에 집중).

데이터 모양 정하기

각 할 일은 어떤 정보를 가질까요? 최소한:

할 일 객체 모양
{
  id: '고유 ID',
  text: '할 일 내용',
  completed: false,
}

idcrypto.randomUUID()로 만든 UUID를 쓰겠습니다. #8에서 배운 대로 인덱스 key는 안티패턴이니까요.

TodoForm 만들기

입력 폼부터 만들어봅시다. controlled component(#9)로, 폼 제출 시 부모에 새 항목을 알리는 구조입니다.

src/TodoForm.jsx를 새로 만듭니다.

src/TodoForm.jsx
import { useState } from 'react';
 
function TodoForm({ onAdd }) {
  const [text, setText] = useState('');
 
  function handleSubmit(e) {
    e.preventDefault();
    const trimmed = text.trim();
    if (!trimmed) return;
    onAdd(trimmed);
    setText('');
  }
 
  return (
    <form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="할 일을 입력하세요"
        style={{ flex: 1, padding: '6px' }}
      />
      <button type="submit" disabled={!text.trim()}>추가</button>
    </form>
  );
}
 
export default TodoForm;

핵심 포인트:

  • text state는 이 폼 안에서만 쓰니 여기 둠
  • onAdd는 부모에게 "새 항목이 들어왔다"고 알리는 콜백 (props로 받음)
  • text.trim()으로 공백만 입력한 경우는 무시
  • 추가 후 입력창은 비움
  • 빈 입력일 때 버튼 비활성화

TodoItem 만들기

개별 항목 컴포넌트입니다. 일단 텍스트와 삭제 버튼만 있는 상태로 시작합니다 (체크박스는 #2에서, 편집 기능은 #4에서 추가).

src/TodoItem.jsx:

src/TodoItem.jsx
function TodoItem({ todo, onDelete }) {
  return (
    <li style={{
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'space-between',
      padding: '8px',
      borderBottom: '1px solid #eee',
    }}>
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)} style={{ marginLeft: '8px' }}>
        삭제
      </button>
    </li>
  );
}
 
export default TodoItem;

onDelete도 부모에서 내려받습니다. 자기 자신을 삭제할 권한은 없고(#4의 props 읽기 전용 원칙 기억하시죠?), "내 ID로 삭제해줘"라고 부모에게 요청하는 형태입니다.

TodoList 만들기

TodoItem들을 그리는 컨테이너입니다. 빈 상태일 때 안내 문구도 처리합니다 (#7 조건부 렌더링).

src/TodoList.jsx:

src/TodoList.jsx
import TodoItem from './TodoItem';
 
function TodoList({ todos, onDelete }) {
  if (todos.length === 0) {
    return <p style={{ color: '#888' }}>할 일이 없습니다. 새로 추가해보세요.</p>;
  }
 
  return (
    <ul style={{ listStyle: 'none', padding: 0 }}>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onDelete={onDelete} />
      ))}
    </ul>
  );
}
 
export default TodoList;

TodoApp으로 묶기

이제 모든 조각을 모아보겠습니다. state(todos)는 여기에 살고, 추가/삭제 핸들러도 여기서 정의해 자식들에 내려줍니다.

src/TodoApp.jsx:

src/TodoApp.jsx
import { useState } from 'react';
import TodoForm from './TodoForm';
import TodoList from './TodoList';
 
function TodoApp() {
  const [todos, setTodos] = useState([]);
 
  function addTodo(text) {
    const newTodo = {
      id: crypto.randomUUID(),
      text,
      completed: false,
    };
    setTodos(prev => [newTodo, ...prev]);
  }
 
  function deleteTodo(id) {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }
 
  return (
    <div style={{ maxWidth: '500px', margin: '0 auto', padding: '24px' }}>
      <h1>Todo</h1>
      <TodoForm onAdd={addTodo} />
      <TodoList todos={todos} onDelete={deleteTodo} />
    </div>
  );
}
 
export default TodoApp;

핵심 패턴:

  • setTodos(prev => [newTodo, ...prev]) — 새 항목을 맨 위에 추가하면서 새 배열을 만들어 전달 (#5에서 배운 불변 업데이트)
  • setTodos(prev => prev.filter(todo => todo.id !== id)) — 삭제도 마찬가지로 새 배열로
  • 함수형 업데이트(prev => ...)를 쓰는 이유는 #5에서 다뤘듯이 이전 값을 안전하게 받기 위함

마지막으로 App.jsx에서 TodoApp을 렌더링합니다.

src/App.jsx
import TodoApp from './TodoApp';
 
function App() {
  return <TodoApp />;
}
 
export default App;

저장하고 브라우저에서 확인해보세요. 입력창에 할 일을 적고 추가 버튼을 누르면 위쪽에 추가되고, 삭제 버튼을 누르면 사라집니다.

동작 확인 체크리스트

  • 빈 입력은 추가되지 않는가
  • 공백만 입력한 경우(" ")도 추가 안 되는가
  • 새 항목이 맨 위에 추가되는가 (시간 순으로 최신이 위)
  • 같은 텍스트를 두 번 추가해도 별개 항목으로 들어가는가 (각자 다른 UUID)
  • 삭제 버튼이 정확히 그 항목만 삭제하는가

다 잘 동작하면 1단계 완성입니다.

데이터 흐름 다시 보기

지금까지 만든 구조를 한 번 정리해보세요.

데이터 흐름
TodoApp (todos state)
  ├─ addTodo / deleteTodo 함수 보유

  ├─ TodoForm
  │   - onAdd={addTodo}    ← 새 항목 알림

  └─ TodoList
      - todos 표시
      - onDelete={deleteTodo}    ← 삭제 요청
        └─ TodoItem (각 항목)

핵심은 데이터(todos)는 한 곳(TodoApp)에만 있고, 모든 변경은 그곳을 거친다는 점입니다. TodoFormTodoItem도 직접 데이터를 건드리지 않고, 콜백으로 "이렇게 해주세요"라고 요청만 합니다. 이게 #11에서 배운 단방향 데이터 흐름과 상태 끌어올리기 패턴이 실전에서 작동하는 모습입니다.

컴포넌트 파일을 어디에 둘지 고민이 될 수 있습니다. 작은 프로젝트는 src/ 바로 아래 평평하게 두고, 늘어나면 src/components/Todo/...처럼 묶는 식으로 점진적으로 정리하는 게 일반적입니다. 처음부터 깊은 폴더 구조를 만들면 오히려 코드 찾기 힘들어요.

마무리

이번 글에서는 Todo 앱의 첫걸음을 떼었습니다.

  • 요구사항을 적고, 컴포넌트 트리를 그렸다
  • TodoForm / TodoItem / TodoList / TodoApp로 책임을 분리했다
  • state는 공통 부모(TodoApp)에만 두고 콜백으로 변경 요청을 받는 단방향 흐름을 만들었다
  • crypto.randomUUID()로 안전한 key를 사용했다

지금 우리 앱은 추가하고 삭제하는 일밖에 못 합니다. 다음 글인 "리액트로 Todo 앱 만들기 #2 완료 토글과 통계"에서는 각 항목에 체크박스를 달아 완료 표시를 하고, 남은 개수 / 전체 개수를 보여주는 통계를 추가해보겠습니다.