지금까지 만든 Todo 앱을 마무리하면서, 자주 발생하는 오류들을 정리하고 전체 기능을 체크리스트로 점검한 뒤 완성된 전체 코드를 모아봤다.
리액트로 프론트엔드 개발을 할 때 자주 발생하는 문제를 이번 시리즈에서 배운 범위 내에서 정리했다. 앞에서 만든 코드들에 해당 문제가 있는지 확인해보자.
| 증상 | 원인 | 해결 방법 |
|---|---|---|
| 콘솔에 "key" 관련 경고가 뜬다 | map()으로 리스트를 렌더링할 때 key를 안 넣었거나, key가 서로 겹친다 | todo.id처럼 고유한 값을 key로 지정한다 |
| 버튼을 눌러도 화면이 안 바뀐다 | todos.push(newTodo)처럼 배열을 직접 수정한 뒤 setTodos(todos)를 호출했다 | setTodos([...todos, newTodo])처럼 새 배열을 만들어서 전달한다 |
| checkbox를 눌러도 아무 반응이 없다 | checked만 넣고 onChange를 안 넣었거나, onToggle 연결을 빠뜨렸다 | checked와 onChange가 항상 짝을 이루는지 확인한다 |
| 삭제 버튼을 눌렀는데 엉뚱한 항목이 지워진다 | key에 todo.id가 아니라 배열의 index를 사용했다 | key를 index가 아니라 todo.id로 바꾼다 |
| 필터를 바꾸면 목록이 통째로 사라진다 | filteredTodos를 만들 때 todos가 아니라 이미 필터링된 배열을 다시 필터링했다 | 항상 원본 todos를 기준으로 filteredTodos를 새로 계산한다 |
이 표는 지금까지 배운 개념(key, 불변성, Controlled Component, 파생 데이터)들이 실제로 어디서 문제를 일으키는지 한눈에 보여준다. 대부분의 오류는 결국 "React가 변화를 감지하지 못하게 만드는 실수"로 귀결된다.
아래 항목을 하나씩 직접 눌러보면서 확인한다.
// src/TodoItem.jsx
function TodoItem({ todo, onDelete, onToggle }) {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>삭제</button>
</li>
);
}
export default TodoItem;
// src/TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos, onDelete, onToggle }) {
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onDelete={onDelete} onToggle={onToggle} />
))}
</ul>
);
}
export default TodoList;
// src/TodoForm.jsx
import { useState } from "react";
function TodoForm({ onAdd }) {
const [text, setText] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim() === "") return;
onAdd(text);
setText("");
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
export default TodoForm;
// src/App.jsx
import { useState } from "react";
import TodoForm from "./TodoForm";
import TodoList from "./TodoList";
function App() {
const [todos, setTodos] = useState([
{ id: 1, text: "React 공부하기", completed: false },
{ id: 2, text: "장보기", completed: true },
{ id: 3, text: "운동하기", completed: false },
]);
const [filter, setFilter] = useState("all");
const handleAdd = (text) => {
const newTodo = { id: Date.now(), text: text, completed: false };
setTodos([...todos, newTodo]);
};
const handleDelete = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
const handleToggle = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const filteredTodos = todos.filter((todo) => {
if (filter === "active") return !todo.completed;
if (filter === "completed") return todo.completed;
return true;
});
const activeCount = todos.filter((todo) => !todo.completed).length;
return (
<div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
<h1>Todo 리스트</h1>
<TodoForm onAdd={handleAdd} />
<div>
<button
onClick={() => setFilter("all")}
style={{ fontWeight: filter === "all" ? "bold" : "normal" }}
>
전체
</button>
<button
onClick={() => setFilter("active")}
style={{ fontWeight: filter === "active" ? "bold" : "normal" }}
>
미완료
</button>
<button
onClick={() => setFilter("completed")}
style={{ fontWeight: filter === "completed" ? "bold" : "normal" }}
>
완료
</button>
</div>
<p>{activeCount}개 남음</p>
<TodoList todos={filteredTodos} onDelete={handleDelete} onToggle={handleToggle} />
</div>
);
}
export default App;
App/TodoForm/TodoList/TodoItem), props로 데이터와 콜백 전달, useState로 상태 관리, map/filter로 배열 다루기까지 React 기본기를 압축해서 담고 있는 좋은 연습 프로젝트였다.지금까지 인라인 스타일(style={{ }})로 꾸며왔던 화면을 Tailwind CSS로 바꿔봤다. Tailwind가 뭔지부터 Vite 프로젝트에 설치하는 방법, 자주 쓰는 클래스, 그리고 Todo 앱 전체를 Tailwind로 다시 스타일링하는 과정까지 다룬다.
지금까지는 style={{ color: "blue", padding: "12px" }}처럼 직접 스타일 값을 하나하나 지정했다. Tailwind CSS는 미리 정의된 클래스 이름을 className에 조합해서 붙이는 것만으로 스타일을 적용하는 도구다.
// 지금까지 방식
<div style={{ padding: "16px", backgroundColor: "#f0f0f0", borderRadius: "8px" }}>내용</div>
// Tailwind 방식
<div className="p-4 bg-gray-100 rounded-lg">내용</div>
p-4(padding), bg-gray-100(배경색), rounded-lg(둥근 모서리)처럼 클래스 이름 자체가 어떤 스타일인지 짐작하기 쉽고, 매번 픽셀 값을 직접 계산하지 않아도 된다.
터미널에서 프로젝트 폴더로 이동한 뒤, 아래 명령어로 Tailwind와 Vite 연동 플러그인을 설치한다.
npm install tailwindcss @tailwindcss/vite
vite.config.js 파일을 열어 Tailwind 플러그인을 추가한다. 이때 반드시 상단에 tailwind를 import해야 한다. defineConfig() 안에만 정의하면 리액트 실행이 안 된다.
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
src/index.css 파일의 내용을 아래 한 줄로 바꾼다. (기존 내용은 지워도 된다.)
@import "tailwindcss";
src/main.jsx에 import "./index.css";가 이미 들어있는지 확인한다. (Vite로 프로젝트를 새로 만들었다면 보통 기본으로 들어있다.) 이제 npm run dev로 다시 실행하면 Tailwind 클래스를 사용할 준비가 끝난다.
tailwind.config.js 파일은 안 만들어도 될까? 예전 버전의 Tailwind는 설정 파일을 직접 만들어야 했지만, 최신 버전은 별도 설정 파일 없이 @import "tailwindcss"; 한 줄과 Vite 플러그인만으로 바로 사용할 수 있다. 나중에 색상이나 폰트 등을 커스터마이징하고 싶을 때만 설정 파일을 추가하면 된다.
기존에 만든 Profile 컴포넌트를 예시로, 인라인 스타일을 Tailwind 클래스로 바꿔보자.
// 기존 방식
function Profile({ name, job, bio }) {
return (
<div style={{ border: "1px solid #ddd", borderRadius: "8px", padding: "16px" }}>
<h2>{name}</h2>
<p>{job}</p>
<p>{bio}</p>
</div>
);
}
// Tailwind로 바꾼 방식
function Profile({ name, job, bio }) {
return (
<div className="border border-gray-300 rounded-lg p-4">
<h2 className="text-xl font-bold">{name}</h2>
<p className="text-gray-600">{job}</p>
<p>{bio}</p>
</div>
);
}
| 분류 | 클래스 예시 | 의미 |
|---|---|---|
| 여백 | p-4, m-2, gap-4 | padding, margin, 요소 사이 간격 |
| 텍스트 | text-lg, font-bold, text-gray-600 | 글자 크기, 굵기, 색상 |
| 배경/테두리 | bg-gray-100, border, rounded-lg | 배경색, 테두리, 둥근 모서리 |
| 레이아웃 | flex, flex-col, items-center, justify-between | display: flex와 정렬 관련 속성 |
| 크기 | w-full, max-w-md, h-10 | 너비, 최대 너비, 높이 |
지금까지 만든 Todo 앱 전체를 Tailwind 클래스로 다시 꾸며본다.
// src/TodoItem.jsx
function TodoItem({ todo, onDelete, onToggle }) {
return (
<li className="flex items-center justify-between py-3 px-2 rounded-lg hover:bg-gray-50 transition">
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
className="w-4 h-4 accent-blue-500"
/>
<span className={todo.completed ? "line-through text-gray-300" : "text-gray-700"}>
{todo.text}
</span>
</div>
<button
onClick={() => onDelete(todo.id)}
className="text-xs text-gray-400 hover:text-red-500 transition"
>
삭제
</button>
</li>
);
}
export default TodoItem;
hover:bg-gray-50처럼 hover: 접두사를 붙이면 마우스를 올렸을 때만 적용되는 스타일도 클래스 하나로 표현할 수 있다.
// src/TodoList.jsx
import TodoItem from "./TodoItem";
function TodoList({ todos, onDelete, onToggle }) {
if (todos.length === 0) {
return <p className="text-center text-gray-300 text-sm py-8">할 일이 없어요</p>;
}
return (
<ul className="divide-y divide-gray-100">
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onDelete={onDelete} onToggle={onToggle} />
))}
</ul>
);
}
export default TodoList;
divide-y divide-gray-100은 리스트 항목 사이에 구분선을 자동으로 넣어주는 클래스다.
// src/TodoForm.jsx
import { useState } from "react";
function TodoForm({ onAdd }) {
const [text, setText] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim() === "") return;
onAdd(text);
setText("");
};
return (
<form onSubmit={handleSubmit} className="flex gap-2 mb-6">
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="할 일을 입력하세요"
className="flex-1 border border-gray-200 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition"
>
추가
</button>
</form>
);
}
export default TodoForm;
focus:ring-2 focus:ring-blue-400처럼 focus: 접두사로 입력창이 포커스됐을 때의 스타일도 지정할 수 있다.
// src/App.jsx
import { useState } from "react";
import TodoForm from "./TodoForm";
import TodoList from "./TodoList";
function App() {
const [todos, setTodos] = useState([
{ id: 1, text: "React 공부하기", completed: false },
{ id: 2, text: "장보기", completed: true },
{ id: 3, text: "운동하기", completed: false },
]);
const [filter, setFilter] = useState("all");
const handleAdd = (text) => {
const newTodo = { id: Date.now(), text: text, completed: false };
setTodos([...todos, newTodo]);
};
const handleDelete = (id) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
const handleToggle = (id) => {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const filteredTodos = todos.filter((todo) => {
if (filter === "active") return !todo.completed;
if (filter === "completed") return todo.completed;
return true;
});
const activeCount = todos.filter((todo) => !todo.completed).length;
const filters = [
{ key: "all", label: "전체" },
{ key: "active", label: "미완료" },
{ key: "completed", label: "완료" },
];
return (
<div className="min-h-screen bg-gray-50 flex justify-center py-12 px-4">
<div className="w-full max-w-md bg-white rounded-2xl shadow-lg p-6">
<h1 className="text-2xl font-bold text-gray-800 mb-1">📝 Todo 리스트</h1>
<p className="text-sm text-gray-400 mb-6">{activeCount}개 남음</p>
<TodoForm onAdd={handleAdd} />
<div className="flex gap-2 mb-4">
{filters.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-3 py-1 rounded-full text-sm transition ${
filter === f.key
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
}`}
>
{f.label}
</button>
))}
</div>
<TodoList todos={filteredTodos} onDelete={handleDelete} onToggle={handleToggle} />
</div>
</div>
);
}
export default App;
여기서 눈여겨볼 부분은 필터 버튼의 클래스 조합이다.
className={`px-3 py-1 rounded-full text-sm transition ${
filter === f.key
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
}`}
이전 섹션에서 배운 조건부 스타일링을, 인라인 style 객체 대신 템플릿 리터럴로 클래스 문자열을 조합하는 방식으로 응용한 것이다. 선택된 필터인지 아닌지에 따라 다른 Tailwind 클래스 조합이 적용된다.
또한 filters 배열을 만들어 버튼 3개를 하드코딩하지 않고 map()으로 렌더링한 점도 눈여겨볼 만하다. 반복되는 UI는 데이터 배열 + map()으로 정리하면 코드가 훨씬 간결해진다.
className에 미리 정의된 클래스를 조합하는 방식으로, 인라인 style 객체보다 코드가 짧고 일관된 디자인 시스템을 유지하기 쉽다.hover:, focus: 같은 접두사를 붙이면 상태별 스타일도 별도의 이벤트 핸들러 없이 클래스만으로 표현할 수 있다.style 대신 템플릿 리터럴로 클래스 문자열을 조합하는 방식으로 자연스럽게 이어진다.map()으로 만들면, 항목이 늘어나도 코드를 거의 수정하지 않아도 된다.