Skip to Content
WebReactReact 중급13. 상태 설계: 파생 상태와 정규화

이번 편의 결과물: books 상태가 { byId, allIds } 정규화 구조로 바뀌고, 목록·상세 페이지가 이 구조를 읽습니다. · 다루는 개념: 파생 상태, 정규화, byId 조회

이 편에서 만드는 파일

bookshelf/src/ ├── reducers/ │ └── booksReducer.js ~ (정규화 구조로 재작성) ├── utils/ │ └── booksStorage.js + (loader와 훅이 함께 쓰는 읽기·쓰기 함수) ├── hooks/ │ └── useBooks.js + (reducer + 영속화를 묶은 훅) └── pages/ ├── BookListPage.jsx ~ (allIds 기반 렌더, 파생 필터링) ├── BookDetailPage.jsx ~ (byId 조회로 loader 단순화) └── NewBookPage.jsx ~ (useLocalStorage 대신 useBooks 사용)

개념 정리

파생 상태는 다른 state나 props에서 계산 가능한 값입니다. 이런 값을 별도 state로 두면 원본이 바뀔 때 손으로 동기화해야 하고, 동기화를 빼먹으면 화면이 실제 데이터와 어긋납니다.

파생 값 예잘못된 방식올바른 방식
검색 결과 개수useStateresultCount 저장 후 검색할 때마다 setResultCount렌더 중 visibleIds.length 계산
완독한 책 목록useEffectdoneBooks state 갱신렌더 중 bookIds.filter(...)
폼 입력이 유효한지제출 시점에만 isValid state로 저장렌더마다 검증 함수 호출

지금까지 books는 배열이었습니다. 배열에서 책 하나를 찾거나 수정하려면 find·map으로 전체를 훑어야 합니다(O(n)). 정규화는 배열을 두 부분으로 나눕니다.

구조조회순서 유지수정
배열 [{...}, {...}]O(n) (find)배열 순서 그대로map으로 새 배열 생성
정규화 { byId, allIds }O(1) (byId[id])allIds 배열이 별도로 유지byId만 얕은 복사

byId는 아이디를 키로 하는 객체이고, allIds는 아이디만 담은 배열입니다. 렌더할 목록 순서는 allIds가 결정하고, 각 항목의 실제 데이터는 byId에서 찾습니다. 상세 페이지처럼 아이디 하나로 데이터 하나를 찾는 화면에서 정규화 효과가 가장 크게 드러납니다.

흔한 오해: “책이 몇 권 안 되니 정규화가 필요 없다”고 생각하기 쉽지만, 이 편의 목적은 성능이 아니라 불변 업데이트를 단순하게 만드는 것입니다. 배열은 map으로 전체를 순회해야 하지만, byId는 바꿀 항목만 교체하면 됩니다.

실습

1. 파일 만들기 / 열기

src/utils/booksStorage.js를 새로 만들고, src/reducers/booksReducer.js를 엽니다.

2. 코드 작성

// src/utils/booksStorage.js const STORAGE_KEY = 'bookshelf-books'; function normalize(list) { const byId = {}; const allIds = []; for (const book of list) { byId[book.id] = book; allIds.push(book.id); } return { byId, allIds }; } export function readBooksState() { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { byId: {}, allIds: [] }; const parsed = JSON.parse(raw); // 이전 배열 형태와 호환: 배열로 저장돼 있으면 정규화해서 돌려준다 if (Array.isArray(parsed)) return normalize(parsed); return parsed; } export function writeBooksState(state) { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }
// src/reducers/booksReducer.js export const initialBooksState = { byId: {}, allIds: [] }; export function booksReducer(state, action) { switch (action.type) { case 'books/hydrated': { return action.payload; } case 'books/added': { const book = action.payload; return { byId: { ...state.byId, [book.id]: book }, allIds: [...state.allIds, book.id], }; } case 'books/updated': { const { id, changes } = action.payload; const prev = state.byId[id]; if (!prev) return state; return { ...state, byId: { ...state.byId, [id]: { ...prev, ...changes } } }; } case 'books/toggled': { const prev = state.byId[action.payload.id]; if (!prev) return state; const status = prev.status === 'done' ? 'reading' : 'done'; return { ...state, byId: { ...state.byId, [prev.id]: { ...prev, status } } }; } case 'books/deleted': { const { id } = action.payload; const nextById = { ...state.byId }; delete nextById[id]; return { byId: nextById, allIds: state.allIds.filter((bookId) => bookId !== id) }; } default: { throw new Error('알 수 없는 액션 타입입니다: ' + action.type); } } }
// src/hooks/useBooks.js import { useReducer, useEffect, useCallback } from 'react'; import { booksReducer, initialBooksState } from '../reducers/booksReducer'; import { readBooksState, writeBooksState } from '../utils/booksStorage'; export function useBooks() { const [state, dispatch] = useReducer(booksReducer, initialBooksState, readBooksState); useEffect(() => { writeBooksState(state); }, [state]); const addBook = useCallback((book) => dispatch({ type: 'books/added', payload: book }), []); const updateBook = useCallback( (id, changes) => dispatch({ type: 'books/updated', payload: { id, changes } }), [] ); const toggleBook = useCallback((id) => dispatch({ type: 'books/toggled', payload: { id } }), []); const deleteBook = useCallback((id) => dispatch({ type: 'books/deleted', payload: { id } }), []); return { booksById: state.byId, bookIds: state.allIds, addBook, updateBook, toggleBook, deleteBook }; }
// src/pages/BookListPage.jsx import { useMemo, useState } from 'react'; import { Link } from 'react-router'; import { useBooks } from '../hooks/useBooks'; import { useDebounce } from '../hooks/useDebounce'; import BookCard from '../components/BookCard'; export default function BookListPage() { const { booksById, bookIds, toggleBook, deleteBook } = useBooks(); const [keyword, setKeyword] = useState(''); const debouncedKeyword = useDebounce(keyword, 300); // 파생 값: 검색어에 맞는 아이디 목록을 렌더마다 계산한다. state로 만들지 않는다. const visibleIds = useMemo(() => { const term = debouncedKeyword.trim().toLowerCase(); if (!term) return bookIds; return bookIds.filter((id) => { const book = booksById[id]; return book.title.toLowerCase().includes(term) || book.author.toLowerCase().includes(term); }); }, [bookIds, booksById, debouncedKeyword]); return ( <section> <h1>내 책장</h1> {/* ...기존 ThemeToggle 유지 */} <input type="search" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="제목 또는 저자 검색" aria-label="책 검색" /> <p>{visibleIds.length}권 표시 중 (전체 {bookIds.length}권)</p> <ul> {visibleIds.map((id) => ( <li key={id}> <BookCard book={booksById[id]} onToggle={() => toggleBook(id)} onDelete={() => deleteBook(id)} /> </li> ))} </ul> <Link to="/books/new">새 책 추가</Link> </section> ); }
// src/pages/BookDetailPage.jsx import { useLoaderData } from 'react-router'; import { readBooksState } from '../utils/booksStorage'; export async function bookDetailLoader({ params }) { const { byId } = readBooksState(); const book = byId[params.bookId]; if (!book) { throw new Response('책을 찾을 수 없습니다', { status: 404 }); } return book; } export default function BookDetailPage() { const book = useLoaderData(); return ( <article> <h1>{book.title}</h1> <p>저자: {book.author}</p> <p>상태: {book.status}</p> </article> ); }

