icon

안동민 개발노트

3장 : React 컴포넌트 심화

간단한 할 일 목록 앱 만들기


지금까지 우리는 리액트 컴포넌트의 심화 개념을 깊이 있게 학습했습니다. 조건부 렌더링, 리스트와 key 사용, 폼 다루기, useEffect 훅까지 익혔으니, 이제 이 지식을 활용해 작지만 기능적인 애플리케이션을 직접 만들어 볼 시간입니다.

이번 실습에서는 간단한 할 일 목록(Todo List) 애플리케이션을 만들어 봅니다. 이 앱은 다음과 같은 기능을 가집니다.

  • 새로운 할 일 추가
  • 할 일 목록 렌더링
  • 각 할 일 완료 상태 토글 (체크박스)
  • 할 일 삭제

이 실습을 통해 배운 개념이 실제 코드에서 어떻게 맞물리는지 체감하고, 이후 기능 확장 연습으로 자연스럽게 이어가면 됩니다.


실습 목표: Todo 상태 흐름 안정화

이번 실습은 정답 구현을 서두르지 않고, 먼저 흔한 오답을 재현해 교정하는 방식으로 진행합니다. 특히 key 선택과 상태 업데이트 방식이 UI 일관성에 미치는 영향을 점검합니다.

useState 훅을 이용한 상태 관리: 할 일 목록 배열과 입력 필드의 값을 관리합니다.

폼 다루기: 새로운 할 일을 입력받는 <input> 필드와 제출 버튼을 제어 컴포넌트로 구현합니다.

리스트 렌더링과 key: 할 일 목록을 map() 함수와 고유한 key를 사용하여 효율적으로 렌더링합니다.

이벤트 처리: 할 일 추가, 완료 상태 토글, 삭제 버튼 클릭 이벤트를 처리합니다.

조건부 렌더링: 할 일의 완료 상태에 따라 텍스트에 취소선을 긋는 등의 시각적 변화를 줍니다.

(선택 사항) useEffect 활용: 초기 할 일 목록을 로드하거나, 할 일 목록이 변경될 때마다 로컬 스토리지에 저장하는 등의 부수 효과를 구현합니다.


준비 단계: 상태 모델 설계

준비 단계에서는 상태 경계를 먼저 정리합니다. 로컬 UI 상태처럼 단순한 값은 useState로 충분하지만, 저장/복원 규칙이 늘어나는 시점부터는 상태 흐름을 명시적으로 분리해 관리하는 편이 안전합니다.

Vite로 생성된 프로젝트가 있다고 가정합니다. src 폴더에 components 폴더를 만들고, 그 안에 TodoList.js 파일을 만들고 App.js에서 이를 렌더링하는 방식으로 진행하겠습니다.

src/App.js 수정 (기존 내용 대체)
src/App.js
import React from 'react';
import './App.css'; // 필요하다면 CSS 파일을 유지합니다.
import TodoList from './components/TodoList'; // TodoList 컴포넌트 불러오기

function App() {
  return (
    <div className="App">
      <h1>나의 멋진 Todo List</h1>
      <TodoList />
    </div>
  );
}

export default App;
src/components/TodoList.js 파일 생성 및 기본 구조 작성
src/components/TodoList.js
import React, { useState, useEffect } from 'react';

