React

React로 투두리스트(To-Do List) 만들기: 완벽 가이드


React를 배우고 계신가요? 가장 좋은 연습 프로젝트는 바로 투두리스트(To-Do List) 입니다. 이 글에서는 단순히 리스트만 보여주는 것이 아니라, 추가, 수정, 삭제, 검색, 그리고 전체 삭제 기능까지 포함된 완성도 높은 앱을 만들어보겠습니다.

🎯 이 튜토리얼에서 배울 내용

  • useReducer Hook을 사용한 복잡한 상태 관리
  • Context API를 활용한 효율적인 데이터 전달
  • 컴포넌트 분리와 재사용성
  • 검색, 필터링 등 실무 기능 구현

📊 전체 구조 이해하기

먼저 우리가 만들 앱의 전체 구조를 살펴봅시다.

컴포넌트 구조

graph TD
    A[App.jsx] --> B[Header]
    A --> C[TodoEditor]
    A --> D[TodoList]
    D --> E[TodoItem]
    D --> F[TodoItem]
    D --> G[TodoItem]
    
    style A fill:#61dafb,stroke:#000,stroke-width:2px
    style B fill:#f9f9f9,stroke:#333
    style C fill:#f9f9f9,stroke:#333
    style D fill:#f9f9f9,stroke:#333
    style E fill:#e9ecef,stroke:#333
    style F fill:#e9ecef,stroke:#333
    style G fill:#e9ecef,stroke:#333

데이터 흐름

graph LR
    A[사용자 입력] -->|onCreate| B[App.jsx Reducer]
    B -->|dispatch| C[상태 업데이트]
    C -->|Context| D[TodoList]
    D -->|props| E[TodoItem들]
    E -->|OnUpdate/OnDelete| B
    
    style A fill:#ffd43b,stroke:#333
    style B fill:#61dafb,stroke:#333
    style C fill:#51cf66,stroke:#333
    style D fill:#748ffc,stroke:#333
    style E fill:#ff6b6b,stroke:#333

왜 useReducer와 Context API를 사용할까요?

🤔 문제 상황:

  • useState만 사용하면 할 일 추가, 수정, 삭제… 너무 많은 상태 관리 함수가 필요합니다
  • 깊이 중첩된 컴포넌트에 props를 계속 전달하는 것은 번거롭습니다 (props drilling)

✅ 해결 방법:

  • useReducer: 관련된 상태 변경 로직을 한 곳에 모아서 관리
  • Context API: 중간 컴포넌트를 거치지 않고 필요한 곳에 직접 데이터를 전달
sequenceDiagram
    participant U as 사용자
    participant TE as TodoEditor
    participant A as App (Reducer)
    participant TL as TodoList
    participant TI as TodoItem
    
    U->>TE: 할 일 입력
    TE->>A: onCreate() 호출
    A->>A: CREATE 액션 처리
    A->>TL: Context로 전달
    TL->>TI: 새 아이템 렌더링
    
    U->>TI: 완료 체크
    TI->>A: OnUpdate() 호출
    A->>A: UPDATE 액션 처리
    A->>TL: 업데이트된 상태 전달
    TL->>TI: 리렌더링

1. 프로젝트 준비

먼저 Vite를 사용하여 React 프로젝트를 생성합니다.

npm create vite@latest todo-app -- --template react
cd todo-app
npm install

폴더 구조

src 폴더 안에 component 폴더를 만들고 다음과 같이 파일을 구성할 것입니다.

src/
  ├── App.jsx          # 메인 앱 로직 (Reducer + Context)
  ├── App.css          # 전체 레이아웃
  ├── main.jsx         # 앱 진입점
  ├── index.css        # 글로벌 스타일
  └── component/
       ├── Header.jsx       # 헤더 (날짜 표시)
       ├── Header.css
       ├── TodoEditor.jsx   # 할 일 추가 입력창
       ├── TodoEditor.css
       ├── TodoList.jsx     # 할 일 목록 + 검색
       ├── TodoList.css
       ├── TodoItem.jsx     # 개별 할 일 아이템
       └── TodoItem.css

2. 기본 스타일링 (CSS)

앱을 예쁘게 만들기 위해 CSS를 먼저 작성해둡시다.

App.css

전체 레이아웃을 잡습니다.

/* src/App.css */
.App {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
  background-color: #f8f9fa;
  min-height: 100vh;
  box-sizing: border-box;
}

Header.css

날짜를 표시할 헤더 스타일입니다.

