Fullstack 87

heo4·2026년 8월 19일

Fullstack

목록 보기
42/70

풀스택

⚛️ React Todo 앱 설계하기

지금까지 배운 개념들을 실전 프로젝트에 적용해볼 차례다. Todo 앱을 만들기 전에 요구사항 정의, 컴포넌트 트리 설계, 그리고 상태를 어디에 둘지 미리 계획하는 과정을 정리했다.

1. 요구사항 정의하기

코드를 작성하기 전에, 우리가 만들 Todo 앱이 정확히 어떤 기능을 가져야 하는지부터 정리한다.

기능설명
추가입력창에 할 일을 적고 등록하면 목록에 새 항목이 추가된다
완료 체크항목의 checkbox를 클릭하면 완료 상태로 표시된다 (취소선 등)
삭제항목의 삭제 버튼을 클릭하면 목록에서 사라진다

이 세 가지 기능을 하나씩 구현하기 전에 먼저 전체 구조를 설계해본다.


2. 컴포넌트 트리 설계하기

화면을 어떤 컴포넌트로 나눌지 미리 정해두면, 나중에 코드를 작성할 때 헤매지 않는다. Todo 앱은 아래와 같은 컴포넌트 트리로 나눈다.

App
 ├─ TodoForm   (할 일을 입력하고 추가하는 부분)
 └─ TodoList   (전체 목록을 보여주는 부분)
      └─ TodoItem   (목록 안의 항목 하나하나, 완료 체크·삭제 버튼 포함)
컴포넌트역할
App전체 todos 데이터를 가지고 있는 최상위 컴포넌트
TodoForm새 할 일을 입력받아 App에 추가를 요청하는 컴포넌트
TodoListtodos 배열을 map()으로 순회하며 TodoItem을 렌더링하는 컴포넌트
TodoItem할 일 하나의 텍스트, 완료 체크박스, 삭제 버튼을 보여주는 컴포넌트

3. state는 어디에 둘 것인가

TodoForm은 입력창의 값을, TodoList는 전체 목록을, TodoItem은 완료 여부를 다뤄야 한다. 그런데 실제로 "할 일 목록(todos) 자체"는 어느 한 컴포넌트만의 것이 아니라, TodoForm(추가), TodoList(표시), TodoItem(삭제·완료 체크)이 전부 함께 다뤄야 하는 데이터다.

이럴 때는 여러 컴포넌트가 공통으로 접근해야 하는 state를, 그 컴포넌트들을 모두 감싸고 있는 가장 가까운 부모 컴포넌트에 둔다. 이 방식을 상태 끌어올리기(Lifting State Up)라고 부른다.

todos를 왜 TodoList가 아니라 App에 둘까? TodoForm은 TodoList의 자식이 아니라 형제 컴포넌트다. 만약 todos를 TodoList 안에 두면, TodoForm에서 새 할 일을 추가하고 싶어도 TodoList의 state에는 접근할 방법이 없다. 두 컴포넌트가 함께 다뤄야 하는 데이터는, 둘을 동시에 감싸고 있는 공통 부모(App)로 끌어올려야 양쪽 모두에게 props로 내려줄 수 있다.


4. Todo 데이터 구조 설계하기

todos는 할 일 객체들을 담은 배열로 만든다. 각 할 일 객체는 아래 세 가지 값을 가진다.

필드타입의미
id숫자각 할 일을 구분하는 고유 값 (key로 사용)
text문자열할 일 내용
completedboolean완료 여부
const todos = [
  { id: 1, text: "React 공부하기", completed: false },
  { id: 2, text: "장보기", completed: true },
  { id: 3, text: "운동하기", completed: false },
];

5. 실습 정리

실습 1) 요구사항 정리해보기

지금까지 정의한 세 가지 기능(추가/완료 체크/삭제) 외에, 나만의 Todo 앱에 추가하고 싶은 기능이 있다면 한 가지 적어본다. (예: 수정 기능, 마감일 표시 등 — 지금 당장 구현하지 않아도 된다.)

실습 2) 컴포넌트 트리 직접 그려보기

  1. 종이나 메모장에 App → TodoForm, TodoList → TodoItem 트리를 직접 그려본다.
  2. 각 컴포넌트 옆에 "이 컴포넌트가 하는 일"을 한 줄씩 적어본다.

실습 3) Todo 데이터 구조 설계하고 초기 데이터 작성하기

src/App.jsx에 useState로 todos state를 만들고, id/text/completed를 가진 할 일 객체 3개를 초기값으로 넣는다. 아직 TodoForm, TodoList는 만들지 않아도 된다.

