Todo 앱 만들기 — React + TypeScript + Tailwind CSS

React를 막 시작한 분을 위해 쓴 튜토리얼입니다. 한 단계씩 따라오시면 돼요. 이미 알 만한 내용도 한 번씩 짚고 넘어가니 부담 없이 읽으셔도 됩니다.
들어가며
다 따라하시면 이런 Todo 앱이 만들어집니다.
- 로그인 화면이 보입니다.
- 로그인하면 Todo 등록 폼과 목록이 나타납니다.
- Todo를 체크박스로 완료 처리할 수 있습니다.
- Todo를 삭제할 수 있습니다.
- Todo를 수정할 수 있습니다.
쓸 도구는 이렇습니다.
- Vite — 프로젝트를 빠르게 띄우고 실행해 주는 빌드 도구
- React — 화면을 만드는 라이브러리 (
useState, props 정도만 씁니다) - TypeScript — JavaScript에 타입을 얹은 언어
- Tailwind CSS —
class="..."만 적어서 스타일을 입히는 프레임워크 - Playwright — 화면을 자동으로 클릭하면서 테스트해 주는 도구
💡 CSS 파일에 직접 스타일을 적지 않고 Tailwind 클래스만 씁니다.
1. 사전 준비
두 가지만 깔려 있으면 됩니다.
- Node.js 20 이상 — 터미널에서
node --version을 쳐서 v20 이상이 찍히면 OK입니다. - 에디터 — VS Code를 권합니다.
이 외에 미리 설치할 건 없습니다.
2. 프로젝트 만들기
작업할 폴더로 들어간 뒤 터미널에서 아래 명령을 실행하세요.
npm create vite@latest todo-app -- --template react-ts
실행하면 todo-app 폴더가 새로 생겨요. 그 안으로 들어가서 패키지를 설치합니다.
cd todo-app npm install
설치가 끝났으면 한 번 띄워 봅니다.
npm run dev
터미널에 뜨는 주소(보통 http://localhost:5173)를 브라우저로 열면 Vite가 만들어둔 기본 화면이 나옵니다. 확인하셨으면 터미널에서 Ctrl + C로 서버를 멈춰주세요.
3. Tailwind CSS 설치
Tailwind 패키지를 설치합니다.
npm install tailwindcss @tailwindcss/vite
이제 Vite에게 "Tailwind 쓸 거다"라고 알려줘야 해요. vite.config.ts를 열고 통째로 다음과 같이 바꿉니다.
vite.config.ts
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [react(), tailwindcss()], })
그다음 src/index.css를 열어 안에 있는 내용을 전부 지우고 아래 한 줄만 남겨주세요.
src/index.css
@import "tailwindcss";
이 한 줄로 Tailwind의 유틸리티 클래스를 다 가져다 쓸 수 있게 됩니다.
4. 시작 코드 정리
Vite가 깔아둔 예제 코드는 이제 필요 없으니 우리 앱 모양으로 바꿔볼게요.
먼저 src/App.css는 안 쓰니까 삭제합니다. 에디터에서 우클릭으로 지우거나 터미널에서 아래 명령을 쳐도 됩니다.
rm src/App.css
그리고 tsconfig.app.json을 열어보면 이런 줄이 있어요.
"verbatimModuleSyntax": true,
이 값을 false로 바꿔 주세요.
"verbatimModuleSyntax": false,
💡 이 옵션을 켜두면 타입을 가져올 때 꼭
import type이라고 적어야 해요. 우리는 평범한import만 쓸 거라 끄고 갑니다.
마지막으로 src/App.tsx를 열어 전체 내용을 아래로 갈아끼웁니다.
src/App.tsx (임시)
function App() { return ( <div className="min-h-screen flex items-center justify-center bg-gray-50"> <h1 className="text-2xl font-bold text-gray-800"> Todo 앱을 만들어 봅시다! </h1> </div> ) } export default App
다시 npm run dev를 실행하면 회색 배경 가운데 검은 글씨가 떠 있을 겁니다. Tailwind가 제대로 붙었는지 확인하는 단계예요. 확인하셨으면 Ctrl + C로 다시 멈춰주세요.
만약 글씨가 가운데 오지 않거나 배경색이 안 들어오면
vite.config.ts와src/index.css를 다시 한 번 확인해 보세요.
5. API 살펴보기
백엔드 API는 이미 만들어져 있어요. 주소는 이렇습니다.
https://api.fullstackfamily.com/api/edu/ws-283fc1
⚠️ 중요 —
ws-283fc1은 강사가 알려주는 slug 값이에요. 강사·교육생마다 다르니 본인이 받은 slug로 바꿔서 쓰세요. 튜토리얼에서는ws-283fc1을 예시로 들고 있습니다.
API 응답은 전부 이런 모양으로 감싸져서 옵니다.
{ "success": true, "message": "Success", "data": { ... } }
실제로 필요한 알맹이는 data 안에 들어 있어요. 이건 잠시 후 만들 API 호출 함수에서 한 번에 처리합니다.
쓸 엔드포인트는 6개입니다.
| 메서드 | 경로 | 설명 |
|---|---|---|
| POST | /auth/login | 로그인 |
| GET | /todos | Todo 목록 |
| POST | /todos | Todo 등록 |
| PUT | /todos/{id} | Todo 수정 |
| PATCH | /todos/{id}/toggle | Todo 완료 토글 |
| DELETE | /todos/{id} | Todo 삭제 |
로그인 빼고는 모든 요청에 로그인 시 발급받은 JWT 토큰을 헤더에 같이 실어 보내야 해요.
Authorization: Bearer <토큰>
토큰은 localStorage에 저장해두고 API를 부를 때마다 꺼내서 헤더에 붙입니다.
6. 타입 정의하기
데이터 모양을 미리 정의해두면 TypeScript가 자동완성도 잘해주고 오타도 잡아줍니다. API에서 주고받을 데이터 타입을 한 군데에 모아 둘게요.
src/ 폴더 안에 types.ts 파일을 새로 만듭니다.
src/types.ts
export type Priority = 'LOW' | 'MEDIUM' | 'HIGH' export interface User { id: number username: string nickname: string createdAt: string } export interface AuthResponse { token: string user: User } export interface Todo { id: number title: string description: string | null completed: boolean priority: Priority dueDate: string | null completedAt: string | null createdAt: string updatedAt: string } export interface CreateTodoInput { title: string description?: string priority?: string dueDate?: string } export interface UpdateTodoInput { title?: string description?: string priority?: string dueDate?: string }
잠깐 풀어볼게요.
Priority는'LOW' | 'MEDIUM' | 'HIGH'셋 중 하나만 들어가는 타입입니다.interface는 객체 모양을 정의해요.Todo는id,title같은 필드를 가진 객체라는 뜻이죠.string | null처럼|로 묶으면 "둘 중 하나"라는 의미입니다.- 필드 이름 뒤에
?가 붙은 건 "있어도 되고 없어도 되는" 선택 필드예요.
7. API 호출 함수 만들기
API를 부르는 함수들을 만들 차례입니다. src/ 폴더 안에 api.ts 파일을 새로 만들어 주세요.
src/api.ts
import { AuthResponse, CreateTodoInput, Todo, UpdateTodoInput } from './types' const BASE_URL = 'https://api.fullstackfamily.com/api/edu/ws-283fc1' // ↑ 본인의 slug로 바꿔주세요 async function request<T>(path: string, options: RequestInit = {}): Promise<T> { const token = localStorage.getItem('token') const headers: any = { 'Content-Type': 'application/json', } if (token) { headers.Authorization = `Bearer ${token}` } const res = await fetch(`${BASE_URL}${path}`, { ...options, headers }) const body = await res.json() if (!res.ok || body.success === false) { throw new Error(body.message || `HTTP ${res.status}`) } return body.data } export const api = { login(username: string, password: string) { return request<AuthResponse>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }), }) }, listTodos() { return request<Todo[]>('/todos') }, createTodo(input: CreateTodoInput) { return request<Todo>('/todos', { method: 'POST', body: JSON.stringify(input), }) }, updateTodo(id: number, input: UpdateTodoInput) { return request<Todo>(`/todos/${id}`, { method: 'PUT', body: JSON.stringify(input), }) }, toggleTodo(id: number) { return request<Todo>(`/todos/${id}/toggle`, { method: 'PATCH', }) }, deleteTodo(id: number) { return request<void>(`/todos/${id}`, { method: 'DELETE', }) }, }
코드에서 짚어볼 점이 몇 가지 있어요.
request<T> 함수
여기 쓴 <T>는 호출하는 쪽에서 응답 타입을 직접 알려주는 자리예요. 예를 들어 request<AuthResponse>(...)라고 쓰면 응답을 AuthResponse 타입으로 받겠다는 뜻이 됩니다.
localStorage
저장된 토큰이 있으면 꺼내서 Authorization 헤더에 붙여요. 토큰이 아직 없는 로그인 요청에는 헤더가 안 붙고, 그 다음 요청부터는 자동으로 붙는 셈입니다.
응답 풀어내기
API가 { success, message, data }로 감싸서 응답하니까, 마지막에 body.data만 꺼내서 돌려줍니다. 컴포넌트 쪽에서는 알맹이만 받으면 되는 거죠.
에러 처리
응답이 실패하면(!res.ok이거나 success가 false인 경우) throw new Error(...)로 에러를 던집니다. 호출하는 쪽에서 try / catch로 받으면 돼요.
⚠️
BASE_URL의ws-283fc1을 본인 slug로 바꾸는 거, 꼭 잊지 마세요.
8. 단계 1 — 로그인 화면 만들기
이제 본격적으로 화면을 만들어 볼게요. 로그인 화면부터입니다.
src/ 안에 components 폴더를 만들고, 그 안에 LoginPage.tsx를 새로 만들어 주세요.
src/components/LoginPage.tsx
import { useState } from 'react' import { api } from '../api' import { User } from '../types' interface Props { onLogin: (user: User) => void } export function LoginPage({ onLogin }: Props) { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError('') setLoading(true) try { const res = await api.login(username, password) localStorage.setItem('token', res.token) localStorage.setItem('user', JSON.stringify(res.user)) onLogin(res.user) } catch { setError('아이디 또는 비밀번호가 올바르지 않습니다.') } setLoading(false) } return ( <div className="min-h-screen flex items-center justify-center bg-gray-50 p-4"> <form onSubmit={handleSubmit} className="w-full max-w-sm bg-white rounded-lg shadow p-6 space-y-4" > <h1 className="text-2xl font-bold text-gray-800 text-center">로그인</h1> <div> <label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1" > 아이디 </label> <input id="username" type="text" value={username} onChange={(e) => setUsername(e.target.value)} required className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> <div> <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1" > 비밀번호 </label> <input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> {error && ( <p data-testid="login-error" className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded" > {error} </p> )} <button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-50 transition" > {loading ? '로그인 중...' : '로그인'} </button> </form> </div> ) }
차근차근 풀어봅시다.
Props와 onLogin
LoginPage는 부모(App)에게서 onLogin이라는 함수를 받아요. 로그인이 성공하면 부모에게 "유저 정보 여기 있어요"라고 알려주는 콜백이죠. props로 함수를 넘기는 패턴, 익숙하시죠?
useState 4개
username,password— 입력칸 두 개의 값error— 빈 문자열이면 에러 없음, 메시지가 들어 있으면 빨간 박스가 뜸loading—true면 버튼이 비활성화되고 "로그인 중..."으로 바뀜
handleSubmit
<form onSubmit={...}>에 연결됩니다. 폼이 제출되면 페이지가 새로고침되는 기본 동작을 e.preventDefault()로 막고 api.login(...)을 부르죠. 성공하면 토큰과 유저 정보를 localStorage에 저장해두고 onLogin을 호출합니다. 실패하면 에러 메시지가 화면에 떠요.
{error && (...)}
JSX에서 자주 쓰는 패턴이에요. "error가 빈 문자열이면 아무것도 안 보이고, 값이 있으면 뒤의 JSX를 보여줘"라는 뜻입니다.
Tailwind 클래스 잠깐
min-h-screen— 화면 높이 가득 채우기flex items-center justify-center— 자식을 가로·세로 가운데로bg-gray-50— 회색 배경rounded-md/rounded-lg— 둥근 모서리shadow— 살짝 그림자space-y-4— 자식들 사이에 세로 간격disabled:opacity-50—disabled상태일 때 흐릿하게
이제 App.tsx에서 이 로그인 화면을 띄워볼게요.
src/App.tsx
import { useState } from 'react' import { LoginPage } from './components/LoginPage' import { User } from './types' function App() { const [user, setUser] = useState<User | null>(null) function handleLogin(loggedInUser: User) { setUser(loggedInUser) } function handleLogout() { localStorage.removeItem('token') localStorage.removeItem('user') setUser(null) } if (!user) { return <LoginPage onLogin={handleLogin} /> } return ( <div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="bg-white rounded-lg shadow p-6 text-center space-y-3"> <p className="text-lg text-gray-800">{user.nickname}님 환영합니다!</p> <button onClick={handleLogout} className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300" > 로그아웃 </button> </div> </div> ) } export default App
포인트는 if (!user) return <LoginPage ... /> 이 한 줄이에요. 로그인 전에는 user가 null이라서 로그인 화면이 뜨고, 로그인하면 user에 값이 들어가니 환영 화면으로 자연스럽게 넘어갑니다.
useState<User | null>(null)처럼 <> 안에 타입을 적은 건 "이 state는 User이거나 null이다"라고 알려주려고 그러는 거예요.
동작 확인
npm run dev
브라우저에서 로그인 화면을 띄우고, 강사가 알려준 아이디·비밀번호로 로그인해 보세요. "환영합니다!"가 뜨면 성공입니다.
💡 일부러 틀린 비밀번호로도 한 번 시도해 보세요. 빨간색 에러 박스가 뜨는지 확인할 겸요.
다 확인했으면 Ctrl + C로 서버를 멈추고 다음 단계로 넘어갑시다.
9. 단계 2 — Todo 등록 폼 만들기
로그인 후 화면에 "새 Todo 등록" 폼을 얹어볼게요.
src/components/TodoForm.tsx (새 파일)
import { useState } from 'react' import { api } from '../api' import { Todo } from '../types' interface Props { onCreated: (todo: Todo) => void } export function TodoForm({ onCreated }: Props) { const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [priority, setPriority] = useState('MEDIUM') const [dueDate, setDueDate] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState('') async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!title.trim()) return setSubmitting(true) setError('') try { const created = await api.createTodo({ title: title.trim(), description: description.trim() || undefined, priority: priority, dueDate: dueDate || undefined, }) onCreated(created) setTitle('') setDescription('') setPriority('MEDIUM') setDueDate('') } catch { setError('등록에 실패했습니다.') } setSubmitting(false) } return ( <form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-4 space-y-3" > <h2 className="text-lg font-semibold text-gray-800">새 Todo 등록</h2> <input type="text" placeholder="제목" value={title} onChange={(e) => setTitle(e.target.value)} required className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" data-testid="todo-title-input" /> <textarea placeholder="상세 설명 (선택)" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" data-testid="todo-description-input" /> <div className="flex flex-col sm:flex-row gap-3"> <select value={priority} onChange={(e) => setPriority(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" data-testid="todo-priority-select" > <option value="LOW">낮음</option> <option value="MEDIUM">보통</option> <option value="HIGH">높음</option> </select> <input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" data-testid="todo-due-date-input" /> </div> {error && <p className="text-sm text-red-600">{error}</p>} <button type="submit" disabled={submitting} className="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-50 transition" data-testid="todo-submit-button" > {submitting ? '등록 중...' : '등록'} </button> </form> ) }
설명할 만한 부분만 짚을게요.
- 제목·설명·우선순위·마감일 — 입력칸 4개 값을 각각
useState로 관리합니다. description.trim() || undefined— 설명이 빈 문자열이면 아예 안 보내려고undefined로 바꿔주는 거예요.data-testid="..."— 나중에 자동 테스트(Playwright)에서 요소를 찾으려고 박아둔 표시입니다. 화면에는 안 보여요.- 등록이 끝나면
setTitle(''),setDescription('')식으로 입력칸을 비워서 다음 입력을 받을 준비를 합니다. onCreated(created)로 새로 만든 Todo를 부모에게 넘깁니다. 부모는 이걸 받아 목록 state에 추가하면 끝이에요.
src/App.tsx 업데이트
App에서 TodoForm을 띄우고, 등록된 Todo를 받아 처리하도록 고칩니다. 목록은 다음 단계에서 만들 거니까, 일단은 console.log로 잘 받았는지만 확인해 볼게요.
import { useState } from 'react' import { LoginPage } from './components/LoginPage' import { TodoForm } from './components/TodoForm' import { Todo, User } from './types' function App() { const [user, setUser] = useState<User | null>(null) function handleLogin(loggedInUser: User) { setUser(loggedInUser) } function handleLogout() { localStorage.removeItem('token') localStorage.removeItem('user') setUser(null) } function handleCreated(todo: Todo) { console.log('새 Todo:', todo) } if (!user) { return <LoginPage onLogin={handleLogin} /> } return ( <div className="min-h-screen bg-gray-50"> <header className="bg-white shadow-sm border-b"> <div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between"> <h1 className="text-xl font-bold text-gray-800">My Todo</h1> <div className="flex items-center gap-3"> <span className="text-sm text-gray-600">{user.nickname}님</span> <button onClick={handleLogout} className="text-sm text-gray-600 hover:text-gray-900" data-testid="logout-button" > 로그아웃 </button> </div> </div> </header> <main className="max-w-2xl mx-auto p-4 space-y-4"> <TodoForm onCreated={handleCreated} /> </main> </div> ) } export default App
동작 확인
npm run dev
로그인하면 위에 "My Todo" 헤더와 로그아웃 버튼이, 아래에 "새 Todo 등록" 폼이 뜹니다. 제목을 입력하고 등록 버튼을 눌러보세요. 브라우저 개발자 도구의 콘솔에 새 Todo: { id: ..., title: ... }가 찍히면 성공이에요.
10. 단계 3 — Todo 목록 보여주기
등록된 Todo를 화면에 띄울 차례입니다. 카드 한 장(Todo 한 개)을 그리는 TodoItem과, 카드를 모아 보여주는 TodoList, 이렇게 두 개를 만들어요.
src/components/TodoItem.tsx (새 파일, 일단 보여주기만)
import { Todo } from '../types' interface Props { todo: Todo } const priorityStyle = { LOW: 'bg-gray-100 text-gray-700', MEDIUM: 'bg-yellow-100 text-yellow-800', HIGH: 'bg-red-100 text-red-700', } const priorityLabel = { LOW: '낮음', MEDIUM: '보통', HIGH: '높음', } export function TodoItem({ todo }: Props) { return ( <li className="bg-white rounded-lg shadow p-4 flex items-start gap-3" data-testid={`todo-item-${todo.id}`} > <div className="flex-1 min-w-0"> <div className="flex items-center gap-2 flex-wrap"> <h3 className="font-medium text-gray-900" data-testid={`todo-title-${todo.id}`} > {todo.title} </h3> <span className={`px-2 py-0.5 text-xs rounded-full ${priorityStyle[todo.priority]}`} > {priorityLabel[todo.priority]} </span> {todo.dueDate && ( <span className="text-xs text-gray-500"> 마감 {todo.dueDate} </span> )} </div> {todo.description && ( <p className="text-sm mt-1 text-gray-600">{todo.description}</p> )} </div> </li> ) }
설명 한 가지만요. 우선순위마다 색이 다르게 보이려고 priorityStyle 객체를 만들어 두고, priorityStyle[todo.priority]로 골라서 클래스에 끼워 넣고 있어요. 이러면 if/else를 길게 늘어놓을 필요가 없습니다.
src/components/TodoList.tsx (새 파일)
import { Todo } from '../types' import { TodoItem } from './TodoItem' interface Props { todos: Todo[] } export function TodoList({ todos }: Props) { if (todos.length === 0) { return ( <p className="text-center text-gray-500 py-8" data-testid="todo-empty" > 등록된 Todo가 없습니다. </p> ) } return ( <ul className="space-y-2" data-testid="todo-list"> {todos.map((todo) => ( <TodoItem key={todo.id} todo={todo} /> ))} </ul> ) }
todos.map(...)으로 각 Todo를 TodoItem으로 풀어 그립니다. React에서 배열을 그릴 때는 각 항목에 key를 줘야 해요. 보통 데이터의 고유한 id를 그대로 씁니다.
src/App.tsx 업데이트
App에 Todo 목록 state를 만들고, 로그인 직후에는 서버에서 목록을 받아옵니다. 등록 시에는 받은 새 Todo를 목록 맨 앞에 끼워 넣어요.
import { useState } from 'react' import { api } from './api' import { LoginPage } from './components/LoginPage' import { TodoForm } from './components/TodoForm' import { TodoList } from './components/TodoList' import { Todo, User } from './types' function App() { const [user, setUser] = useState<User | null>(null) const [todos, setTodos] = useState<Todo[]>([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') async function loadTodos() { setLoading(true) setError('') try { const list = await api.listTodos() setTodos(list) } catch { setError('목록을 불러오지 못했습니다.') } setLoading(false) } function handleLogin(loggedInUser: User) { setUser(loggedInUser) loadTodos() } function handleLogout() { localStorage.removeItem('token') localStorage.removeItem('user') setUser(null) setTodos([]) } function handleCreated(todo: Todo) { setTodos([todo, ...todos]) } if (!user) { return <LoginPage onLogin={handleLogin} /> } return ( <div className="min-h-screen bg-gray-50"> <header className="bg-white shadow-sm border-b"> <div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between"> <h1 className="text-xl font-bold text-gray-800">My Todo</h1> <div className="flex items-center gap-3"> <span className="text-sm text-gray-600">{user.nickname}님</span> <button onClick={handleLogout} className="text-sm text-gray-600 hover:text-gray-900" data-testid="logout-button" > 로그아웃 </button> </div> </div> </header> <main className="max-w-2xl mx-auto p-4 space-y-4"> <TodoForm onCreated={handleCreated} /> {error && ( <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded"> {error} </p> )} {loading ? ( <p className="text-center text-gray-500 py-8">불러오는 중...</p> ) : ( <TodoList todos={todos} /> )} </main> </div> ) } export default App
새로 들어간 부분을 짚어볼게요.
todosstate — 화면에 그릴 Todo 배열loadTodos()— 서버에서 목록을 받아와 state에 넣는 함수handleLogin— 로그인 직후loadTodos()를 불러서 목록을 가져옵니다handleCreated— 새로 만든 Todo를 배열 맨 앞에 끼워 넣어요 ([todo, ...todos])loading ? ... : ...— 불러오는 동안엔 안내 문구를, 끝나면 목록을 보여주는 분기입니다
동작 확인
npm run dev
로그인하고 Todo를 등록하면 곧바로 목록에 카드가 한 장 추가됩니다. 등록된 게 하나도 없으면 "등록된 Todo가 없습니다." 메시지가 뜨고요.
11. 단계 4 — Todo 체크하기 (완료 토글)
각 Todo 카드 왼쪽에 체크박스를 두고, 체크하면 완료 처리되도록 만들어 볼게요.
TodoItem.tsx를 통째로 아래 내용으로 갈아끼웁니다.
src/components/TodoItem.tsx (체크박스 추가)
import { api } from '../api' import { Todo } from '../types' interface Props { todo: Todo onChange: (todo: Todo) => void } const priorityStyle = { LOW: 'bg-gray-100 text-gray-700', MEDIUM: 'bg-yellow-100 text-yellow-800', HIGH: 'bg-red-100 text-red-700', } const priorityLabel = { LOW: '낮음', MEDIUM: '보통', HIGH: '높음', } export function TodoItem({ todo, onChange }: Props) { async function handleToggle() { const updated = await api.toggleTodo(todo.id) onChange(updated) } return ( <li className="bg-white rounded-lg shadow p-4 flex items-start gap-3" data-testid={`todo-item-${todo.id}`} > <input type="checkbox" checked={todo.completed} onChange={handleToggle} className="mt-1.5 h-4 w-4 cursor-pointer" data-testid={`todo-toggle-${todo.id}`} /> <div className="flex-1 min-w-0"> <div className="flex items-center gap-2 flex-wrap"> <h3 className={`font-medium ${ todo.completed ? 'line-through text-gray-400' : 'text-gray-900' }`} data-testid={`todo-title-${todo.id}`} > {todo.title} </h3> <span className={`px-2 py-0.5 text-xs rounded-full ${priorityStyle[todo.priority]}`} > {priorityLabel[todo.priority]} </span> {todo.dueDate && ( <span className="text-xs text-gray-500"> 마감 {todo.dueDate} </span> )} </div> {todo.description && ( <p className={`text-sm mt-1 ${ todo.completed ? 'text-gray-400' : 'text-gray-600' }`} > {todo.description} </p> )} </div> </li> ) }
새로 들어간 것들입니다.
onChangeprops — 토글 결과를 부모에게 알려주는 콜백handleToggle— 체크박스를 누르면api.toggleTodo를 부르고, 받은 새 Todo를 부모에게 넘겨요<input type="checkbox" checked={todo.completed} ... />— 체크박스 자체.checked는todo.completed값에 묶여 있고, 클릭하면handleToggle이 실행됩니다- 완료된 Todo는 제목에
line-through(취소선) 클래스가 붙고 회색으로 바뀝니다
이제 TodoList도 부모가 준 onChange를 자식한테 그대로 넘겨야 해요.
src/components/TodoList.tsx
import { Todo } from '../types' import { TodoItem } from './TodoItem' interface Props { todos: Todo[] onChange: (todo: Todo) => void } export function TodoList({ todos, onChange }: Props) { if (todos.length === 0) { return ( <p className="text-center text-gray-500 py-8" data-testid="todo-empty" > 등록된 Todo가 없습니다. </p> ) } return ( <ul className="space-y-2" data-testid="todo-list"> {todos.map((todo) => ( <TodoItem key={todo.id} todo={todo} onChange={onChange} /> ))} </ul> ) }
마지막으로 App.tsx에 handleChange 함수를 추가하고 TodoList로 내려주면 됩니다.
src/App.tsx 수정 부분
기존 handleCreated 아래에 handleChange 함수를 추가합니다.
function handleChange(updated: Todo) { setTodos(todos.map((t) => (t.id === updated.id ? updated : t))) }
TodoList를 쓰는 곳에 onChange도 같이 넘깁니다.
<TodoList todos={todos} onChange={handleChange} />
전체 파일은 이렇게 됩니다.
src/App.tsx (전체)
import { useState } from 'react' import { api } from './api' import { LoginPage } from './components/LoginPage' import { TodoForm } from './components/TodoForm' import { TodoList } from './components/TodoList' import { Todo, User } from './types' function App() { const [user, setUser] = useState<User | null>(null) const [todos, setTodos] = useState<Todo[]>([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') async function loadTodos() { setLoading(true) setError('') try { const list = await api.listTodos() setTodos(list) } catch { setError('목록을 불러오지 못했습니다.') } setLoading(false) } function handleLogin(loggedInUser: User) { setUser(loggedInUser) loadTodos() } function handleLogout() { localStorage.removeItem('token') localStorage.removeItem('user') setUser(null) setTodos([]) } function handleCreated(todo: Todo) { setTodos([todo, ...todos]) } function handleChange(updated: Todo) { setTodos(todos.map((t) => (t.id === updated.id ? updated : t))) } if (!user) { return <LoginPage onLogin={handleLogin} /> } return ( <div className="min-h-screen bg-gray-50"> <header className="bg-white shadow-sm border-b"> <div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between"> <h1 className="text-xl font-bold text-gray-800">My Todo</h1> <div className="flex items-center gap-3"> <span className="text-sm text-gray-600">{user.nickname}님</span> <button onClick={handleLogout} className="text-sm text-gray-600 hover:text-gray-900" data-testid="logout-button" > 로그아웃 </button> </div> </div> </header> <main className="max-w-2xl mx-auto p-4 space-y-4"> <TodoForm onCreated={handleCreated} /> {error && ( <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded"> {error} </p> )} {loading ? ( <p className="text-center text-gray-500 py-8">불러오는 중...</p> ) : ( <TodoList todos={todos} onChange={handleChange} /> )} </main> </div> ) } export default App
handleChange를 잠깐 뜯어볼게요.
todos.map((t) => (t.id === updated.id ? updated : t))
"기존 todos 배열을 돌면서 id가 같은 항목은 새 Todo로 갈아끼우고, 나머지는 그대로 두기"라는 뜻입니다.
동작 확인
체크박스를 누르면 제목에 줄이 그어지고 회색으로 바뀝니다. 다시 누르면 원래 모습으로 돌아오고요.
12. 단계 5 — Todo 삭제하기
각 카드 오른쪽에 "삭제" 버튼을 붙여볼게요.
src/components/TodoItem.tsx (삭제 버튼 추가)
import { api } from '../api' import { Todo } from '../types' interface Props { todo: Todo onChange: (todo: Todo) => void onDelete: (id: number) => void } const priorityStyle = { LOW: 'bg-gray-100 text-gray-700', MEDIUM: 'bg-yellow-100 text-yellow-800', HIGH: 'bg-red-100 text-red-700', } const priorityLabel = { LOW: '낮음', MEDIUM: '보통', HIGH: '높음', } export function TodoItem({ todo, onChange, onDelete }: Props) { async function handleToggle() { const updated = await api.toggleTodo(todo.id) onChange(updated) } async function handleDelete() { await api.deleteTodo(todo.id) onDelete(todo.id) } return ( <li className="bg-white rounded-lg shadow p-4 flex items-start gap-3" data-testid={`todo-item-${todo.id}`} > <input type="checkbox" checked={todo.completed} onChange={handleToggle} className="mt-1.5 h-4 w-4 cursor-pointer" data-testid={`todo-toggle-${todo.id}`} /> <div className="flex-1 min-w-0"> <div className="flex items-center gap-2 flex-wrap"> <h3 className={`font-medium ${ todo.completed ? 'line-through text-gray-400' : 'text-gray-900' }`} data-testid={`todo-title-${todo.id}`} > {todo.title} </h3> <span className={`px-2 py-0.5 text-xs rounded-full ${priorityStyle[todo.priority]}`} > {priorityLabel[todo.priority]} </span> {todo.dueDate && ( <span className="text-xs text-gray-500"> 마감 {todo.dueDate} </span> )} </div> {todo.description && ( <p className={`text-sm mt-1 ${ todo.completed ? 'text-gray-400' : 'text-gray-600' }`} > {todo.description} </p> )} </div> <div className="flex gap-2"> <button onClick={handleDelete} className="px-3 py-1 text-sm bg-red-50 text-red-700 rounded hover:bg-red-100" data-testid={`todo-delete-${todo.id}`} > 삭제 </button> </div> </li> ) }
TodoList도 onDelete를 받아서 자식한테 그대로 넘깁니다.
src/components/TodoList.tsx
import { Todo } from '../types' import { TodoItem } from './TodoItem' interface Props { todos: Todo[] onChange: (todo: Todo) => void onDelete: (id: number) => void } export function TodoList({ todos, onChange, onDelete }: Props) { if (todos.length === 0) { return ( <p className="text-center text-gray-500 py-8" data-testid="todo-empty" > 등록된 Todo가 없습니다. </p> ) } return ( <ul className="space-y-2" data-testid="todo-list"> {todos.map((todo) => ( <TodoItem key={todo.id} todo={todo} onChange={onChange} onDelete={onDelete} /> ))} </ul> ) }
App에는 handleDelete를 추가하고 TodoList로 내려보냅니다.
src/App.tsx (전체)
import { useState } from 'react' import { api } from './api' import { LoginPage } from './components/LoginPage' import { TodoForm } from './components/TodoForm' import { TodoList } from './components/TodoList' import { Todo, User } from './types' function App() { const [user, setUser] = useState<User | null>(null) const [todos, setTodos] = useState<Todo[]>([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') async function loadTodos() { setLoading(true) setError('') try { const list = await api.listTodos() setTodos(list) } catch { setError('목록을 불러오지 못했습니다.') } setLoading(false) } function handleLogin(loggedInUser: User) { setUser(loggedInUser) loadTodos() } function handleLogout() { localStorage.removeItem('token') localStorage.removeItem('user') setUser(null) setTodos([]) } function handleCreated(todo: Todo) { setTodos([todo, ...todos]) } function handleChange(updated: Todo) { setTodos(todos.map((t) => (t.id === updated.id ? updated : t))) } function handleDelete(id: number) { setTodos(todos.filter((t) => t.id !== id)) } if (!user) { return <LoginPage onLogin={handleLogin} /> } return ( <div className="min-h-screen bg-gray-50"> <header className="bg-white shadow-sm border-b"> <div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between"> <h1 className="text-xl font-bold text-gray-800">My Todo</h1> <div className="flex items-center gap-3"> <span className="text-sm text-gray-600">{user.nickname}님</span> <button onClick={handleLogout} className="text-sm text-gray-600 hover:text-gray-900" data-testid="logout-button" > 로그아웃 </button> </div> </div> </header> <main className="max-w-2xl mx-auto p-4 space-y-4"> <TodoForm onCreated={handleCreated} /> {error && ( <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded"> {error} </p> )} {loading ? ( <p className="text-center text-gray-500 py-8">불러오는 중...</p> ) : ( <TodoList todos={todos} onChange={handleChange} onDelete={handleDelete} /> )} </main> </div> ) } export default App
handleDelete도 잠깐 보고 갈게요.
todos.filter((t) => t.id !== id)
"id가 일치하지 않는 항목만 남겨라" — 결과적으로 해당 id의 Todo를 빼버린다는 뜻이죠.
동작 확인
카드 오른쪽 빨간 "삭제" 버튼을 누르면 그 자리에서 사라집니다.
13. 단계 6 — Todo 수정하기
마지막 기능이에요. "수정" 버튼을 붙이고, 누르면 카드가 입력 모드로 변신하게 만듭니다.
src/components/TodoItem.tsx (수정 모드 추가, 최종 버전)
import { useState } from 'react' import { api } from '../api' import { Todo } from '../types' interface Props { todo: Todo onChange: (todo: Todo) => void onDelete: (id: number) => void } const priorityStyle = { LOW: 'bg-gray-100 text-gray-700', MEDIUM: 'bg-yellow-100 text-yellow-800', HIGH: 'bg-red-100 text-red-700', } const priorityLabel = { LOW: '낮음', MEDIUM: '보통', HIGH: '높음', } export function TodoItem({ todo, onChange, onDelete }: Props) { const [editing, setEditing] = useState(false) const [title, setTitle] = useState(todo.title) const [description, setDescription] = useState(todo.description || '') const [priority, setPriority] = useState<string>(todo.priority) const [dueDate, setDueDate] = useState(todo.dueDate || '') async function handleToggle() { const updated = await api.toggleTodo(todo.id) onChange(updated) } async function handleDelete() { await api.deleteTodo(todo.id) onDelete(todo.id) } async function handleSave() { const updated = await api.updateTodo(todo.id, { title: title.trim(), description: description.trim() || undefined, priority: priority, dueDate: dueDate || undefined, }) onChange(updated) setEditing(false) } function handleCancel() { setTitle(todo.title) setDescription(todo.description || '') setPriority(todo.priority) setDueDate(todo.dueDate || '') setEditing(false) } if (editing) { return ( <li className="bg-white rounded-lg shadow p-4 space-y-3 border-2 border-blue-300" data-testid={`todo-item-${todo.id}`} > <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md" data-testid={`todo-edit-title-${todo.id}`} /> <textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} className="w-full px-3 py-2 border border-gray-300 rounded-md" data-testid={`todo-edit-description-${todo.id}`} /> <div className="flex gap-2"> <select value={priority} onChange={(e) => setPriority(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-md" data-testid={`todo-edit-priority-${todo.id}`} > <option value="LOW">낮음</option> <option value="MEDIUM">보통</option> <option value="HIGH">높음</option> </select> <input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-md" data-testid={`todo-edit-due-date-${todo.id}`} /> </div> <div className="flex gap-2 justify-end"> <button onClick={handleSave} disabled={!title.trim()} className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50" data-testid={`todo-save-${todo.id}`} > 저장 </button> <button onClick={handleCancel} className="px-3 py-1 text-sm bg-gray-200 text-gray-800 rounded hover:bg-gray-300" data-testid={`todo-cancel-${todo.id}`} > 취소 </button> </div> </li> ) } return ( <li className="bg-white rounded-lg shadow p-4 flex items-start gap-3" data-testid={`todo-item-${todo.id}`} > <input type="checkbox" checked={todo.completed} onChange={handleToggle} className="mt-1.5 h-4 w-4 cursor-pointer" data-testid={`todo-toggle-${todo.id}`} /> <div className="flex-1 min-w-0"> <div className="flex items-center gap-2 flex-wrap"> <h3 className={`font-medium ${ todo.completed ? 'line-through text-gray-400' : 'text-gray-900' }`} data-testid={`todo-title-${todo.id}`} > {todo.title} </h3> <span className={`px-2 py-0.5 text-xs rounded-full ${priorityStyle[todo.priority]}`} > {priorityLabel[todo.priority]} </span> {todo.dueDate && ( <span className="text-xs text-gray-500"> 마감 {todo.dueDate} </span> )} </div> {todo.description && ( <p className={`text-sm mt-1 ${ todo.completed ? 'text-gray-400' : 'text-gray-600' }`} > {todo.description} </p> )} </div> <div className="flex gap-2"> <button onClick={() => setEditing(true)} className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200" data-testid={`todo-edit-${todo.id}`} > 수정 </button> <button onClick={handleDelete} className="px-3 py-1 text-sm bg-red-50 text-red-700 rounded hover:bg-red-100" data-testid={`todo-delete-${todo.id}`} > 삭제 </button> </div> </li> ) }
수정 모드의 핵심은 "한 컴포넌트가 두 가지 모습을 가질 수 있다"는 발상이에요.
editingstate가true면 입력 폼을 보여주고false면 평소처럼 카드 모양으로 보여줍니다
if (editing) return (...) 한 줄로 두 화면을 나눠 그리는 거죠.
수정 모드용으로 새로 들어간 state들이에요.
title,description,priority,dueDate— 입력 중인 값들이에요. 처음에는 기존 Todo 값으로 채워뒀다가, 사용자가 고치면 그 값이 들어갑니다.
함수들도 짚어볼게요.
handleSave—api.updateTodo로 서버에 보내고, 응답으로 받은 새 Todo를 부모한테 전달한 뒤editing을false로 돌립니다handleCancel— 입력값을 원래 Todo 값으로 되돌리고editing을false로 만들어요
TodoList와 App은 이번 단계에서 손댈 필요 없어요. onChange가 이미 잘 동작하고 있으니까요.
💡
useState<string>(todo.priority)처럼<string>을 명시한 이유가 있어요.todo.priority는 더 좁은 타입('LOW' | 'MEDIUM' | 'HIGH')이라서 그대로 두면<select>의 onChange가 던져주는 일반 문자열을 못 받게 되거든요. 그래서 "그냥 문자열로 다루겠다"고 알려주는 겁니다.
동작 확인
npm run dev
카드 오른쪽에 "수정" 버튼이 새로 생겼어요. 누르면 카드가 입력 모드로 바뀌고, "저장"을 누르면 변경 내용이 반영된 채로 다시 카드 모양으로 돌아옵니다.
축하드립니다! Todo 앱의 기본 기능이 모두 완성됐어요 🎉
14. 보너스 — 새로고침해도 로그인 유지하기
지금 상태로는 페이지를 새로고침하면 로그인 화면이 또 뜹니다. 토큰과 유저 정보는 이미 localStorage에 저장해 뒀으니, 앱이 시작될 때 그 값을 읽어와서 자동으로 로그인된 상태로 만들면 돼요.
여기서 처음으로 useEffect를 써봅니다. useEffect는 "컴포넌트가 화면에 처음 그려졌을 때 한 번 실행할 코드"를 적어두는 자리예요.
src/App.tsx (전체, useEffect 추가)
import { useEffect, useState } from 'react' import { api } from './api' import { LoginPage } from './components/LoginPage' import { TodoForm } from './components/TodoForm' import { TodoList } from './components/TodoList' import { Todo, User } from './types' function App() { const [user, setUser] = useState<User | null>(null) const [todos, setTodos] = useState<Todo[]>([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') useEffect(() => { const userJson = localStorage.getItem('user') if (userJson) { const savedUser = JSON.parse(userJson) setUser(savedUser) loadTodos() } }, []) async function loadTodos() { setLoading(true) setError('') try { const list = await api.listTodos() setTodos(list) } catch { setError('목록을 불러오지 못했습니다.') } setLoading(false) } function handleLogin(loggedInUser: User) { setUser(loggedInUser) loadTodos() } function handleLogout() { localStorage.removeItem('token') localStorage.removeItem('user') setUser(null) setTodos([]) } function handleCreated(todo: Todo) { setTodos([todo, ...todos]) } function handleChange(updated: Todo) { setTodos(todos.map((t) => (t.id === updated.id ? updated : t))) } function handleDelete(id: number) { setTodos(todos.filter((t) => t.id !== id)) } if (!user) { return <LoginPage onLogin={handleLogin} /> } return ( <div className="min-h-screen bg-gray-50"> <header className="bg-white shadow-sm border-b"> <div className="max-w-2xl mx-auto px-4 py-3 flex items-center justify-between"> <h1 className="text-xl font-bold text-gray-800">My Todo</h1> <div className="flex items-center gap-3"> <span className="text-sm text-gray-600">{user.nickname}님</span> <button onClick={handleLogout} className="text-sm text-gray-600 hover:text-gray-900" data-testid="logout-button" > 로그아웃 </button> </div> </div> </header> <main className="max-w-2xl mx-auto p-4 space-y-4"> <TodoForm onCreated={handleCreated} /> {error && ( <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded"> {error} </p> )} {loading ? ( <p className="text-center text-gray-500 py-8">불러오는 중...</p> ) : ( <TodoList todos={todos} onChange={handleChange} onDelete={handleDelete} /> )} </main> </div> ) } export default App
핵심은 이 한 토막이에요.
useEffect(() => { const userJson = localStorage.getItem('user') if (userJson) { const savedUser = JSON.parse(userJson) setUser(savedUser) loadTodos() } }, [])
useEffect의 두 번째 인자로 빈 배열 []을 넘기면 "처음 한 번만 실행해라"라는 뜻이 됩니다. 안에서는 이런 일을 해요.
localStorage에 저장해 둔 user 문자열을 꺼내고- 있으면
JSON.parse로 객체로 되살린 다음 setUser로 로그인 상태로 만들고loadTodos()로 목록을 가져옵니다
💡 React 개발 모드에서는
useEffect가 두 번 실행되는 것처럼 보일 수 있어요. 일부러 그렇게 만든 거니까 걱정 안 하셔도 됩니다. 배포할 때는 한 번만 실행돼요.
동작 확인
로그인한 상태에서 페이지를 새로고침해 보세요. 로그인 화면을 거치지 않고 바로 Todo 목록이 뜨면 성공입니다.
로그아웃을 누른 뒤 새로고침해 보면 localStorage가 비워졌으니 다시 로그인 화면으로 돌아오고요.
15. 자동 테스트 (Playwright)
매번 손으로 클릭해서 확인하지 않아도 되도록, 화면을 자동으로 조작해 주는 테스트를 만들어 볼게요.
Playwright 설치
npm install -D @playwright/test npx playwright install chromium
-D는 "개발용 패키지(devDependency)"라는 표시입니다. 두 번째 명령은 테스트에서 쓸 Chromium 브라우저를 받아 오는 건데, 시간이 좀 걸릴 수 있어요.
playwright.config.ts (새 파일, 프로젝트 최상위에 만드세요)
import { defineConfig, devices } from '@playwright/test' const PORT = 5180 const BASE_URL = `http://localhost:${PORT}` export default defineConfig({ testDir: './e2e', fullyParallel: false, workers: 1, reporter: [['list']], use: { baseURL: BASE_URL, trace: 'on-first-retry', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], webServer: { command: `npm run dev -- --port ${PORT} --strictPort`, url: BASE_URL, reuseExistingServer: false, timeout: 120_000, }, })
이 설정 덕에 Playwright가 5180 포트로 앱을 띄운 뒤 테스트를 돌립니다. 5173 대신 5180을 쓴 건 다른 프로젝트가 5173에 떠 있을 때 부딪히지 않게 하려는 거예요.
e2e/todo.spec.ts (새 파일, 프로젝트 최상위에 e2e 폴더 만들고 그 안에)
아이디와 비밀번호는 본인 계정으로 바꿔서 쓰세요.
import { expect, test } from '@playwright/test' const USERNAME = 'testuser01' const PASSWORD = 'pass1234' test.describe('Todo 앱 E2E', () => { test('로그인 → 등록 → 토글 → 수정 → 삭제 시나리오', async ({ page }) => { // 1) 로그인 화면 노출 await page.goto('/') await expect(page.getByRole('heading', { name: '로그인' })).toBeVisible() // 2) 로그인 수행 await page.getByLabel('아이디').fill(USERNAME) await page.getByLabel('비밀번호').fill(PASSWORD) await page.getByRole('button', { name: '로그인' }).click() // 로그인 성공 후 메인 화면 await expect(page.getByRole('heading', { name: 'My Todo' })).toBeVisible() await expect(page.getByRole('heading', { name: '새 Todo 등록' })).toBeVisible() // 3) Todo 등록 const uniqueTitle = `E2E 테스트 ${Date.now()}` await page.getByTestId('todo-title-input').fill(uniqueTitle) await page.getByTestId('todo-description-input').fill('자동 테스트로 만든 항목') await page.getByTestId('todo-priority-select').selectOption('HIGH') await page.getByTestId('todo-submit-button').click() // 목록에 등록한 항목이 보임 const item = page.locator('li', { hasText: uniqueTitle }).first() await expect(item).toBeVisible() const itemId = await item.getAttribute('data-testid') expect(itemId).toMatch(/^todo-item-\d+$/) const todoId = itemId!.replace('todo-item-', '') // 4) 완료 토글 const toggle = page.getByTestId(`todo-toggle-${todoId}`) await expect(toggle).not.toBeChecked() await toggle.click() await expect(toggle).toBeChecked() // 다시 해제 await toggle.click() await expect(toggle).not.toBeChecked() // 5) 수정 await page.getByTestId(`todo-edit-${todoId}`).click() const editTitle = page.getByTestId(`todo-edit-title-${todoId}`) const updatedTitle = `${uniqueTitle} (수정)` await editTitle.fill(updatedTitle) await page.getByTestId(`todo-edit-priority-${todoId}`).selectOption('LOW') await page.getByTestId(`todo-save-${todoId}`).click() await expect(page.getByTestId(`todo-title-${todoId}`)).toHaveText(updatedTitle) // 6) 삭제 await page.getByTestId(`todo-delete-${todoId}`).click() await expect(page.getByTestId(`todo-item-${todoId}`)).toHaveCount(0) }) test('잘못된 비밀번호로 로그인 시 에러 메시지를 표시한다', async ({ page }) => { await page.goto('/') await page.getByLabel('아이디').fill(USERNAME) await page.getByLabel('비밀번호').fill('wrong-password') await page.getByRole('button', { name: '로그인' }).click() await expect(page.getByTestId('login-error')).toBeVisible() }) })
package.json에 스크립트 추가
package.json을 열어 scripts 부분에 test:e2e 한 줄을 끼워 넣습니다.
"scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", "test:e2e": "playwright test" }
실행
npm run test:e2e
Running 2 tests using 1 worker ✓ 1 [chromium] › e2e/todo.spec.ts:7:3 › 로그인 → 등록 → 토글 → 수정 → 삭제 시나리오 ✓ 2 [chromium] › e2e/todo.spec.ts:62:3 › 잘못된 비밀번호로 로그인 시 에러 메시지를 표시한다 2 passed
둘 다 ✓가 뜨면 성공입니다.
💡 테스트를 돌리면 npm run dev가 알아서 백그라운드에서 5180 포트에 앱을 띄워줍니다. 테스트가 끝나면 자동으로 내려가고요.
테스트 시나리오도 잠깐 풀어볼게요.
page.goto('/')— 로그인 화면 열기page.getByLabel('아이디').fill(...)— "아이디" 라벨이 붙은 입력칸에 값 채우기page.getByRole('button', { name: '로그인' }).click()— "로그인" 버튼 클릭page.getByTestId('todo-title-input').fill(...)—data-testid로 입력칸을 찾아서 값 채우기expect(...).toBeVisible()— "이 요소가 화면에 있어야 한다"고 검증
data-testid 덕분에 화면 텍스트가 좀 바뀌어도 테스트가 잘 안 깨지게 짠 셈입니다.
16. 마무리
여기까지 따라오시느라 수고 많으셨습니다! 같이 만든 건 이런 것들이에요.
- Vite + React + TypeScript + Tailwind 프로젝트
- JWT 토큰 기반 로그인 (localStorage)
- Todo CRUD (등록, 목록, 토글, 수정, 삭제)
- 새로고침해도 로그인 유지 (
useEffect1개) - Playwright 자동 테스트 2개
최종 폴더 구조
todo-app/ ├── e2e/ │ └── todo.spec.ts ├── src/ │ ├── components/ │ │ ├── LoginPage.tsx │ │ ├── TodoForm.tsx │ │ ├── TodoItem.tsx │ │ └── TodoList.tsx │ ├── App.tsx │ ├── api.ts │ ├── index.css │ ├── main.tsx │ └── types.ts ├── index.html ├── package.json ├── playwright.config.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts
더 해보고 싶다면
- 우선순위·완료 여부로 필터링 붙이기 (
GET /todos?completed=true&priority=HIGH) - 회원가입 화면 만들기
- Todo 카드 정렬 (마감일 순 같은 것)
- "삭제하시겠습니까?" 확인 창 띄우기
- 로딩·에러 화면을 좀 더 예쁘게 다듬기
고생하셨어요! 🎉




댓글
댓글을 작성하려면 이 필요합니다.