function TodoList() {
  // 1. 상태 정의: 할 일 목록 배열 (todoItems)과 새 할 일 입력 값 (newTodo)
  const [todoItems, setTodoItems] = useState(() => {
    // (선택 사항) 로컬 스토리지에서 초기 할 일 목록 불러오기
    const savedTodos = localStorage.getItem('todoItems');
    return savedTodos ? JSON.parse(savedTodos) : [];
  });
  const [newTodo, setNewTodo] = useState('');

  // 2. (선택 사항) todoItems가 변경될 때마다 로컬 스토리지에 저장
  useEffect(() => {
    localStorage.setItem('todoItems', JSON.stringify(todoItems));
  }, [todoItems]); // todoItems가 의존성 배열에 있으므로, 변경될 때마다 실행

  // 3. 새 할 일 입력 필드 변경 핸들러
  const handleInputChange = (e) => {
    setNewTodo(e.target.value);
  };

  // 4. 할 일 추가 핸들러
  const handleAddTodo = (e) => {
    e.preventDefault(); // 폼 제출 시 페이지 새로고침 방지
    if (newTodo.trim() === '') return; // 빈 값은 추가하지 않음

    const newId = todoItems.length > 0 ? Math.max(...todoItems.map(item => item.id)) + 1 : 1;
    const newTodoItem = {
      id: newId,
      text: newTodo,
      completed: false, // 초기에는 완료되지 않은 상태
    };

    setTodoItems([...todoItems, newTodoItem]); // 기존 목록에 새 할 일 추가
    setNewTodo(''); // 입력 필드 초기화
  };

  // 5. 할 일 완료 상태 토글 핸들러
  const handleToggleComplete = (id) => {
    setTodoItems(
      todoItems.map((item) =>
        item.id === id ? { ...item, completed: !item.completed } : item
      )
    );
  };

  // 6. 할 일 삭제 핸들러
  const handleDeleteTodo = (id) => {
    setTodoItems(todoItems.filter((item) => item.id !== id));
  };

  // 7. JSX 렌더링
  return (
    <div style={{ maxWidth: '500px', margin: '30px auto', padding: '20px', border: '1px solid #eee', borderRadius: '10px', boxShadow: '0 2px 10px rgba(0,0,0,0.1)' }}>
      {/* 할 일 추가 폼 */}
      <form onSubmit={handleAddTodo} style={{ display: 'flex', marginBottom: '20px' }}>
        <input
          type="text"
          value={newTodo}
          onChange={handleInputChange}
          placeholder="새 할 일을 입력하세요..."
          style={{ flexGrow: 1, padding: '10px', fontSize: '16px', border: '1px solid #ddd', borderRadius: '5px 0 0 5px' }}
        />
        <button type="submit" style={{ padding: '10px 15px', fontSize: '16px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '0 5px 5px 0', cursor: 'pointer' }}>
          추가
        </button>
      </form>

      {/* 할 일 목록 */}
      {todoItems.length === 0 ? (
        <p style={{ textAlign: 'center', color: '#666' }}>아직 할 일이 없습니다. 새 할 일을 추가해 보세요!</p>
      ) : (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {todoItems.map((item) => (
            <li
              key={item.id} // ⭐️ key 속성 중요!
              style={{
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'space-between',
                padding: '10px',
                borderBottom: '1px dashed #eee',
                backgroundColor: item.completed ? '#e6ffe6' : 'white', // 조건부 스타일링
              }}
            >
              <div style={{ display: 'flex', alignItems: 'center', flexGrow: 1 }}>
                <input
                  type="checkbox"
                  checked={item.completed} // ⭐️ checked 속성으로 state와 연결
                  onChange={() => handleToggleComplete(item.id)}
                  style={{ marginRight: '10px', transform: 'scale(1.2)' }}
                />
                <span
                  style={{
                    fontSize: '18px',
                    textDecoration: item.completed ? 'line-through' : 'none', // 조건부 렌더링 (취소선)
                    color: item.completed ? '#999' : '#333',
                  }}
                >
                  {item.text}
                </span>
              </div>
              <button
                onClick={() => handleDeleteTodo(item.id)}
                style={{
                  backgroundColor: '#dc3545',
                  color: 'white',
                  border: 'none',
                  padding: '5px 10px',
                  borderRadius: '5px',
                  cursor: 'pointer',
                  fontSize: '14px',
                }}
              >
                삭제
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default TodoList;

코드 설명 및 핵심 개념 복습

TodoList.js 코드는 우리가 배운 여러 개념을 통합하여 사용하고 있습니다.

useState를 이용한 상태 관리
  • todoItems: 현재 할 일 목록을 담는 배열 상태입니다. 각 할 일 객체는 id, text, completed 속성을 가집니다.
  • newTodo: 새로운 할 일을 입력받는 <input> 필드의 값을 관리하는 상태입니다.
폼 다루기 (제어 컴포넌트)
  • handleInputChange: <input> 필드의 onChange 이벤트에 연결되어 newTodo 상태를 실시간으로 업데이트합니다. value={newTodo}를 통해 input 필드가 newTodo 상태에 의해 제어됩니다.
  • handleAddTodo: <form>onSubmit 이벤트에 연결됩니다. e.preventDefault()로 페이지 새로고침을 막고, newTodotodoItems 배열에 추가한 후 newTodo를 초기화합니다. 새로운 할 일에는 id를 부여하여 나중에 key로 사용합니다.
리스트 렌더링과 key
  • todoItems.map((item) => ...): todoItems 배열을 순회하며 각 할 일 아이템을 <li> JSX 요소로 변환합니다.
  • key={item.id}: 각 <li> 요소에 할 일 객체의 고유 idkey로 부여합니다. 이는 리액트가 목록을 효율적으로 업데이트하는 데 필수적입니다.
이벤트 처리
  • handleAddTodo, handleToggleComplete, handleDeleteTodo 함수들이 각각의 버튼 클릭이나 체크박스 변경 이벤트에 연결되어 있습니다.
  • handleToggleComplete, handleDeleteTodoid를 인자로 받아 특정 할 일에 대한 작업을 수행합니다. map() 함수 내부에서 onClick={() => handleDeleteTodo(item.id)}와 같이 화살표 함수로 감싸 인자를 전달하는 방식이 사용되었습니다.
조건부 렌더링
  • todoItems.length === 0 ? <p> ... </p> : <ul> ... </ul>: todoItems가 비어있으면 할 일이 없습니다 메시지를, 그렇지 않으면 목록을 렌더링합니다. (삼항 연산자)
  • backgroundColor: item.completed ? '#e6ffe6' : 'white': 할 일이 완료되면 배경색을 변경합니다.
  • textDecoration: item.completed ? 'line-through' : 'none': 할 일이 완료되면 텍스트에 취소선을 적용합니다. (삼항 연산자)
  • checked={item.completed}: 체크박스의 checked 속성을 item.completed 상태와 연결하여 제어 컴포넌트로 만듭니다.
(선택 사항) useEffect 활용 (로컬 스토리지)
  • 첫 번째 useState의 초기값 설정 부분에서 localStorage.getItem('todoItems')를 통해 이전에 저장된 할 일 목록이 있다면 불러와 todoItems의 초기값으로 사용합니다.
  • 두 번째 useEffect 훅은 todoItems 상태가 변경될 때마다 ([todoItems] 의존성 배열 때문에) localStorage.setItem을 사용하여 현재의 todoItems 배열을 로컬 스토리지에 JSON 문자열 형태로 저장합니다. 이렇게 하면 페이지를 새로고침해도 할 일 목록이 유지됩니다.

검증 순서: 추가·토글·삭제

실습 검증은 추가 -> 토글 -> 삭제 -> 새로고침 순서로 고정합니다. 이 순서를 유지하면 상태 동기화 누락과 key 관련 이상 동작을 빠르게 찾을 수 있습니다.

위의 App.jsTodoList.js 코드를 각각의 파일에 복사하여 붙여넣으세요.

개발 서버가 실행 중이지 않다면, 프로젝트 폴더에서 npm run dev (또는 yarn dev) 명령어를 실행합니다.

브라우저를 열어 http://localhost:5173 (Vite 기본 포트)에 접속합니다.

새로운 할 일을 입력하고 추가 버튼을 클릭해보세요.

추가된 할 일 옆의 체크박스를 클릭하여 완료 상태를 토글해보고, 삭제 버튼을 클릭하여 할 일을 제거해보세요.

(선택 사항) 페이지를 새로고침했을 때 할 일 목록이 유지되는지 확인해 보세요.

개발자 도구(F12)의 Console 탭과 Components 탭을 활용하여 stateprops의 변화를 관찰해 보세요.


이제 여러분은 리액트의 핵심 개념들을 모두 통합하여 작지만 실제 동작하는 웹 애플리케이션의 한 부분을 직접 구현해 보셨습니다. 이 실습을 통해 조건부 렌더링, 리스트 렌더링, 폼 다루기, 그리고 useEffect 훅이 어떻게 상호작용하며 동적인 UI를 만드는지 명확하게 이해하셨기를 바랍니다.

목차