이번 편의 결과물: 목록 페이지에서 필터·검색 시 불필요하게 다시 렌더되던 BookCard들이 memo/useMemo/useCallback 적용 후 필요한 카드만 렌더됩니다. 측정 전/후 콘솔 로그가 주석으로 남습니다. 다루는 개념: React DevTools Profiler, React.memo, useMemo, useCallback
이 편에서 만드는 파일
bookshelf/src/
├── pages/
│ └── BookListPage.jsx ~ useMemo·useCallback 적용
└── components/
└── BookCard.jsx ~ memo 적용개념 정리
부모 컴포넌트가 리렌더되면 자식 컴포넌트도 기본적으로 함께 리렌더됩니다. props가 그대로여도 그렇습니다. 목록이 길어질수록 이 비용이 커집니다. 하지만 최적화를 먼저 넣지 않습니다. 측정 → 원인 확인 → 적용 순서를 지킵니다.
| 도구/훅 | 역할 | 주의할 점 |
|---|---|---|
| React DevTools Profiler | 어떤 컴포넌트가 왜 리렌더됐는지 기록 | ”Record why each component rendered”를 켜야 원인이 보임 |
React.memo(Component) | props가 얕게 같으면 리렌더 건너뜀 | 객체·함수 props가 매번 새로 생성되면 무의미함 |
useMemo(fn, deps) | 계산 결과를 deps가 바뀔 때만 재계산 | 계산이 가벼우면 캐싱 비용이 더 클 수 있음 |
useCallback(fn, deps) | 함수 참조를 deps가 바뀔 때만 새로 생성 | memo 자식에 넘기지 않으면 이득이 없음 |
useCallback은 그 자체로는 아무것도 빠르게 만들지 않습니다. 함수를 받는 자식이 memo로 감싸져 있을 때만 “참조가 그대로라 리렌더를 건너뛴다”는 효과가 생깁니다. memo 없이 useCallback만 붙이는 것은 흔한 오해입니다.
실습
1. 측정 전 상태 확인
BookCard.jsx 함수 맨 위에 console.log('BookCard 렌더:', book.title)를 임시로 추가합니다. 브라우저에서 검색창에 한 글자씩 입력하며 콘솔을 관찰합니다.
BookCard 렌더: 클린 코드
BookCard 렌더: 리팩터링 2판
BookCard 렌더: 모던 자바스크립트 Deep Dive
BookCard 렌더: 함께 자라기
BookCard 렌더: HTTP 완벽 가이드
BookCard 렌더: 누구나 자료 구조와 알고리즘글자 하나를 입력할 때마다 검색 결과와 무관한 카드까지 전부 다시 로그가 찍힙니다. BookListPage가 리렌더될 때 BookCard 전체가 같이 리렌더된다는 뜻입니다. React DevTools의 Profiler 탭에서 녹화 버튼을 누르고 같은 입력을 반복하면, 플레임그래프에서 모든 BookCard 막대가 회색이 아닌 색으로 채워진 것을 볼 수 있습니다.
2. 코드 작성
BookCard를 memo로 감쌉니다.
// src/components/BookCard.jsx
import { memo } from 'react';
function BookCard({ book, onToggle, onDelete }) {
return (
<li className="book-card" style={{ borderColor: book.coverColor }}>
<h3>{book.title}</h3>
<p>{book.author}</p>
<p>상태: {book.status}</p>
<button type="button" onClick={() => onToggle(book.id)}>
상태 변경
</button>
<button type="button" onClick={() => onDelete(book.id)}>
삭제
</button>
</li>
);
}
// props가 얕게 같으면 리렌더를 건너뜁니다.
// onToggle·onDelete가 매 렌더마다 새 함수면 이 memo는 무의미해집니다.
export default memo(BookCard);BookListPage는 06편에서 useLocalStorage('bookshelf-books', [])로 books 배열을 직접 관리하도록 바뀌었습니다. 검색어(keyword)는 useDebounce로 디바운스하고, 필터링 결과는 useMemo로 캐싱하고, 자식에 넘기는 함수는 useCallback으로 감쌉니다. 기존 헤더(<h1>내 책장</h1>, ThemeToggle)는 07편까지와 동일하게 그대로 남아 있습니다. 아래 코드는 이번 편에서 바뀐 부분만 보여줍니다.
// src/pages/BookListPage.jsx (일부 — 헤더 부분은 07편과 동일하게 유지)
import { useMemo, useCallback, useState } from 'react';
import { Link } from 'react-router';
import BookCard from '../components/BookCard.jsx';
import { useLocalStorage } from '../hooks/useLocalStorage.js';
import { useDebounce } from '../hooks/useDebounce.js';
import { booksReducer } from '../reducers/booksReducer.js';
function BookListPage() {
const [books, setBooks] = useLocalStorage('bookshelf-books', []);
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 300);
// 검색어가 그대로면 filteredBooks 배열을 재생성하지 않습니다.
// 배열이 새로 생기면 아래에서 감싼 memo도 무력화되므로 필수입니다.
const filteredBooks = useMemo(() => {
const term = debouncedKeyword.trim().toLowerCase();
if (!term) return books;
return books.filter((book) => book.title.toLowerCase().includes(term));
}, [books, debouncedKeyword]);
// BookCard가 memo로 감싸져 있으므로, 함수 참조가 유지돼야 리렌더를 건너뜁니다.
const handleToggle = useCallback((id) => {
setBooks((prevBooks) => booksReducer(prevBooks, { type: 'toggled', payload: { id } }));
}, [setBooks]);
const handleDelete = useCallback((id) => {
setBooks((prevBooks) => booksReducer(prevBooks, { type: 'deleted', payload: { id } }));
}, [setBooks]);
return (
<section>
{/* ...기존 page-header(h1, ThemeToggle) 유지 */}
<input
type="search"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="제목 검색"
aria-label="책 검색"
/>
<ul className="book-list">
{filteredBooks.map((book) => (
<BookCard key={book.id} book={book} onToggle={handleToggle} onDelete={handleDelete} />
))}
</ul>
<Link to="/books/new">책 추가</Link>
</section>
);
}
export default BookListPage;setBooks는 useLocalStorage가 반환하는 갱신 함수라 컴포넌트 생애 동안 참조가 바뀌지 않습니다. useCallback 의존성 배열에 넣어도 리렌더를 일으키지 않습니다. 이번 편에서 바뀐 부분은 useMemo로 감싼 filteredBooks와 useCallback으로 감싼 두 핸들러, BookCard의 memo뿐입니다.
3. 실행
npm run dev4. 확인
- 검색창에 입력할 때 검색어와 무관한
BookCard콘솔 로그가 더 이상 찍히지 않습니다. - 책 상태를 토글하거나 삭제해도 나머지 카드는 로그가 찍히지 않습니다.
- React DevTools Profiler로 같은 입력을 다시 녹화하면, 변경되지 않은 카드의 막대가 회색(렌더 건너뜀)으로 보입니다.
- 임시로 추가한
console.log를 지웁니다.
직접 해보기
handleToggle의useCallback의존성 배열에books를 추가해 보세요. 검색 시BookCard로그가 다시 찍히는지 확인하고 이유를 설명해 보세요.filteredBooks를useMemo없이 매 렌더마다 새로 계산하도록 되돌리고, Profiler에서 렌더 시간 차이를 비교해 보세요.
풀이 보기
의존성 배열에 books를 추가하면 books가 바뀔 때마다(다른 책의 상태를 바꿔도) handleToggle이 새 함수가 됩니다. 새 함수는 BookCard의 onToggle prop을 바꾸므로 memo의 얕은 비교에서 다르다고 판단되어 모든 카드가 다시 렌더됩니다. setBooks만 의존성에 두면 참조가 고정되어 이런 문제가 없습니다.
useMemo 없이 계산하면 목록이 짧을 때는 체감 차이가 거의 없습니다. 항목 수가 늘어날수록(10편에서 다룸) 차이가 커집니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
memo를 붙였는데도 계속 리렌더됨 | 부모가 매번 새 객체·함수를 props로 넘김 | 객체는 useMemo, 함수는 useCallback으로 참조 고정 |
useCallback을 붙였는데 효과가 없음 | 자식 컴포넌트가 memo로 감싸져 있지 않음 | 자식에도 memo 적용 |
useMemo 의존성 배열에 실제로 쓰는 값을 빠뜨림 | 필터 조건이 바뀌어도 이전 결과가 그대로 나옴 | 계산에 사용한 모든 변수를 deps에 포함 |
모든 컴포넌트에 memo를 습관적으로 붙임 | 측정 없이 미리 최적화함 | Profiler로 실제 리렌더 비용을 먼저 확인 |