이번 편의 결과물: 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로 두면 원본이 바뀔 때 손으로 동기화해야 하고, 동기화를 빼먹으면 화면이 실제 데이터와 어긋납니다.
| 파생 값 예 | 잘못된 방식 | 올바른 방식 |
|---|---|---|
| 검색 결과 개수 | useState로 resultCount 저장 후 검색할 때마다 setResultCount | 렌더 중 visibleIds.length 계산 |
| 완독한 책 목록 | useEffect로 doneBooks 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.jsx도 useLocalStorage 대신 useBooks를 씁니다. const { bookIds, booksById, addBook } = useBooks();로 바꾸고, 중복 제목 검사는 bookIds.map((id) => booksById[id].title)로, 추가는 addBook({ ...새책, id: Date.now() })로 바꿉니다.
3. 실행
npm run dev4. 확인
- 목록 페이지에서 검색창에 아무것도 입력하지 않으면 전체 권수가 표시됩니다.
- 검색어를 입력하면 표시 중인 권수만 줄어들고, 전체 권수는 그대로입니다(파생 값이 각각 따로 계산됨).
- 브라우저 개발자 도구 → Application → Local Storage에서
bookshelf-books값이{"byId":...,"allIds":[...]}형태(기존 배열 형태에서 정규화된 구조)로 바뀌어 있는지 확인합니다. - 상세 페이지 URL의
bookId를 존재하지 않는 값으로 바꾸면 404 응답이 발생합니다.
직접 해보기
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에 저장하지 않고 렌더마다 새로 계산합니다.
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 같은 순수 함수만 사용 |