Skip to Content
WebReactReact 실무17. Profiler·Lighthouse로 성능 측정과 개선

이번 편의 결과물: 정렬·필터 조작마다 전부 다시 그려지던 책 테이블 행이 필요한 행만 리렌더되도록 고칩니다. 개선 전후 Lighthouse 성능 점수를 표로 기록합니다. · 다루는 개념: React Profiler로 리렌더 원인 찾기, memo와 selector로 리렌더 범위 좁히기, Lighthouse 성능 측정

이 편에서 만드는 파일

bookshelf/src/ ├── components/ │ ├── BookTable.jsx ~ 행 렌더링을 BookRow로 위임, 정렬 상태만 selector로 구독 │ └── BookRow.jsx + 테이블 행을 별도 컴포넌트로 분리, React.memo 적용 ├── pages/ │ └── BookListPage.jsx ~ <Profiler>로 렌더 구간을 감싸고 행 클릭 핸들러 추가 └── utils/ └── onRenderCallback.js + Profiler onRender 콜백(콘솔 로그)

개념 정리

리렌더는 왜 퍼지는가

부모 컴포넌트가 리렌더되면 그 안의 모든 자식이 기본적으로 함께 리렌더됩니다. props가 그대로여도 마찬가지입니다. BookListPage가 검색어 상태 하나 때문에 리렌더되면, 그 아래 BookTableBookTable이 그리는 모든 BookRow 100개가 전부 다시 실행됩니다. 행 하나당 계산량은 작아도 100개가 쌓이면 화면이 버벅입니다.

Zustand 스토어를 컴포넌트 전체에서 구독하는 방식도 같은 문제를 만듭니다. useUiStore()처럼 스토어 전체를 구독하면, 스토어 안의 어떤 값이 바뀌어도 그 컴포넌트가 리렌더됩니다. 정렬 기준만 바뀌었는데 검색어를 구독하던 컴포넌트까지 다시 그려지는 식입니다.

<Profiler>가 알려주는 것

React의 <Profiler> 컴포넌트는 감싼 트리가 리렌더될 때마다 onRender 콜백을 호출합니다. 콜백은 어떤 컴포넌트가, 왜(mount 또는 update), 얼마나 걸려서 리렌더됐는지 인자로 받습니다.

인자
id<Profiler id="...">에 준 이름
phase"mount"(최초 렌더) 또는 "update"(리렌더)
actualDuration이번 렌더에 실제로 걸린 시간(ms)
baseDuration메모이제이션 없이 트리 전체를 다시 그렸다면 걸렸을 시간(ms)

개발자 도구의 React DevTools Profiler 탭이 같은 정보를 시각화해서 보여줍니다. 이 편에서는 콘솔 로그로 확인하고, DevTools Profiler로 다시 확인하는 순서로 진행합니다.

memo가 막아주는 것과 못 막는 것

React.memo(Component)로 감싼 컴포넌트는 props가 이전과 얕게 같으면 리렌더를 건너뜁니다. 부모가 리렌더되어도 자식의 props가 그대로면 자식은 다시 그리지 않습니다. 단, props에 매 렌더마다 새로 만들어지는 함수나 객체(onClick={() => ...})를 넘기면 항상 “다르다”고 판정되어 memo가 무력화됩니다. 이 편에서는 부모가 넘기는 콜백을 안정된 참조로 유지해 이 문제를 피합니다.

실습

1. Profiler로 문제 재현하기

렌더 정보를 콘솔에 남기는 콜백을 만듭니다.

// src/utils/onRenderCallback.js export function onRenderCallback(id, phase, actualDuration, baseDuration) { if (import.meta.env.DEV) { console.log( `[Profiler] ${id} - ${phase} - actual: ${actualDuration.toFixed(2)}ms / base: ${baseDuration.toFixed(2)}ms` ) } }

BookListPage에서 테이블을 감쌉니다.

// src/pages/BookListPage.jsx (Profiler로 감싸는 부분만 발췌 — 조회·검색·페이지 로직은 11편 코드 유지) import { Profiler } from 'react' import { onRenderCallback } from '../utils/onRenderCallback' import BookTable from '../components/BookTable' // ...useUiStore, useDebounce, useBooks import는 11편 코드 유지 function BookListPage() { // ...11편의 statusFilter·sortBy·sortOrder·searchQuery·page·useBooks 로직 유지 return ( <section> {/* ...검색 input은 11편 코드 유지 */} <Profiler id="BookTable" onRender={onRenderCallback}> <BookTable books={data.items} onSelectBook={handleSelect} /> </Profiler> {/* ...페이지네이션 nav는 11편 코드 유지 */} </section> ) }

handleSelectid를 받아 상세 페이지 경로로 navigate하는 함수를 useCallback으로 감싸 만들고, BookTable에 넘깁니다. 이 편에서 행 클릭 시 상세 페이지로 이동하는 기능을 처음 추가하면서, 동시에 리렌더 최적화도 함께 적용합니다.

개발 서버(npm run dev)를 켜고 검색창에 한 글자를 입력해 봅니다. 콘솔에 BookTable 리렌더 로그가 찍히는데, 이때 actualDuration이 행 개수에 비례해 커지면 테이블 전체가 다시 그려진 것입니다.

2. 테이블 행을 분리하고 memo 적용하기

