리스트 컴포넌트를 전부 재봤더니, 고칠 건 하나였어요
프론트엔드 성능 측정 시리즈
(5편)1편에서 이렇게 썼어요.
.map()으로 리스트를 그리는 컴포넌트가 29개예요. 이 중에 행이 많아질 때 실제로 느려지는 게 있는지 먼저 잴 거예요. 느린 게 없으면 아무것도 안 넣어요.
4편에서 DataTable 하나를 쟀고, 나머지는 미뤄뒀어요. 오늘 그걸 훑었습니다.
결과부터
각 컴포넌트를 두 조건으로 쟀어요. 현실적으로 이 정도까지 간다 싶은 개수와, 일부러 극단까지 민 개수예요. 부모만 리렌더시키고 자식이 커밋되는 비용을 봤어요.
| 컴포넌트 | 현실적 개수 | 비용 | 극단 개수 | 비용 | 배율 |
|---|---|---|---|---|---|
| Breadcrumb | 5 | 0.12ms | 200 | 1.61ms | 13x |
| StepIndicator | 5 | 0.15ms | 200 | 3.40ms | 21x |
| NavigationMenu | 8 | 0.44ms | 200 | 4.20ms | 9x |
| SideNavigation | 10 | 0.07ms | 200 | 0.95ms | 14x |
| Pagination | 10 | 0.17ms | 5000 | 0.17ms | 1.0x |
| DataTable (4편) | 50 | 1.9ms | 1000 | 21.0ms | 11x |
세 번 돌려서 값이 흔들리지 않는 것만 적었어요.
결론은 싱겁습니다. 현실적인 개수에서는 전부 0.5ms 미만이에요. 손댈 이유가 없어요.
빵부스러기 5개, 단계 5개, 메뉴 8개 — 이게 실제로 쓰이는 규모예요. 여기에 memo를 붙이면 비교 비용만 늘고 얻는 게 없어요. 3편에서 말한 과최적화 그대로고요.
1편에서 "느린 게 없으면 아무것도 안 넣는다, 그것도 결과다"라고 했는데, 실제로 그 결과가 나왔어요. 29개 중 손댈 건 DataTable 하나예요.
Pagination만 안 늘어나는 이유
표에서 눈에 걸리는 건 Pagination이에요. 5000페이지인데 10페이지일 때와 비용이 같아요.
이유가 코드에 있어요.
totalPages가 몇이든 실제로 그리는 버튼 개수는 상수예요. 기본값이면 7개. 나머지는 …으로 접혀요.
이게 중요한 이유는, 이건 최적화를 한 게 아니라 애초에 그 문제가 생길 수 없게 만든 것이기 때문이에요. memo도 useCallback도 없어요. 그냥 그릴 게 적어요.
memo는 "그리긴 그리는데 건너뛰자"예요. 개수 상수화는 "애초에 안 그린다"고요. 둘 중엔 뒤가 항상 세요. 비교 비용도 없고, props 안정성 같은 조건도 안 붙거든요.
가상화(virtualization)가 유명한 것도 같은 원리예요. 1만 행이 있어도 화면에 보이는 20행만 그리는 거요. 개수를 상수로 만들 수 있으면 그게 1순위고, memo는 그게 안 될 때 쓰는 차선책이에요.
DataTable이 문제인 것도 뒤집어 보면 이거예요. 1000행을 전부 DOM에 그리고 있으니까요. memo를 붙이는 것보다 행 가상화를 넣는 게 근본이에요. 다만 그건 훨씬 큰 작업이라, 단계를 나눠야겠죠.
못 고치는 것도 있어요
표에 안 넣은 숫자가 하나 있어요. 커밋 횟수요.
부모를 3번 클릭했을 때 자식이 몇 번 커밋되는지 셌는데, 대부분 3회였어요. 그런데 NavigationMenu만 8회였어요. 세 번 돌려도 똑같이 8회고요.
원인은 짐작이 가요.
Radix 내부에 자체 상태와 이펙트가 있어서, 제 컴포넌트가 한 번 그려질 때 안에서 추가 커밋이 일어나요. 제 코드를 아무리 고쳐도 이 부분은 안 줄어요.
라이브러리를 쓰면 이런 게 생겨요. 접근성 처리와 키보드 내비게이션을 직접 짜지 않는 대가로, 렌더 횟수에 대한 통제권을 일부 넘기는 거죠. 저는 그 거래가 맞다고 보지만, "최적화가 내 손 밖인 구간이 있다"는 걸 아는 것과 모르는 건 달라요. 모르면 안 줄어드는 숫자를 붙들고 하루를 씁니다.
측정이 거짓말한 순간
이 글에서 제일 쓸모 있는 건 여기일 수도 있겠어요.
처음 돌렸을 때 결과가 이랬어요.
전부 0이에요. 잠깐 "다 괜찮네" 하고 넘어갈 뻔했어요.
버그는 제 측정 코드에 있었어요. 자식을 이렇게 넘겼거든요.
React는 엘리먼트 참조가 이전과 같으면 그 서브트리를 통째로 건너뛰어요. props 비교도 안 해요. 그래서 커밋이 아예 안 일어난 거예요.
실제 코드에서는 이런 일이 잘 안 생겨요. JSX가 부모 렌더 함수 안에 있으니 매번 새로 만들어지거든요. 제 측정 하네스만 실제 코드와 다른 모양이었던 거예요.
고친 건 간단했어요. 엘리먼트 대신 함수를 넘겨서 매번 새로 만들게 했어요.
그랬더니 위 표의 숫자가 나왔어요.
교훈은 이거예요. 측정 코드가 실제 코드와 다른 모양이면, 측정은 조용히 거짓말해요. 에러도 안 나고, 그럴듯한 숫자가 나와요. 0.00ms가 다섯 줄 연속으로 나왔을 때 "좋네"가 아니라 "이상한데"가 나와야 했어요.
그래서 이번엔 커밋 횟수를 같이 찍게 해뒀어요. 시간은 0에 가까울 수 있어도, 커밋이 0회면 그건 뭔가 잘못된 것이거든요. 검산할 두 번째 숫자를 두는 거죠.
정리하면
이 시리즈를 시작할 때 제 상태는 이랬어요. React.memo 0개, useCallback 16개, 측정 도구 0개. 그리고 성능 글을 쓰고 있었고요.
다섯 편 동안 한 건 결국 재는 것뿐이었어요.
useCallback16개의 효과는 0이었어요 (3편)DataTable은 1000행에서 21ms — 프레임 예산 초과 (4편)- 나머지 리스트 컴포넌트는 현실적 개수에서 전부 0.5ms 미만 (오늘)
- Pagination은 구조로 이미 풀려 있었고요
그래서 할 일이 하나로 좁혀졌어요. DataTable 하나. 스물여덟 개는 안 건드립니다.
최적화보다 최적화 후보를 좁히는 게 먼저라는 게 이 시리즈에서 제일 크게 배운 거예요. 재기 전엔 29개가 전부 후보였는데, 재고 나니 1개예요. 나머지 28개에 memo를 붙였다면 코드는 복잡해지고 성능은 그대로였을 거예요.
이력서에 쓸 문장도 그래서 달라져요. "memo로 최적화" 대신 **"29개를 재서 1개로 좁혔고, 그 하나에서 21ms를 줄였다"**가 되는 거죠. 뒤쪽이 훨씬 짧고 강해요.
직접 해보기
실험 코드는 전부 저장소에 있어요.
본인 프로젝트라면 순서는 이거예요.
- 리스트를 그리는 컴포넌트 목록을 뽑는다 (
grep -rl "\.map(" src/) - 각각 현실적으로 몇 개까지 가는지 적는다 — 이게 제일 중요해요
- 그 개수로 재본다. 대부분 여기서 끝나요
- 남는 것만 극단까지 밀어보고, 실제로 늘어나면 그때 고친다
2번을 건너뛰고 바로 극단으로 재면 전부 문제로 보여요. "200개일 때 느리다"는 그 컴포넌트가 실제로 200개를 담을 때만 의미가 있어요.
관련 링크
- Profiler — React 공식 문서
- HANUI — KRDS 기반 디자인 시스템
- claude-settings —
/perf,/optimize커맨드 저장소
시리즈: 프론트엔드 성능 측정 (완결)
- 1편: 채용공고 1순위가 성능 최적화인데, 내 라이브러리엔 React.memo가 0개였어요
- 2편: 성능 측정 도구 지도 — 어떤 질문에 어떤 도구를 쓰나
- 3편: React.memo가 안 듣는 이유 — 200번 리렌더를 0으로 만든 조건
- 4편: React Profiler를 스크린샷 말고 코드로 — 실제 컴포넌트 재보기
- 5편: 리스트 컴포넌트를 전부 재봤더니, 고칠 건 하나였어요 ← 현재 글
HANUI
KRDS 기반 React 컴포넌트 라이브러리. 공공 웹 개발을 더 쉽게.