import { useState } from "react";

function App() {
  const [todos, setTodos] = useState([
    { id: 1, text: "React 공부하기", completed: false },
    { id: 2, text: "장보기", completed: true },
    { id: 3, text: "운동하기", completed: false },
  ]);

  return (
    <div>
      <h1>Todo 리스트</h1>
      {/* TodoForm, TodoList 구현 예정 */}
    </div>
  );
}

export default App;

마무리

  • 실전 프로젝트는 코드를 바로 작성하기보다 요구사항 정의 → 컴포넌트 트리 설계 → state 위치 결정 → 데이터 구조 설계 순서로 계획을 먼저 세우는 게 중요하다.
  • 여러 컴포넌트가 공유해야 하는 데이터는 그 컴포넌트들의 가장 가까운 공통 부모로 상태를 끌어올린다(Lifting State Up)는 원칙을 기억하자.
  • Todo 데이터는 id(구분용), text(내용), completed(완료 여부) 세 필드를 가진 객체 배열로 설계하면, 이후 map()으로 렌더링하거나 id 기준으로 삭제/수정하기 편하다.

⚛️ React Todo 앱 - 컴포넌트 분리하기

앞서 설계한 Todo 앱을 실제로 파일 단위로 쪼개서 구현해봤다. export/import로 컴포넌트를 분리하는 방법부터 TodoItem, TodoList를 만들고 App에서 연결하는 과정, 기본 레이아웃까지 다룬다.

1. 컴포넌트를 파일로 분리하기: import와 export

지금까지는 한 파일(App.jsx) 안에 여러 컴포넌트를 함께 작성했다. 이제부터는 컴포넌트마다 별도의 파일로 나누고, export/import로 서로 연결한다.

// src/Greeting.jsx
function Greeting() {
  return <h2>안녕하세요!</h2>;
}

export default Greeting;
// src/App.jsx
import Greeting from "./Greeting";

function App() {
  return <Greeting />;
}

export default App;

Greeting.jsx에서 export default Greeting은 "이 컴포넌트를 다른 파일에서 가져다 쓸 수 있게 내보낸다"는 뜻이고, App.jsx에서 import Greeting from "./Greeting"은 "같은 폴더에 있는 Greeting.jsx 파일에서 내보낸 걸 가져온다"는 뜻이다.

import 경로에는 왜 ./를 붙이고, 확장자(.jsx)는 왜 안 써도 될까? ./는 "지금 이 파일과 같은 폴더에서 찾는다"는 뜻이다. ./가 없으면 React는 내가 만든 파일이 아니라 node_modules에 설치된 라이브러리를 찾으려고 한다. 확장자는 Vite가 .jsx, .js 등 몇 가지 정해진 확장자를 자동으로 붙여서 찾아주기 때문에 생략해도 된다.


2. TodoItem.jsx 만들기

목록 안의 할 일 하나를 보여주는 컴포넌트다. 지금 단계에서는 완료 여부에 따라 취소선만 표시해본다.

// src/TodoItem.jsx
function TodoItem({ todo }) {
  return (
    <li style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
      {todo.text}
    </li>
  );
}

export default TodoItem;

todo라는 객체 하나를 props로 통째로 받아서, todo.text, todo.completed처럼 필요한 값을 꺼내 쓰고 있다.


3. TodoList.jsx 만들기

todos 배열 전체를 받아서, map()으로 TodoItem을 여러 개 렌더링하는 컴포넌트다.

// src/TodoList.jsx
import TodoItem from "./TodoItem";

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

export default TodoList;

TodoList는 TodoItem.jsx를 import해서 사용하고 있다. 컴포넌트도 서로 다른 파일에 있을 뿐, 사용하는 방법은 지금까지와 똑같다.


4. App.jsx에서 전체 연결하기

// src/App.jsx
import { useState } from "react";
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 },
  ]);

  return (
    <div>
      <h1>Todo 리스트</h1>
      <TodoList todos={todos} />
    </div>
  );
}

export default App;

App이 가지고 있는 todos state를 TodoList에게 props로 내려주고, TodoList는 그걸 다시 TodoItem에게 하나씩 내려준다. 앞서 배운 "부모→자식 데이터 전달"이 파일이 나뉜 상태에서도 똑같이 적용된다.


5. 기본 레이아웃과 스타일 적용하기

// src/App.jsx
function App() {
  // ...state는 그대로...

  return (
    <div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
      <h1>Todo 리스트</h1>
      <TodoList todos={todos} />
    </div>
  );
}

