리액트로 Todo 앱 만들기 #5 영속화와 마무리

7 분 소요

지난 시간에는 인라인 편집까지 마쳤습니다. 그런데 새로고침하면 데이터가 모두 사라집니다 — 모든 게 메모리에만 있고 어디에도 저장되지 않기 때문입니다. 이번 글에서는 localStorage로 영속화해서 새로고침해도 데이터가 유지되도록 만들고, 시리즈 전체를 회고하며 마무리하겠습니다.

이번 단계 목표 #

  • 할 일 목록이 localStorage에 자동 저장
  • 페이지 로드 시 저장된 데이터를 복원
  • 필터 상태(active, completed)도 같이 유지
  • 시리즈 회고 + 다음 단계 안내

useLocalStorage 커스텀 훅 #

기초 강좌 #13에서 이미 만든 적이 있는 훅입니다. 그대로 가져다 사용합니다.

src/hooks/useLocalStorage.js:

src/hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // 저장 실패는 조용히 무시 (용량 초과 등)
    }
  }, [key, value]);

  return [value, setValue];
}

#13에서 만든 것에 try/catch를 추가했습니다. localStorage는 다음과 같은 실패 가능성이 있습니다.

  • JSON.parse 실패 — 다른 코드가 저장한 잘못된 데이터가 들어 있을 때
  • localStorage.setItem 실패 — 저장 용량 초과(보통 5MB), 시크릿 모드 일부 환경 등

이런 경우에 앱 전체가 깨지는 것보다 조용히 fallback 하는 편이 사용자 경험이 좋습니다.

노트
초기값 인자에 함수를 넘긴 점(useState(() => { ... }))도 다시 짚어두면, 이건 #5에서 살짝 언급했던 “지연 초기화(lazy initializer)” 패턴입니다. useState(localStorage.getItem(...))처럼 직접 호출하면 매 렌더링마다 localStorage를 읽지만, 함수로 감싸면 첫 렌더링 시 한 번만 실행됩니다. localStorage같이 비용이 있는 작업의 초기화에 적합합니다.

TodoApp에 적용 #

useStateuseLocalStorage로 바꾸기만 하면 됩니다. 인터페이스가 똑같으니 다른 코드는 손대지 않아도 됩니다.

src/TodoApp.jsx 변경 부분:

src/TodoApp.jsx (수정 부분)
import { useState } from 'react';
import { useLocalStorage } from './hooks/useLocalStorage';
// ... 다른 import들 ...

function TodoApp() {
  const [todos, setTodos] = useLocalStorage('todos', []);
  const [filter, setFilter] = useLocalStorage('todoFilter', 'all');
  const [editingId, setEditingId] = useState(null);

  // ... 나머지 그대로 ...
}

todosfilter는 영속화 대상이고, editingId는 일시적인 UI 상태이니 그대로 useState로 둡니다 — 페이지를 새로고침했는데 편집 모드가 그대로 떠 있으면 오히려 어색하기 때문입니다.

키 이름('todos', 'todoFilter')은 localStorage에 저장되는 식별자입니다. 다른 앱과 충돌하지 않도록 충분히 명확한 이름으로 두는 게 좋습니다. 큰 앱에서는 'myapp:todos'처럼 prefix를 붙이는 관례도 있습니다.

동작 확인 #

저장하고 다음을 시도해보세요.

  1. 할 일 몇 개 추가하고 일부를 완료 표시
  2. 필터를 “미완료"로 변경
  3. 페이지 새로고침 (Cmd+R 또는 F5)
  4. 모든 할 일과 필터 상태가 그대로 복원됨
  5. 브라우저 개발자 도구 → Application 탭 → Local Storage → 도메인 → 'todos', 'todoFilter' 키가 있는지 확인

새 탭에서 같은 페이지를 열어도 (같은 도메인이면) localStorage가 공유되니 데이터가 그대로 보입니다.

비어 있는 상태로 시작 vs 데모 데이터 #

처음 방문한 사용자에게 빈 화면을 보여주면 “어떻게 쓰는 건지” 막막할 수 있습니다. 시드(seed) 데이터를 넣을지 결정해야 할 때가 있습니다. 옵션은:

A. 빈 상태로 시작 + 안내 문구 (현재)

  • 단순하고 명확
  • “할 일이 없습니다. 새로 추가해보세요.” 안내가 빈 상태 처리 역할

B. 데모 데이터로 시작

const [todos, setTodos] = useLocalStorage('todos', [
  { id: 'demo-1', text: '리액트 공부하기', completed: true },
  { id: 'demo-2', text: '운동 30분', completed: false },
]);
  • 사용자가 바로 기능을 둘러볼 수 있음
  • 단점: 데모 데이터를 일일이 지워야 함

이번 시리즈는 A로 둡니다. 둘 다 합리적이니 본인 취향에 맞게 선택하세요.

다른 탭과의 동기화 (선택) #

같은 도메인의 두 탭을 열어두고 한쪽에서 할 일을 추가하면, 다른 탭은 새로고침할 때까지 변화를 모릅니다. localStorage 자체는 변경됐지만 리액트는 그걸 모르기 때문입니다.

브라우저는 localStorage가 다른 탭에서 변경되면 storage 이벤트를 발생시킵니다. 이걸 구독하면 자동 동기화가 가능합니다.

훅 확장 (선택)
useEffect(() => {
  function handleStorage(e) {
    if (e.key === key && e.newValue !== null) {
      try {
        setValue(JSON.parse(e.newValue));
      } catch {
        // ignore
      }
    }
  }
  window.addEventListener('storage', handleStorage);
  return () => window.removeEventListener('storage', handleStorage);
}, [key]);

기능이 필요한 시점에 추가해도 충분합니다. 이번 시리즈에선 단순화를 위해 생략했습니다.