NewBookPage.jsxuseLocalStorage 대신 useBooks를 씁니다. const { bookIds, booksById, addBook } = useBooks();로 바꾸고, 중복 제목 검사는 bookIds.map((id) => booksById[id].title)로, 추가는 addBook({ ...새책, id: Date.now() })로 바꿉니다.

3. 실행

npm run dev

4. 확인

  • 목록 페이지에서 검색창에 아무것도 입력하지 않으면 전체 권수가 표시됩니다.
  • 검색어를 입력하면 표시 중인 권수만 줄어들고, 전체 권수는 그대로입니다(파생 값이 각각 따로 계산됨).
  • 브라우저 개발자 도구 → Application → Local Storage에서 bookshelf-books 값이 {"byId":...,"allIds":[...]} 형태(기존 배열 형태에서 정규화된 구조)로 바뀌어 있는지 확인합니다.
  • 상세 페이지 URL의 bookId를 존재하지 않는 값으로 바꾸면 404 응답이 발생합니다.

직접 해보기

  1. allIds 순서는 그대로 두고, 목록 페이지에 “제목순 정렬” 체크박스를 추가해 정렬된 아이디 배열을 파생 값으로 계산해보세요. allIds 자체는 바꾸지 않습니다.

답 보기

const sortedIds = useMemo(() => { const ids = [...visibleIds]; if (!sortByTitle) return ids; return ids.sort((a, b) => booksById[a].title.localeCompare(booksById[b].title)); }, [visibleIds, booksById, sortByTitle]);

정렬 결과는 allIds에 저장하지 않고 렌더마다 새로 계산합니다.

  1. books/updated 액션으로 메모(memo 필드)를 수정하는 작은 폼을 상세 페이지에 추가해보세요.

답 보기

updateBook(book.id, { memo: event.target.value })onBlur에서 호출합니다. byId의 해당 항목만 교체되어 다른 책은 참조가 유지됩니다.

자주 하는 실수

증상원인고치는 법
책 삭제 후에도 목록에 빈 칸이 보임byId에서만 지우고 allIds에서 아이디를 안 지움deleted 액션에서 두 필드를 함께 갱신
목록이 정규화 후 렌더되지 않음컴포넌트가 여전히 books.map을 씀bookIds.map으로 바꾸고 booksById[id]로 조회
검색 결과 개수가 한 박자 늦게 바뀜결과 개수를 useState로 따로 저장state를 없애고 렌더 중 .length 계산
상세 페이지가 새로고침 후 빈 화면loader가 컴포넌트 훅(useBooks)을 호출하려 함loader는 훅 대신 readBooksState 같은 순수 함수만 사용

확인 문제

문제 14지선다
파생 상태를 별도 state로 만들지 않아야 하는 이유로 가장 알맞은 것은?
문제 24지선다
배열 대신 { byId, allIds } 구조를 쓰는 가장 큰 이유는?
문제 34지선다
이 편의 booksReducer에서 books/deleted 액션이 하는 일은?
문제 44지선다
BookDetailPage의 loader 함수 안에서 하면 안 되는 것은?

참고 자료

Last updated on