Fullstack 90

heo4·2026년 8월 24일

Fullstack

목록 보기
45/70

풀스택

⚛️ React Todo 앱 - 트러블슈팅 & 전체 코드 정리

지금까지 만든 Todo 앱을 마무리하면서, 자주 발생하는 오류들을 정리하고 전체 기능을 체크리스트로 점검한 뒤 완성된 전체 코드를 모아봤다.

1. 자주 발생하는 오류들

리액트로 프론트엔드 개발을 할 때 자주 발생하는 문제를 이번 시리즈에서 배운 범위 내에서 정리했다. 앞에서 만든 코드들에 해당 문제가 있는지 확인해보자.

증상원인해결 방법
콘솔에 "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가 변화를 감지하지 못하게 만드는 실수"로 귀결된다.


2. 전체 기능 통합 테스트 체크리스트

아래 항목을 하나씩 직접 눌러보면서 확인한다.

  • 입력창에 할 일을 적고 추가 버튼을 누르면 목록에 새 항목이 나타나는가
  • 빈 입력창 상태로 추가 버튼을 눌러도 빈 항목이 추가되지 않는가
  • 삭제 버튼을 누르면 정확히 그 항목만 사라지는가
  • checkbox를 클릭하면 해당 항목에만 취소선이 생기는가
  • "완료" 필터를 누르면 완료된 항목만 보이는가
  • "미완료" 필터를 누르면 미완료 항목만 보이는가
  • "전체" 필터를 누르면 다시 모든 항목이 보이는가
  • 화면 상단의 "N개 남음" 숫자가 완료 체크할 때마다 정확히 바뀌는가

3. 완성된 Todo 앱 전체 코드

TodoItem.jsx

// 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;

TodoList.jsx

// 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;

TodoForm.jsx

// 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;

App.jsx

// 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;

마무리

  • 이번 트러블슈팅 표를 보면, React에서 발생하는 버그 대부분이 "key를 잘못 쓴 경우"와 "state를 직접 수정해서 불변성이 깨진 경우" 두 가지로 수렴한다는 걸 알 수 있다. 이 두 가지만 습관적으로 점검해도 웬만한 버그는 예방할 수 있다.
  • Todo 앱은 컴포넌트 분리(App/TodoForm/TodoList/TodoItem), props로 데이터와 콜백 전달, useState로 상태 관리, map/filter로 배열 다루기까지 React 기본기를 압축해서 담고 있는 좋은 연습 프로젝트였다.
  • 앞으로 새로운 기능(수정, 마감일, 로컬 저장 등)을 추가하고 싶다면, 이번에 정리한 컴포넌트 구조와 상태 관리 패턴을 그대로 확장해나가면 된다.

⚛️ React + Tailwind CSS 정리

지금까지 인라인 스타일(style={{ }})로 꾸며왔던 화면을 Tailwind CSS로 바꿔봤다. Tailwind가 뭔지부터 Vite 프로젝트에 설치하는 방법, 자주 쓰는 클래스, 그리고 Todo 앱 전체를 Tailwind로 다시 스타일링하는 과정까지 다룬다.

1. Tailwind CSS란

지금까지는 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(둥근 모서리)처럼 클래스 이름 자체가 어떤 스타일인지 짐작하기 쉽고, 매번 픽셀 값을 직접 계산하지 않아도 된다.


2. Vite 프로젝트에 Tailwind 설치하기

터미널에서 프로젝트 폴더로 이동한 뒤, 아래 명령어로 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 플러그인만으로 바로 사용할 수 있다. 나중에 색상이나 폰트 등을 커스터마이징하고 싶을 때만 설정 파일을 추가하면 된다.


3. style={{ }}를 className으로 바꿔보기

기존에 만든 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>
  );
}

4. 자주 쓰는 Tailwind 클래스

분류클래스 예시의미
여백p-4, m-2, gap-4padding, margin, 요소 사이 간격
텍스트text-lg, font-bold, text-gray-600글자 크기, 굵기, 색상
배경/테두리bg-gray-100, border, rounded-lg배경색, 테두리, 둥근 모서리
레이아웃flex, flex-col, items-center, justify-betweendisplay: flex와 정렬 관련 속성
크기w-full, max-w-md, h-10너비, 최대 너비, 높이

5. Todo 앱에 Tailwind 적용해보기

지금까지 만든 Todo 앱 전체를 Tailwind 클래스로 다시 꾸며본다.

TodoItem.jsx

// 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: 접두사를 붙이면 마우스를 올렸을 때만 적용되는 스타일도 클래스 하나로 표현할 수 있다.

TodoList.jsx

// 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은 리스트 항목 사이에 구분선을 자동으로 넣어주는 클래스다.

TodoForm.jsx

// 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: 접두사로 입력창이 포커스됐을 때의 스타일도 지정할 수 있다.

App.jsx

// 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()으로 정리하면 코드가 훨씬 간결해진다.


마무리

  • Tailwind CSS는 className에 미리 정의된 클래스를 조합하는 방식으로, 인라인 style 객체보다 코드가 짧고 일관된 디자인 시스템을 유지하기 쉽다.
  • hover:, focus: 같은 접두사를 붙이면 상태별 스타일도 별도의 이벤트 핸들러 없이 클래스만으로 표현할 수 있다.
  • 조건부 스타일링은 인라인 style 대신 템플릿 리터럴로 클래스 문자열을 조합하는 방식으로 자연스럽게 이어진다.
  • 반복되는 버튼/항목 UI는 데이터 배열 + map()으로 만들면, 항목이 늘어나도 코드를 거의 수정하지 않아도 된다.

0개의 댓글