/* src/component/Header.css */
.Header {
  margin-bottom: 20px;
}
.Header h3 {
  color: #2c3e50;
  margin: 0;
}
.Header h1 {
  color: #34495e;
  margin-top: 5px;
  font-size: 2rem;
}

TodoEditor.css

할 일을 입력하는 에디터 스타일입니다.

/* src/component/TodoEditor.css */
.TodoEditor {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
  padding-bottom: 20px;
  border-bottom: 1px solid #e9ecef;
}
.TodoEditor input {
  flex: 1;
  padding: 12px;
  border: 1px solid #dee2e6;
  border-radius: 5px;
  font-size: 16px;
}
.TodoEditor input:focus {
  outline: none;
  border-color: #4dabf7;
}
.TodoEditor button {
  padding: 0 20px;
  background-color: #228be6;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-weight: bold;
  transition: background-color 0.2s;
}
.TodoEditor button:hover {
  background-color: #1c7ed6;
}

TodoList.css

리스트와 검색창 스타일입니다.

/* src/component/TodoList.css */
.TodoList {
  background-color: white;
  padding: 20px;
  border-radius: 10px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.list_header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 15px;
}
.list_header h3 {
  margin: 0;
  font-size: 18px;
}
.searchbar {
  width: 100%;
  padding: 10px;
  margin-bottom: 15px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
  box-sizing: border-box;
}
.list_wrapper {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.clear_btn {
  padding: 6px 12px;
  background-color: #ff6b6b;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
}
.clear_btn:hover {
  background-color: #fa5252;
}

TodoItem.css

개별 아이템 스타일입니다.

/* src/component/TodoItem.css */
.TodoItem {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 0;
  border-bottom: 1px solid #f1f3f5;
}
.TodoItem:last-child {
  border-bottom: none;
}
.checkbox_col {
  width: 20px;
}
.title_col {
  flex: 1;
  font-size: 16px;
}
.date_col {
  font-size: 12px;
  color: #868e96;
}
.btn_col button {
  padding: 5px 10px;
  background-color: #f1f3f5;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  color: #495057;
}
.btn_col button:hover {
  background-color: #e9ecef;
}

3. 핵심 개념: useReducer 이해하기

Reducer란?

Reducer는 “현재 상태”와 “어떤 액션”을 받아서 “새로운 상태”를 반환하는 함수입니다.

function reducer(현재상태, 액션) {
  // 액션의 종류에 따라 다른 작업 수행
  switch(액션.type) {
    case "추가":
      return 추가된_새로운_상태;
    case "삭제":
      return 삭제된_새로운_상태;
    default:
      return 현재상태;
  }
}

우리 앱의 Reducer

graph TD
    A[Reducer] -->|CREATE| B[새 항목 추가]
    A -->|UPDATE| C[완료 상태 토글]
    A -->|DELETE| D[항목 삭제]
    A -->|CLEAR| E[전체 삭제]
    
    B --> F[새로운 상태 반환]
    C --> F
    D --> F
    E --> F
    
    style A fill:#61dafb,stroke:#000,stroke-width:2px
    style F fill:#51cf66,stroke:#333

4. 컴포넌트 구현

이제 핵심 로직과 컴포넌트를 하나씩 구현해봅시다.

App.jsx (핵심 로직)

이것이 우리 앱의 두뇌입니다! 모든 상태와 로직이 여기에 모여 있습니다.

// src/App.jsx
import './App.css'
import React, { useReducer, useRef } from 'react';
import Header from './component/Header'
import TodoEditor from './component/TodoEditor'
import TodoList from './component/TodoList'

// 초기 데이터
const mockTodo = [
  {
    id: 0,
    isDone: false,
    content: 'React 공부하기',
    createDate: new Date().getTime(),
  },
  {
    id: 1,
    isDone: false,
    content: '블로그 글 쓰기',
    createDate: new Date().getTime(),
  },
];

// Reducer 함수: 상태 변화 로직을 담당
function reducer(state, action) {
  switch(action.type) {
    case "CREATE":
      // 기존 배열 앞에 새 항목 추가
      return [action.newItem, ...state];
      
    case "UPDATE":
      // id가 일치하는 항목의 isDone을 반전
      return state.map(it => 
        it.id === action.id 
          ? { ...it, isDone: !it.isDone }
          : it
      );
      
    case "DELETE":
      // id가 일치하지 않는 항목만 남김
      return state.filter(it => it.id !== action.id);
      
    case "CLEAR":
      // 빈 배열 반환
      return [];
      
    default:
      return state;
  }
}

// Context 생성 - 전역 데이터 공유
export const TodoContext = React.createContext();

function App() {
  // useReducer: [현재상태, dispatch함수] = useReducer(리듀서함수, 초기상태)
  const [todo, dispatch] = useReducer(reducer, mockTodo);
  
  // useRef: 컴포넌트가 리렌더링되어도 값이 유지됨 (id 카운터용)
  const idRef = useRef(3);

  // 기능 1: 할 일 추가
  const onCreate = (content) => {
    dispatch({
      type: "CREATE",
      newItem: {
        id: idRef.current,
        isDone: false,
        content,
        createDate: new Date().getTime()
      }
    });
    idRef.current++; // 다음 id를 위해 증가
  };
  
  // 기능 2: 완료 상태 토글
  const OnUpdate = (targetId) => {
    dispatch({
      type: "UPDATE",
      id: targetId
    });
  };

  // 기능 3: 삭제
  const OnDelete = (targetId) => {
    dispatch({
      type: "DELETE",
      id: targetId
    });
  };

  // 기능 4: 전체 삭제
  const OnClear = () => {
    if (window.confirm('정말 모든 할 일을 삭제하시겠습니까?')) {
      dispatch({ type: "CLEAR" });
    }
  };

  return (
    <div className='App'>
      <Header />
      {/* Context로 데이터와 함수를 하위 컴포넌트에 제공 */}
      <TodoContext.Provider value={{ todo, onCreate, OnUpdate, OnDelete, OnClear }}>
        <TodoEditor />
        <TodoList />
      </TodoContext.Provider>
    </div>
  )
}

export default App

💡 핵심 포인트:

  • useReducer는 복잡한 상태 로직을 한 곳에 모아줍니다
  • dispatch를 호출하면 reducer 함수가 실행됩니다
  • Context.Provider로 감싸면 하위 컴포넌트 어디서든 데이터에 접근 가능합니다

Header.jsx

오늘 날짜를 보여주는 간단한 컴포넌트입니다.

// src/component/Header.jsx
import './Header.css';

const Header = () => {
    return (
        <div className='Header'>
            <h3>오늘은 📅</h3>
            <h1>{new Date().toDateString()}</h1>
        </div>
    );
};

export default Header;

TodoEditor.jsx

새로운 할 일을 입력받는 컴포넌트입니다.

// src/component/TodoEditor.jsx
import { useContext, useRef, useState } from 'react';
import { TodoContext } from '../App';
import './TodoEditor.css';

const TodoEditor = () => {
    // Context에서 onCreate 함수 가져오기
    const { onCreate } = useContext(TodoContext);
    
    // 입력값을 저장할 상태
    const [content, setContent] = useState("");
    
    // input 요소에 접근하기 위한 ref
    const inputRef = useRef();

    // 입력값이 변경될 때마다 상태 업데이트
    const onChangeContent = (e) => {
        setContent(e.target.value);
    };

    // 추가 버튼 클릭 시
    const onSubmit = () => {
        // 빈 값 체크
        if (!content) {
            inputRef.current.focus();
            return;
        }
        onCreate(content);
        setContent(""); // 입력창 비우기
    };

    // Enter 키 입력 처리
    const onKeyDown = (e) => {
        if (e.keyCode === 13) {
            onSubmit();
        }
    };

    return (
        <div className='TodoEditor'>
            <input 
                ref={inputRef}
                value={content}
                onChange={onChangeContent}
                onKeyDown={onKeyDown}
                placeholder='새로운 할 일을 입력하세요...' 
            />
            <button onClick={onSubmit}>추가</button>
        </div>
    );
};

export default TodoEditor;

💡 핵심 포인트:

  • useContext로 App에서 제공한 onCreate 함수를 가져옵니다
  • useState로 입력값을 관리합니다
  • useRef로 input에 포커스를 줄 수 있습니다

TodoList.jsx

할 일 목록을 보여주고, 검색 기능을 제공합니다.

// src/component/TodoList.jsx
import { useContext, useState } from 'react';
import { TodoContext } from '../App';
import TodoItem from './TodoItem';
import './TodoList.css';

const TodoList = () => {
    // Context에서 데이터와 함수 가져오기
    const { todo, OnClear } = useContext(TodoContext);
    
    // 검색어 상태
    const [search, setSearch] = useState("");

    // 검색어 변경 시
    const onChangeSearch = (e) => {
        setSearch(e.target.value);
    };

    // 검색 결과 필터링
    const getSearchResult = () => {
        return search === ""
            ? todo // 검색어가 없으면 전체 표시
            : todo.filter((it) => 
                // 대소문자 구분 없이 검색
                it.content.toLowerCase().includes(search.toLowerCase())
              );
    };

    return (
        <div className='TodoList'>
            <div className='list_header'>
                <h3>할 일 목록 📝</h3>
                {/* 할 일이 있을 때만 전체 삭제 버튼 표시 */}
                {todo.length > 0 && (
                    <button className='clear_btn' onClick={OnClear}>
                        전체 삭제
                    </button>
                )}
            </div>
            <input 
                value={search}
                onChange={onChangeSearch}
                className='searchbar' 
                placeholder='검색어를 입력하세요' 
            />
            <div className='list_wrapper'>
                {/* 검색 결과를 TodoItem으로 렌더링 */}
                {getSearchResult().map((it) => (
                    <TodoItem key={it.id} {...it} />
                ))}
            </div>
        </div>
    );
};

export default TodoList;

💡 핵심 포인트:

  • filter로 검색어가 포함된 항목만 표시합니다
  • toLowerCase()로 대소문자 구분 없이 검색합니다
  • map으로 각 할 일을 TodoItem 컴포넌트로 렌더링합니다

TodoItem.jsx

개별 할 일 아이템입니다.

// src/component/TodoItem.jsx
import { useContext } from 'react';
import { TodoContext } from '../App';
import './TodoItem.css';

const TodoItem = ({ id, isDone, content, createDate }) => {
    // Context에서 업데이트/삭제 함수 가져오기
    const { OnUpdate, OnDelete } = useContext(TodoContext);

    // 체크박스 변경 시
    const onChangeCheckbox = () => {
        OnUpdate(id);
    };

    // 삭제 버튼 클릭 시
    const onClickDelete = () => {
        OnDelete(id);
    };

    return (
        <div className='TodoItem'>
            <div className='checkbox_col'>
                <input 
                    onChange={onChangeCheckbox}
                    checked={isDone} 
                    type="checkbox" 
                />
            </div>
            <div className='title_col'>{content}</div>
            <div className='date_col'>
                {new Date(createDate).toLocaleDateString()}
            </div>
            <div className='btn_col'>
                <button onClick={onClickDelete}>삭제</button>
            </div>
        </div>
    );
};

export default TodoItem;

💡 핵심 포인트:

  • Props로 받은 데이터를 화면에 표시합니다
  • useContext로 업데이트/삭제 함수를 가져와 사용합니다

5. 데이터 흐름 정리

전체 과정을 다시 한번 정리해볼까요?

sequenceDiagram
    autonumber
    participant User as 👤 사용자
    participant Editor as TodoEditor
    participant App as App (Reducer)
    participant Context as Context
    participant List as TodoList
    participant Item as TodoItem

    User->>Editor: "React 공부하기" 입력
    Editor->>App: onCreate("React 공부하기")
    App->>App: dispatch({type: "CREATE", ...})
    App->>App: reducer 실행
    App->>Context: 새로운 todo 상태 제공
    Context->>List: todo 배열 전달
    List->>Item: map으로 각 항목 렌더링
    Item-->>User: 화면에 표시

🔄 업데이트 흐름:

  1. 사용자가 체크박스 클릭
  2. TodoItem이 OnUpdate(id) 호출
  3. App의 dispatch가 실행되어 reducer 호출
  4. reducer가 새로운 상태 반환
  5. Context가 업데이트된 상태를 전파
  6. TodoList와 TodoItem이 리렌더링

6. 마무리

이제 터미널에서 npm run dev를 실행하여 앱을 확인해보세요!

✅ 체크리스트

  • 할 일을 추가할 수 있나요?
  • 체크박스로 완료 표시가 되나요?
  • 삭제 버튼이 작동하나요?
  • 검색이 잘 되나요?
  • 전체 삭제가 동작하나요?

🚀 더 나아가기

이 프로젝트를 바탕으로 다음 기능을 추가해보세요:

  1. 로컬 스토리지 연동

    useEffect(() => {
      localStorage.setItem('todos', JSON.stringify(todo));
    }, [todo]);
  2. 완료/미완료 필터

    • 전체 보기 / 완료된 것만 / 미완료만 보기
  3. 우선순위 기능

    • 중요도를 설정하고 색상으로 구분
  4. 수정 기능

    • 할 일 내용을 나중에 수정할 수 있게

Happy Coding! 🚀


참고 자료: 이 글은 ‘한입 크기로 잘라 먹는 리액트’를 바탕으로 작성되었습니다.