리스트 컴포넌트를 전부 재봤더니, 고칠 건 하나였어요

HANUI·
성능최적화React측정리렌더컴포넌트

1편에서 이렇게 썼어요.

.map()으로 리스트를 그리는 컴포넌트가 29개예요. 이 중에 행이 많아질 때 실제로 느려지는 게 있는지 먼저 잴 거예요. 느린 게 없으면 아무것도 안 넣어요.

4편에서 DataTable 하나를 쟀고, 나머지는 미뤄뒀어요. 오늘 그걸 훑었습니다.

결과부터

각 컴포넌트를 두 조건으로 쟀어요. 현실적으로 이 정도까지 간다 싶은 개수와, 일부러 극단까지 민 개수예요. 부모만 리렌더시키고 자식이 커밋되는 비용을 봤어요.

컴포넌트현실적 개수비용극단 개수비용배율
Breadcrumb50.12ms2001.61ms13x
StepIndicator50.15ms2003.40ms21x
NavigationMenu80.44ms2004.20ms9x
SideNavigation100.07ms2000.95ms14x
Pagination100.17ms50000.17ms1.0x
DataTable (4편)501.9ms100021.0ms11x

세 번 돌려서 값이 흔들리지 않는 것만 적었어요.

결론은 싱겁습니다. 현실적인 개수에서는 전부 0.5ms 미만이에요. 손댈 이유가 없어요.

빵부스러기 5개, 단계 5개, 메뉴 8개 — 이게 실제로 쓰이는 규모예요. 여기에 memo를 붙이면 비교 비용만 늘고 얻는 게 없어요. 3편에서 말한 과최적화 그대로고요.

1편에서 "느린 게 없으면 아무것도 안 넣는다, 그것도 결과다"라고 했는데, 실제로 그 결과가 나왔어요. 29개 중 손댈 건 DataTable 하나예요.

Pagination만 안 늘어나는 이유

표에서 눈에 걸리는 건 Pagination이에요. 5000페이지인데 10페이지일 때와 비용이 같아요.

이유가 코드에 있어요.

totalPages가 몇이든 실제로 그리는 버튼 개수는 상수예요. 기본값이면 7개. 나머지는 으로 접혀요.

이게 중요한 이유는, 이건 최적화를 한 게 아니라 애초에 그 문제가 생길 수 없게 만든 것이기 때문이에요. memouseCallback도 없어요. 그냥 그릴 게 적어요.

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개. 그리고 성능 글을 쓰고 있었고요.

다섯 편 동안 한 건 결국 재는 것뿐이었어요.

  • useCallback 16개의 효과는 0이었어요 (3편)
  • DataTable은 1000행에서 21ms — 프레임 예산 초과 (4편)
  • 나머지 리스트 컴포넌트는 현실적 개수에서 전부 0.5ms 미만 (오늘)
  • Pagination은 구조로 이미 풀려 있었고요

그래서 할 일이 하나로 좁혀졌어요. DataTable 하나. 스물여덟 개는 안 건드립니다.

최적화보다 최적화 후보를 좁히는 게 먼저라는 게 이 시리즈에서 제일 크게 배운 거예요. 재기 전엔 29개가 전부 후보였는데, 재고 나니 1개예요. 나머지 28개에 memo를 붙였다면 코드는 복잡해지고 성능은 그대로였을 거예요.

이력서에 쓸 문장도 그래서 달라져요. "memo로 최적화" 대신 **"29개를 재서 1개로 좁혔고, 그 하나에서 21ms를 줄였다"**가 되는 거죠. 뒤쪽이 훨씬 짧고 강해요.

직접 해보기

실험 코드는 전부 저장소에 있어요.

본인 프로젝트라면 순서는 이거예요.

  1. 리스트를 그리는 컴포넌트 목록을 뽑는다 (grep -rl "\.map(" src/)
  2. 각각 현실적으로 몇 개까지 가는지 적는다 — 이게 제일 중요해요
  3. 그 개수로 재본다. 대부분 여기서 끝나요
  4. 남는 것만 극단까지 밀어보고, 실제로 늘어나면 그때 고친다

2번을 건너뛰고 바로 극단으로 재면 전부 문제로 보여요. "200개일 때 느리다"는 그 컴포넌트가 실제로 200개를 담을 때만 의미가 있어요.

관련 링크

시리즈: 프론트엔드 성능 측정 (완결)

HANUI

KRDS 기반 React 컴포넌트 라이브러리. 공공 웹 개발을 더 쉽게.