Skip to Content
WebReactReact 기초11. useEffect로 localStorage 저장·제목 갱신

이번 편의 결과물: 책 목록이 localStorage에 저장되어 새로고침해도 유지되고, 브라우저 탭 제목에 책 권수가 표시됩니다. 다루는 개념: useEffect 기본 형태와 의존성 배열, 클린업 함수, 외부 시스템과의 동기화

이 편에서 만드는 파일

bookshelf/src/ └── App.jsx ~ useEffect 2개 추가, 초기 상태를 localStorage에서 복원

개념 정리

렌더링 함수는 화면에 무엇을 보여줄지 계산하는 데만 집중해야 합니다. localStorage에 저장하거나 document.title을 바꾸는 일은 화면 계산과 무관한 부수 효과(side effect)입니다. React는 이런 작업을 렌더링 도중이 아니라 렌더링이 끝난 뒤 실행하도록 useEffect를 제공합니다.

useEffect(() => { // 렌더링 이후 실행할 코드 return () => { // 다음 effect 실행 전, 또는 컴포넌트가 사라지기 전 정리(clean-up) }; }, [의존성1, 의존성2]);
의존성 배열실행 시점
생략매 렌더링 후마다 실행
[]처음 마운트된 직후 한 번만 실행
[books]처음 실행 + books가 바뀐 렌더링 후마다 실행

useEffect를 “책 목록이 바뀔 때 localStorage와 동기화한다”처럼 외부 시스템과 맞춘다는 관점으로 보면 언제 써야 할지 판단하기 쉽습니다. 반대로 state끼리 계산만 하면 되는 값(예: 평균 별점)은 effect 없이 렌더링 중에 바로 계산하는 편이 낫습니다.

클린업 함수는 effect가 타이머·이벤트 리스너·구독처럼 “뒷정리가 필요한” 작업을 할 때 씁니다. localStorage 저장이나 document.title 변경은 매번 값을 덮어쓸 뿐이라 클린업이 필요 없습니다.

실습

1. 파일 열기

App.jsx를 엽니다.

2. 코드 작성

useState의 초깃값을 함수로 넘기면(지연 초기화) 컴포넌트가 처음 마운트될 때 한 번만 실행됩니다. 이 방식으로 localStorage에 저장된 값이 있으면 그것을 먼저 씁니다.

// src/App.jsx import { useState, useEffect } from 'react'; import BookList from './components/BookList.jsx'; import BookForm from './components/BookForm.jsx'; import initialBooks from './data/books.seed.json'; const STORAGE_KEY = 'bookshelf-books'; function loadBooks() { const saved = localStorage.getItem(STORAGE_KEY); if (saved === null) { return initialBooks; } try { return JSON.parse(saved); } catch { return initialBooks; } } function App() { const [books, setBooks] = useState(loadBooks); useEffect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(books)); }, [books]); useEffect(() => { document.title = `bookshelf (${books.length}권)`; }, [books.length]); function handleAddBook(newBook) { setBooks([...books, newBook]); } function handleDeleteBook(id) { setBooks(books.filter((book) => book.id !== id)); } function handleRateBook(id, rating) { setBooks(books.map((book) => (book.id === id ? { ...book, rating } : book))); } function handleToggleStatus(id, status) { setBooks(books.map((book) => (book.id === id ? { ...book, status } : book))); } return ( <div className="app"> <h1>bookshelf</h1> <p>총 {books.length}권</p> <BookList books={books} onDelete={handleDeleteBook} onRate={handleRateBook} onToggleStatus={handleToggleStatus} /> <BookForm onAddBook={handleAddBook} /> </div> ); } export default App;

useEffect를 분리한 이유가 있습니다. 저장은 books 배열 전체가 바뀔 때마다 필요하지만, 제목 갱신은 권수(books.length)가 바뀔 때만 필요합니다. 별점만 바뀌고 권수는 그대로면 두 번째 effect는 다시 실행되지 않습니다.

3. 실행

npm run dev

4. 확인

  • 책을 추가한 뒤 브라우저를 새로고침해도 추가한 책이 그대로 남아 있습니다.
  • 브라우저 탭 제목이 bookshelf (6권)처럼 권수를 포함해 표시됩니다.
  • 개발자 도구의 애플리케이션(Application) 탭 → Local Storage에서 bookshelf-books 키에 JSON 문자열이 저장된 것을 확인할 수 있습니다.
  • 별점만 바꾸고 권수는 그대로면 탭 제목은 바뀌지 않습니다(React DevTools의 Profiler로 두 번째 effect가 재실행되지 않는지 확인할 수도 있습니다. 16편에서 다룹니다).

직접 해보기

  1. localStorage.getItem이 실패하거나 저장된 값이 손상된 JSON일 때를 대비해 이미 try/catch를 넣었습니다. JSON.parse에 일부러 잘못된 문자열을 저장해 두고(localStorage.setItem('bookshelf-books', '{망가진 json')) 새로고침해도 앱이 죽지 않는지 확인해 보세요.
  2. useEffect를 하나 더 추가해, 읽고 있는 책(status === 'reading')이 있으면 탭 제목 앞에 표시가 붙도록 만들어 보세요.

풀이 보기

useEffect(() => { const readingCount = books.filter((book) => book.status === 'reading').length; const prefix = readingCount > 0 ? `읽는 중 ${readingCount} · ` : ''; document.title = `${prefix}bookshelf (${books.length}권)`; }, [books]);

의존성을 books.length가 아니라 books 전체로 바꿔야 별점 변경이 아닌 상태 변경도 감지합니다. 두 effect를 합쳤으므로 앞의 제목 effect는 지웁니다.

자주 하는 실수

증상원인고치는 법
새로고침하면 추가한 책이 사라짐useState(initialBooks)를 그대로 두고 저장 effect만 추가초깃값을 loadBooks 함수로 바꿔 저장된 값을 먼저 읽음
effect가 무한히 반복 실행됨의존성 배열에 매 렌더링마다 새로 만들어지는 객체·함수를 넣음원시값(books.length)이나 실제로 필요한 값만 넣기
의존성 배열을 생략함”그냥 한 번만 저장하면 되겠지”라는 추측배열을 생략하면 매 렌더링마다 실행됨을 기억, 필요한 값만 명시
JSON.parse 에러로 흰 화면이 뜸손상된 저장값을 그대로 파싱try/catch로 감싸고 실패 시 기본값으로 대체

확인 문제

문제 14지선다
의존성 배열을 빈 배열 []로 두면 useEffect는 언제 실행되는가
문제 24지선다
localStorage 저장과 document.title 갱신을 하나의 useEffect가 아니라 두 개로 나눈 이유는 무엇인가
문제 34지선다
useEffect의 클린업 함수가 특히 필요한 경우는 다음 중 무엇인가

참고 자료

Last updated on