maxWidth와 margin: "0 auto"로 화면 가운데에 적당한 너비로 정렬해서, 이후 입력창과 버튼을 추가했을 때도 보기 좋은 레이아웃을 미리 잡아둔다.


6. 실습 정리

실습 1) TodoItem.jsx 파일 만들기

src 폴더 안에 TodoItem.jsx 파일을 새로 만들고, todo props를 받아서 텍스트를 <li>로 보여주는 컴포넌트를 작성한 뒤 export default로 내보낸다.

// src/TodoItem.jsx
function TodoItem({ todo }) {
  return (
    <li style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
      {todo.text}
    </li>
  );
}

export default TodoItem;

실습 2) TodoList.jsx 파일 만들기

src 폴더 안에 TodoList.jsx 파일을 새로 만들고, TodoItem을 import해서 todos props를 map()으로 순회하며 렌더링한다.

// src/TodoList.jsx
import TodoItem from "./TodoItem";

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

export default TodoList;

실습 3) App.jsx에서 연결해서 화면에 렌더링하기

App.jsx에서 TodoList를 import하고, todos state를 props로 전달해서 화면에 할 일 3개가 리스트로 나타나는지 확인한다.

// src/App.jsx
import { useState } from "react";
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 },
  ]);

  return (
    <div>
      <h1>Todo 리스트</h1>
      <TodoList todos={todos} />
    </div>
  );
}

export default App;

실습 4) 기본 스타일 적용하기

App의 최상위 <div>에 style을 추가해서 적당한 너비로 가운데 정렬되도록 꾸며본다. (선택) TodoItem의 <li>에도 여백이나 글자 크기를 자유롭게 꾸며본다.

// src/App.jsx
<div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
  <h1>Todo 리스트</h1>
  <TodoList todos={todos} />
</div>

마무리

  • export default 컴포넌트명으로 내보내고 import 컴포넌트명 from "./경로"로 가져오는 패턴이 파일 분리의 기본이다.
  • App → TodoList → TodoItem처럼 props가 여러 단계를 거쳐 내려가도, 부모→자식 전달 방식 자체는 하나의 파일에서 쓰던 것과 동일하다.
  • 컴포넌트를 파일로 나누면 각 파일이 하나의 역할만 담당하게 되어, 나중에 기능을 수정하거나 찾을 때 훨씬 편해진다.

⚛️ React Todo 앱 - 추가 기능 구현

Todo 앱의 첫 번째 기능인 "추가"를 구현했다. 자식 컴포넌트에서 부모에게 데이터를 요청하는 콜백 패턴, 고유 id 생성, 그리고 React에서 배열 state를 다룰 때 반드시 지켜야 할 불변성 개념까지 다룬다.

1. TodoForm.jsx 만들기: 입력값을 state로 관리

새 할 일을 입력받는 TodoForm 컴포넌트를 만든다. 앞에서 배운 Controlled Component 방식으로, input의 값을 state와 연결한다.

// src/TodoForm.jsx
import { useState } from "react";

function TodoForm() {
  const [text, setText] = useState("");

  return (
    <form>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="할 일을 입력하세요"
      />
      <button type="submit">추가</button>
    </form>
  );
}

export default TodoForm;

2. 부모(App)에게 추가 요청하기: 콜백 함수 전달

text state는 TodoForm 자신이 가지고 있지만, 진짜 할 일 목록(todos)은 부모인 App이 가지고 있다. 그래서 TodoForm은 새 할 일을 직접 추가할 수 없고, App으로부터 "추가해줘"라고 요청할 함수를 props로 받아서 호출해야 한다.

// src/TodoForm.jsx
import { useState } from "react";