기존 BookTable.jsx<tr>을 테이블 컴포넌트 안에서 직접 그리고 있었습니다. 행을 별도 컴포넌트로 뽑아 memo로 감쌉니다.

// src/components/BookRow.jsx import { memo } from 'react' function BookRowComponent({ book, onSelect }) { return ( <tr onClick={() => onSelect(book.id)}> <td>{book.title}</td> <td>{book.author}</td> <td>{book.pages}</td> <td>{book.status}</td> </tr> ) } const BookRow = memo(BookRowComponent) export default BookRow

BookTable.jsx는 11편처럼 books를 props로 받고, 정렬 상태만 useUiStore에서 selector로 구독합니다. 행 렌더링만 BookRow로 바꿉니다.

// src/components/BookTable.jsx (수정 후 — 헤더·정렬 버튼은 11편 코드 유지) import { useUiStore } from '../store/useUiStore'; import BookRow from './BookRow'; function BookTable({ books, onSelectBook }) { const sortBy = useUiStore((state) => state.sortBy); const sortOrder = useUiStore((state) => state.sortOrder); const setSort = useUiStore((state) => state.setSort); return ( <table className="book-table"> {/* ...11편의 thead(정렬 버튼) 그대로 유지 */} <tbody> {books.map((book) => ( <BookRow key={book.id} book={book} onSelect={onSelectBook} /> ))} </tbody> </table> ); } export default BookTable;

두 가지가 바뀌었습니다. 첫째, sortBy·sortOrder·setSort를 값마다 useUiStore(state => state.값)으로 따로 구독해, 11편처럼 BookTable이 정렬 상태에만 반응하게 유지합니다. 둘째, 행을 BookRow로 분리하고 BookListPage가 넘긴 onSelectBookuseCallback으로 참조가 고정돼 있어 memo가 실제로 효과를 냅니다.

3. 다시 측정하기

검색창에 다시 한 글자를 입력하고 콘솔을 확인합니다.

[Profiler] BookTable - update - actual: 1.80ms / base: 14.20ms

actualDurationbaseDuration보다 훨씬 작아진 것을 확인합니다. 이는 memo가 리렌더를 실제로 건너뛰었다는 뜻입니다. React DevTools 확장 프로그램의 Profiler 탭에서 녹화 버튼을 누르고 같은 조작을 반복하면, 리렌더된 행에만 색이 칠해지는 것을 시각적으로 확인할 수 있습니다.

4. Lighthouse로 로딩 성능 측정하기

프로덕션 빌드로 측정해야 정확합니다. 개발 서버는 최적화가 꺼져 있어 점수가 실제보다 낮게 나옵니다.

npm run build npm run preview

새 터미널에서 Lighthouse CLI를 실행합니다.

npx lighthouse http://localhost:4173 --view --only-categories=performance

--view 옵션은 결과 리포트를 브라우저로 자동으로 엽니다. Chrome 개발자 도구의 Lighthouse 패널에서 같은 방식으로 실행해도 됩니다. 개선 전(BookRow 분리 전)과 개선 후 점수를 각각 기록합니다.

측정 시점PerformanceLCPTBT
개선 전기록한 값기록한 값기록한 값
개선 후기록한 값기록한 값기록한 값

LCP(Largest Contentful Paint, 최대 콘텐츠풀 페인트)는 화면에서 가장 큰 요소가 그려지는 시점, TBT(Total Blocking Time, 총 차단 시간)는 메인 스레드가 사용자 입력에 응답하지 못한 시간의 합입니다. 리렌더가 줄면 TBT가 특히 개선됩니다.

직접 해보기

BookCard(목록 카드)에도 memo를 적용하고, Profiler 로그의 actualDuration이 더 줄어드는지 확인해 보세요.

export default memo(BookCard)로 감싸면 부모가 리렌더돼도 props가 얕게 같으면 다시 그리지 않습니다. BookRow가 이미 memo로 감싸져 있다면 체감 차이는 크지 않을 수 있지만, 원시값(문자열·숫자)만 props로 받는 컴포넌트일수록 memo 효과가 확실합니다.

자주 하는 실수

증상원인고치는 법
memo를 씌웠는데 여전히 매번 리렌더된다부모가 매 렌더마다 새 함수·객체를 props로 넘김useCallback/useMemo로 참조 고정
개발 서버에서 Lighthouse 점수가 낮게 나온다개발 모드는 압축·트리 쉐이킹이 꺼짐npm run buildnpm run preview로 측정
Zustand selector를 썼는데도 리렌더된다selector가 매번 새 객체를 반환(state => ({ a: state.a, b: state.b }))값 하나씩 따로 구독하거나 useShallow 사용

확인 문제

문제 14지선다
React Profiler의 onRender 콜백에서 actualDuration이 뜻하는 것은 무엇입니까
문제 24지선다
useUiStore() 처럼 스토어 전체를 구독하면 생기는 문제는 무엇입니까
문제 34지선다
React.memo로 감싼 컴포넌트가 리렌더를 건너뛰지 못하는 대표적인 원인은 무엇입니까
문제 44지선다
Lighthouse 성능 점수를 개발 서버(npm run dev)가 아니라 npm run preview로 측정해야 하는 이유는 무엇입니까

참고 자료

Last updated on