그 외 개선 아이디어 #

여기까지 만들고 나면 본인이 직접 더 발전시킬 거리가 많이 보일 것입니다.

  • 시각적 다듬기: 인라인 스타일을 CSS Modules / Tailwind / styled-components로 분리. 모바일 반응형
  • 드래그로 순서 변경: react-dnd@dnd-kit/core 사용
  • 카테고리/태그: 할 일에 태그를 달고 필터에 추가
  • 마감일: 날짜 필드 추가, 지난 항목 강조
  • 검색: 입력 텍스트로 즉시 필터링 (#13의 useDebounce 활용 좋은 기회)
  • 다크 모드: #12에서 만든 ThemeContext 적용
  • TypeScript 마이그레이션: 항목 객체 타입을 명시해 안전성 ↑
  • 테스트: Vitest + React Testing Library로 핵심 동작 단위 테스트
  • 백엔드 연동: 정말로 동기화하려면 서버가 필요. JSON Server나 Supabase 같은 BaaS로 실험 가능

각각이 좋은 학습 거리이고, 작은 앱일수록 실험하기 부담이 없습니다. 만들고 싶은 게 있으면 시도해보세요.

시리즈 회고 #

이 시리즈에서 우리는 다음을 만들었습니다.

#추가된 기능등장한 핵심 패턴/도구
1추가/삭제, 컴포넌트 분해단방향 데이터 흐름, 상태 끌어올리기, UUID, controlled form
2완료 토글, 통계불변 업데이트(map 패턴), 파생 값
3필터링, 일괄 삭제데이터 배열로 옵션 렌더, 빈 상태 분기
4인라인 편집useRef, draft state, 키보드 처리, onBlur
5localStorage 영속화커스텀 훅 재사용, lazy initializer

순서대로 따라 오셨다면 단순한 컴포넌트 하나에서 시작해 작은 실전 앱이 만들어지는 과정을 직접 체험하셨을 것입니다.

기초 강좌에서 배운 것들이 어떻게 합쳐졌나 #

이번 시리즈에서 거의 모든 기초 개념이 자연스럽게 등장했습니다.

  • 컴포넌트와 props (#4) — TodoForm, TodoItem, TodoList, TodoStats, TodoFilter로 화면을 책임 단위로 분해
  • useState (#5) — todos, filter, editingId, draft 등 모든 변하는 데이터
  • 이벤트 핸들링 (#6) — onClick, onChange, onSubmit, onKeyDown, onBlur
  • 조건부 렌더링 (#7) — 빈 상태, 편집 모드 분기, 일괄 삭제 버튼
  • 리스트와 key (#8) — todos를 map으로 그리고 UUID를 key로
  • 폼 다루기 (#9) — controlled component 패턴, 체크박스
  • useEffect (#10) — localStorage 동기화, 편집 모드 진입 시 포커스
  • 상태 끌어올리기 (#11) — todos는 TodoApp에, 자식들은 콜백으로 알림
  • useContext (#12) — 등장하지 않았음 (이 규모에선 prop 전달이 더 명확)
  • 커스텀 훅 (#13) — useLocalStorage
  • 성능 최적화 (#14) — 의도적으로 최적화 안 함 (현재 규모엔 불필요)
  • 라우팅 (#15) — 단일 화면이라 미사용 (멀티 페이지 앱이라면 등장)

모든 기초 개념이 매번 등장하지는 않습니다. 필요한 도구를 그 상황에 맞게 골라 쓰는 감각이 점차 생기는 게 더 중요합니다. 한 가지 도구로 모든 걸 해결하려 하기보다, “이 상황엔 이게 더 맞다"고 판단할 수 있게 되는 게 진짜 실력입니다.

다음 단계 추천 #

이 시리즈를 마치셨다면 다음 중 하나로 넘어가시면 좋습니다.

A. 또 다른 작은 프로젝트 만들기 (가장 추천) #

직접 작은 앱을 하나 더 만들어보세요. 본인의 일상에 도움 되는 작은 도구가 가장 좋습니다.

  • 운동 기록기 (어제 한 횟수와 비교)
  • 습관 추적기 (체크 박스 달력)
  • 메모장 (markdown 지원, 검색)
  • 가계부 (월별 합계)
  • 독서 기록 (책 + 메모)

기능을 욕심내기보다 첫 버전을 빠르게 완성 → 사용해보면서 개선하는 사이클이 학습 속도가 빠릅니다.

B. 모던 리액트 19 + Next.js 시리즈 (예정) #

이 블로그의 다음 시리즈로, Server Components / use() / Actions / Suspense 같은 최신 패턴을 다룰 예정입니다. 클라이언트 사이드만으로는 부족한 영역들을 어떻게 풀어내는지 보게 됩니다.

C. 좀 더 큰 실전 빌드 (예정) #

블로그, 쇼핑몰처럼 라우팅 + 상태 관리 + 데이터 페칭이 모두 들어가는 더 큰 프로젝트.

마무리 #

여기까지 따라와주셔서 감사합니다. 단순한 입력창 하나에서 시작해, 컴포넌트 분리 → 토글 → 필터링 → 편집 → 영속화까지 작은 실전 앱이 완성됐습니다. 처음에는 막막해 보이던 것들이 차례차례 더해지면서 손에 익었을 것입니다.

리액트는 도구일 뿐입니다. 우리가 만든 게 화려하진 않아도 머릿속의 아이디어를 손으로 만들어내는 경험을 했다는 게 가장 큰 수확입니다. 그 감각이 쌓이면 어떤 라이브러리, 어떤 프레임워크라도 빠르게 익힐 수 있게 됩니다.

이상으로 Todo 앱 빌드 시리즈를 마칩니다.

X