function TodoForm({ onAdd }) {
  const [text, setText] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    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;

제출 후에는 setText("")로 입력창을 다시 비워준다.


3. 새로운 todo 객체 만들기: 고유 id 부여

App은 onAdd로 전달받은 텍스트를 가지고, id/text/completed를 갖춘 새로운 할 일 객체를 만들어야 한다. id가 서로 겹치면 안 되기 때문에, 현재 시각을 밀리초로 돌려주는 Date.now()를 자주 사용한다.

const newTodo = { id: Date.now(), text: text, completed: false };

왜 id를 1, 2, 3 ... 처럼 직접 정하지 않고 Date.now()를 쓸까? 할 일을 추가했다가 삭제하는 상황이 반복되면, 단순히 1, 2, 3 ... 순서로 번호를 매기는 방식은 항목끼리 번호가 겹치기 쉽다. Date.now()는 "지금 이 순간의 시각"을 숫자로 돌려주는데, 같은 밀리초에 두 개를 동시에 만들지 않는 한 항상 서로 다른 값이 나오기 때문에 간단하면서도 안전하게 고유한 id를 만들 수 있다.


4. 배열 state에 불변성을 지키며 항목 추가하기

배열에 값을 추가할 때 자바스크립트의 push()를 바로 떠올리기 쉽지만, React의 state를 다룰 때는 push()를 사용하면 안 된다.

// ❌ 이렇게 하면 안 됩니다
todos.push(newTodo);
setTodos(todos);
// ✅ 스프레드 연산자로 새 배열을 만들어야 합니다
setTodos([...todos, newTodo]);

push()는 기존 배열 자체를 그 자리에서 바꿔버린다. React는 state가 바뀌었는지 확인할 때 "이전 배열과 새 배열이 완전히 다른 객체인지"를 비교하는데, push()를 쓰면 배열의 내용은 바뀌어도 배열 자체(메모리 주소)는 그대로이기 때문에 React가 변화를 감지하지 못해 화면이 갱신되지 않을 수 있다.

[...todos, newTodo]처럼 스프레드 연산자(...)를 사용하면, 기존 todos의 내용을 그대로 복사한 완전히 새로운 배열을 만들어서 setTodos에 전달하기 때문에 React가 변화를 확실하게 감지한다.

⚠️ 이 원칙(불변성)은 배열뿐 아니라 객체 state를 다룰 때도 똑같이 적용된다. React state는 직접 수정하지 말고 항상 "새로운 값을 만들어서" 교체해야 한다.


5. 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 handleAdd = (text) => {
    const newTodo = { id: Date.now(), text: text, completed: false };
    setTodos([...todos, newTodo]);
  };

  return (
    <div style={{ maxWidth: "400px", margin: "0 auto", padding: "24px" }}>
      <h1>Todo 리스트</h1>
      <TodoForm onAdd={handleAdd} />
      <TodoList todos={todos} />
    </div>
  );
}

export default App;

6. 실습 정리

실습 1) TodoForm.jsx 만들기

src/TodoForm.jsx 파일을 만들고, text state와 input을 연결한 Controlled Component로 작성한다.

// src/TodoForm.jsx
import { useState } from "react";

function TodoForm() {
  const [text, setText] = useState("");

  return (
    <form>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="할 일을 입력하세요"
      />
      <button type="submit">추가</button>
    </form>
  );
}

export default TodoForm;

실습 2) onAdd 콜백 연결하기

TodoForm이 onAdd라는 props를 받도록 수정하고, 제출 시 onAdd(text)를 호출한 뒤 입력창을 비운다. App.jsx에서 TodoForm에 onAdd props를 전달한다. (아직 handleAdd는 콘솔 로그만 찍어도 된다.)

// src/TodoForm.jsx
import { useState } from "react";

function TodoForm({ onAdd }) {
  const [text, setText] = useState("");

  const handleSubmit = (e) => {
    e.preventDefault();
    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;

실습 3) 고유 id 부여하고 todos 배열에 추가하기

App.jsx에 handleAdd 함수를 작성해서, 새 할 일 객체를 만들고 스프레드 연산자로 todos에 추가한다. 입력창에 할 일을 적고 "추가" 버튼을 눌러, 목록에 실제로 항목이 늘어나는지 확인한다.

// src/App.jsx
const handleAdd = (text) => {
  const newTodo = { id: Date.now(), text: text, completed: false };
  setTodos([...todos, newTodo]);
};

실습 4) [선택] 빈 문자열 추가 방지하기

입력창이 비어있는 상태에서 "추가" 버튼을 누르면 빈 항목이 추가되지 않도록, text.trim()이 빈 문자열이면 함수를 그대로 종료(return)하도록 만든다.

// src/TodoForm.jsx
const handleSubmit = (e) => {
  e.preventDefault();
  if (text.trim() === "") {
    return;
  }
  onAdd(text);
  setText("");
};

마무리

  • 자식이 부모의 데이터를 바꿔야 할 때는, 부모가 함수를 props로 내려주고 자식은 그 함수를 호출(onAdd(text))하는 콜백 패턴을 사용한다.
  • Date.now()는 간단하면서도 겹치지 않는 고유 id를 만드는 실용적인 방법이다.
  • React state(특히 배열/객체)는 절대 직접 수정하지 말고, 스프레드 연산자([...todos, newTodo])로 항상 새로운 값을 만들어서 교체해야 한다. 이게 바로 "불변성을 지킨다"는 의미다.

0개의 댓글