리액트 기초 강좌(#1~#15)를 마치신 분들을 위한 실전 빌드 시리즈를 시작합니다. 첫 프로젝트는 모든 프레임워크 입문자가 한 번씩은 만들어보는 Todo 앱입니다. 작아 보이지만 컴포넌트 분해, state 관리, 이벤트, 폼, 리스트 렌더링, 영속화까지 리액트의 거의 모든 기본기가 자연스럽게 녹아드는 좋은 연습 주제입니다.
5편으로 나눠 점진적으로 기능을 쌓아갑니다.
- #1 시작과 추가/삭제 ← 이번 글
- #2 완료 토글과 통계
- #3 필터링
- #4 편집 기능
- #5 영속화와 마무리
요구사항 정의
뭐든 만들기 전에 무엇을 만들 건지부터 명확히 적는 습관이 중요합니다. 머릿속에서만 굴리지 말고 한두 줄이라도 써두세요.
이번 시리즈가 끝나면 우리 앱은 다음을 할 수 있습니다.
- 새 할 일 입력하고 추가
- 할 일 목록 표시
- 항목 삭제
- 항목 완료 표시(체크박스)
- 남은 개수 / 전체 개수 표시
- 전체 / 미완료 / 완료로 필터링
- 일괄 처리(전부 완료, 완료 항목 삭제)
- 항목 인라인 편집
- 새로고침해도 데이터 유지(localStorage)
이번 글에서는 그중 추가/목록/삭제까지만 다룹니다. 나머지는 다음 글들에서 차근차근 쌓아갑니다.
컴포넌트 트리 설계
코드를 짜기 전에 컴포넌트를 어떻게 나눌지도 미리 그려보면 좋습니다. 너무 잘게 쪼개려고 욕심내지 말고, 처음에는 큰 그림 위주로 단순하게.
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에서 시작하겠습니다.
function App() {
return <h1>Todo 앱</h1>;
}
export default App;src/App.css나 src/index.css도 비워두거나 최소한만 남겨두세요. 이번 시리즈는 스타일을 인라인 style로 처리해 코드를 단순하게 유지합니다 (실전에선 CSS 모듈, Tailwind, styled-components 등을 쓰겠지만 지금은 핵심 로직에 집중).
데이터 모양 정하기
각 할 일은 어떤 정보를 가질까요? 최소한:
{
id: '고유 ID',
text: '할 일 내용',
completed: false,
}id는 crypto.randomUUID()로 만든 UUID를 쓰겠습니다. #8에서 배운 대로 인덱스 key는 안티패턴이니까요.
TodoForm 만들기
입력 폼부터 만들어봅시다. controlled component(#9)로, 폼 제출 시 부모에 새 항목을 알리는 구조입니다.
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;핵심 포인트:
textstate는 이 폼 안에서만 쓰니 여기 둠onAdd는 부모에게 "새 항목이 들어왔다"고 알리는 콜백 (props로 받음)text.trim()으로 공백만 입력한 경우는 무시- 추가 후 입력창은 비움
- 빈 입력일 때 버튼 비활성화
TodoItem 만들기
개별 항목 컴포넌트입니다. 일단 텍스트와 삭제 버튼만 있는 상태로 시작합니다 (체크박스는 #2에서, 편집 기능은 #4에서 추가).
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:
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:
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을 렌더링합니다.
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)에만 있고, 모든 변경은 그곳을 거친다는 점입니다. TodoForm도 TodoItem도 직접 데이터를 건드리지 않고, 콜백으로 "이렇게 해주세요"라고 요청만 합니다. 이게 #11에서 배운 단방향 데이터 흐름과 상태 끌어올리기 패턴이 실전에서 작동하는 모습입니다.
컴포넌트 파일을 어디에 둘지 고민이 될 수 있습니다. 작은 프로젝트는 src/ 바로 아래 평평하게 두고, 늘어나면 src/components/Todo/...처럼 묶는 식으로 점진적으로 정리하는 게 일반적입니다. 처음부터 깊은 폴더 구조를 만들면 오히려 코드 찾기 힘들어요.
마무리
이번 글에서는 Todo 앱의 첫걸음을 떼었습니다.
- 요구사항을 적고, 컴포넌트 트리를 그렸다
TodoForm/TodoItem/TodoList/TodoApp로 책임을 분리했다- state는 공통 부모(
TodoApp)에만 두고 콜백으로 변경 요청을 받는 단방향 흐름을 만들었다 crypto.randomUUID()로 안전한 key를 사용했다
지금 우리 앱은 추가하고 삭제하는 일밖에 못 합니다. 다음 글인 "리액트로 Todo 앱 만들기 #2 완료 토글과 통계"에서는 각 항목에 체크박스를 달아 완료 표시를 하고, 남은 개수 / 전체 개수를 보여주는 통계를 추가해보겠습니다.