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

19 분 소요

지난 시간에는 인라인 편집까지 마쳤습니다. 그런데 새로고침하면 데이터가 모두 사라집니다 — 모든 게 메모리에만 있고 어디에도 저장되지 않거든요. 이번 글에서는 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. 좀 더 큰 실전 빌드 (예정)

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

마무리

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

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

다음 시리즈에서 또 만나요!