Deallo

렌더링 · 성능

대표 글

응답은 0.5초에 끝났는데 클릭은 15초를 기다렸다 - 바이어 목록 5,000행 가상화

바이어 목록은 유저 화면과 어드민 양쪽에 있다. 어드민 쪽 API가 유독 느려서 백엔드에 개선을 요청하려고 사이즈별 응답 속도를 재던 중, 비교하러 열어본 유저 페이지에서 이상한 장면을 봤다. API 응답은 0.5초에 끝났는데 스피너는 한참을 더 돌았고, 그동안 클릭이 안 먹혔다. 네트워크 문제가 아니라는 뜻이다. 응답이 끝난 뒤의 시간이 어디에 쓰이는지 측정으로 확인하고, 가상화로 줄이고, 다시 측정으로 검증한 기록이다.

비포 실황 - 네트워크 응답은 이미 끝났는데 스피너는 계속 돌고, 클릭은 큐에 쌓였다가 한참 뒤에 몰아서 반영된다. 리사이즈나 빠른 스크롤에는 빈 화면이 길게 노출된다.

사건의 시작 - 어드민 속도를 재다가 유저 페이지 문제를 발견했다

어드민 API 개선 요청의 근거를 만들려고, 어드민 워크스페이스(3,960 buyers)의 buyers 탭을 사이즈를 키워가며 쟀다. DevTools Network 실측:

size응답 시간크기
10472ms3.3kB
100542ms22.6kB
5002.67s109kB
1,0003.11s214kB
2,0005.39s427kB
5,0009.84s849kB
어드민 buyers 탭 사이즈별 Network 실측 - size=500 2.67s, 1000 3.11s, 2000 5.39s, 5000 9.84s

표의 출처 - 어드민 buyers 탭 Network 실측

유저 화면의 바이어 검색은 같은 size=5000에서도 2초 이내로 응답하고 있었다. 어드민의 10초는 백엔드 쿼리 몫이 크다 - 슬랙으로 백엔드에 개선을 요청했다. 여기까지면 백엔드 이슈로 끝났을 사건이다.

그런데 비교하러 열어둔 유저 페이지(size=5000)에서 앞뒤가 안 맞는 장면이 보였다. 응답은 448ms, 1.4MB로 이미 끝나 있는데 화면에는 계속 스피너가 돌았고, 클릭과 페이지 이동이 몇 초씩 밀렸다. 응답이 도착한 뒤에 화면이 따로 쓰는 시간이 있다는 뜻이다. 그래서 백엔드 요청 슬랙에 한 줄을 덧붙였다 - "5000건부터는 프론트 렌더링 이슈도 함께 걸리는 부분이어서 개선할 수 있는 부분 알아보겠습니다." 이 글은 그 한 줄의 후속 작업이다.

측정 수치를 근거로 백엔드에 응답 속도 개선을 요청한 슬랙 메시지

측정 수치를 근거로 백엔드에 개선을 요청한 슬랙. 마지막 줄 - "프론트 렌더링 이슈도 함께 걸리는 부분이어서 개선할 수 있는 부분 알아보겠습니다" - 가 이 글의 시작이다.

병목은 한 겹이 아니었다

같은 "buyers 느리다" 아래에 레이어가 세 개 있었고, 셋은 담당도 처방도 다르다:

레이어증상처방
BE 쿼리 (어드민 API)size=5000에서 9.84s백엔드 트랙, 별도 진행
FE 불필요 호출 (필터 옵션 3종 즉시 호출)목록과 무관한 API가 LCP를 밀어냄분리 개선(머지됨)
FE 렌더 (5,000행 일괄 렌더)응답이 끝나도 스피너·먹통이번 건

판별은 단순하다. 응답이 끝났는데 화면이 멈춰 있으면, 다음으로 볼 곳은 네트워크가 아니라 메인 스레드다.

첫 오해 - "isLoading이 아직 안 끝난 거 아냐?"

아니었다. isLoading(JS 값)은 응답이 도착한 448ms 시점에 이미 false가 돼 있었다. 늦은 건 값이 아니라, 그 값이 화면에 반영되는 쪽이었다.

React Query는 응답이 오면 isLoading=false와 data 5,000건을 한 번의 렌더로 묶어서 반영한다. 즉 "스피너 없음 + 5,000행"이 하나의 렌더 결과물이고, 그것이 완성될 때까지 브라우저는 마지막으로 그려둔 프레임 - 스피너 - 를 그대로 보여준다. 스피너만 먼저 끄는 중간 프레임은 없다.

[네트워크]    |-- 448ms --| ✓ (Network 탭은 여기서 "끝")
[메인 스레드]             |-- 5,000행 렌더 → DOM 20만 커밋 → 레이아웃·페인트 --|
[화면]        스피너 ────── (마지막 프레임 그대로) ──────────────→ 테이블 등장
[클릭]                    ✗ 같은 큐에서 대기

스피너가 계속 돌아 보였던 건 transform 애니메이션을 컴포지터 스레드(별도 스레드)가 돌리기 때문이다 - 스피너는 도는데 페이지는 먹통인 상태가 그래서 나온다. 값이 바뀌는 것과 화면이 바뀌는 것은 별개의 층이다.

측정 전에, 콘솔에 이미 찍혀 있던 숫자들

측정 장치를 만들기 전에 크롬이 자동으로 출력하는 경고([Violation])부터 확인했다. 유저 페이지(size=5000)에서 관측된 것:

  • [Violation] 'setTimeout' handler took 8819ms - 렌더 태스크 하나가 8.8초
  • 클릭→처리 지연: 8526 → 14137 → 13162 → 11892 → … → 3467ms - 화면이 멈춘 동안 누른 클릭들이 큐에 쌓였다가 순서대로 처리되는 패턴
  • 'click' handler took 6562ms - 전체선택 핸들러 자체가 6.5초
  • Performance 패널 Local metrics: INP 15,896ms (poor)
size=5000 요청 전에는 클릭 지연 4~11ms, 요청 후에는 8,526~14,137ms로 치솟은 콘솔 로그

같은 리스너, 같은 페이지 - size=5000 요청 전에는 클릭 지연이 한 자릿수 ms, 요청 후에는 8,526→14,137→13,162ms. 화면이 멈춘 동안 쌓인 클릭이 큐에서 순서대로 처리되는 패턴이다.

Performance 패널 Local metrics - INP 15,896ms (poor), LCP 3.99s

Performance 패널 Local metrics - INP 15,896ms(poor). 클릭 하나가 처리까지 15초를 기다렸다.

클릭 하나가 처리까지 15초를 기다렸다. 현상 확인은 여기까지로 충분했고, 개선 폭을 재려면 조건을 고정한 측정이 필요했다.

측정 조건 고정 - 하네스 구성

손으로 DevTools를 재면 회차마다 데이터와 타이밍이 달라져 before/after 비교가 성립하지 않는다. 그래서 조건을 고정하는 측정 하네스를 먼저 만들었다:

  • MSW 결정론적 5,000행 시드 - 백엔드 의존 없음, 매 회차 같은 데이터. 네트워크 비용을 0으로 만들어 렌더 비용만 격리(어드민 10초의 BE 몫과 섞이지 않게)
  • Playwright 시나리오 스펙 - PerformanceObserver(longtask/event timing/LCP) 주입 후, 초기 로드 → 행 선택 → 전체 선택 → 정렬 → 휠 스크롤+최하단 점프를 자동 수행
  • 수집 리포터 - 결과를 JSON으로 기록

워밍업 1회 제외 · 3회 반복 median · 뷰포트 1440×900 고정 · trace/video off.

PERF_LABEL=before|after pnpm test:e2e e2e/authed/buyer/table-virtualization-perf.spec.ts \
  --reporter="list,./e2e/perf-reporter.ts"   # → e2e/.perf/results-<label>.json

⚠️ dev 모드 절대값은 프로덕션보다 크게 나온다. 목적은 상대 비교고, 절대값 검증은 배포 후 Datadog RUM에서 한다.

실측 before - 어디서 막히고 있었나

  • Long Task 최대 3,389ms - 5,000행을 한 커밋에 그리는 동안 메인 스레드가 점유된 시간. 스피너가 멈춘 채 떠 있던 구간이 이것이다.
  • DOM 노드 205,196개 - 산수가 맞아떨어졌다. 셀 하나가 노드 하나가 아니라 작은 트리(체크박스·아바타·배지·툴팁)라서 행당 ~41노드 × 5,000행 ≈ 205,000.
  • 전체 선택 1,181ms - rowSelection에 5,000개 키가 생기며 모든 행이 재렌더된다. 브로드캐스트 대상을 전체선택하는 순간 1.2초 멈춤.
  • ResizeObserver ~10,000개 - 행마다 태그·업종 셀의 오버플로우 측정용이 2개씩 등록된다.
  • 그리고 예상과 다른 결과 하나. 스크롤 자체는 별로 안 막혔다(스크롤 중 TBT 100ms - 컴포지터가 처리). "스크롤이 버벅인다"는 체감의 실체는 스크롤이 아니라, 스크롤 중에 만나는 재렌더와 hover 스타일 재계산이었다. 측정 전 짐작과 측정 후 사실이 달랐던 지점.

해결 - 렌더 상한을 데이터 크기에서 화면 크기로

파이프라인을 빠르게 만드는 게 아니라, 한 번의 렌더+커밋에 들어가는 행 수를 "화면에 보일 수 있는 수"로 제한하는 것 - 가상화다. 데이터 5,000건은 메모리에 그대로 있고, DOM으로 만드는 것만 줄인다.

// before - 5,000행 전부를 렌더
{table.getRowModel().rows.map(row => <tr>…7셀…</tr>)}
 
// after - 화면에 걸친 ~35행만 (@tanstack/react-virtual)
const rowVirtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => scrollContainerRef.current,
  estimateSize: () => 40,   // 행 높이 h-10(40px) 고정과 일치 필수
  overscan: 10,
})
{/* 위 spacer <tr> */}
{rowVirtualizer.getVirtualItems().map(v => {
  const row = rows[v.index]
  return <tr key={row.id}>…기존 7셀 그대로…</tr>
})}
{/* 아래 spacer <tr> */}

동작은 계산이다: scrollTop ÷ 40 = 시작 인덱스, 뷰포트 900px ÷ 40 ≈ 22행 + overscan 위아래 10행 = ~35행만 렌더. 스크롤하면 범위를 다시 계산하고, React의 key diff가 범위에 들어온 행만 마운트, 나간 행만 언마운트한다. 스크롤바 길이는 위아래 spacer(잘린 높이 합 ~20만px)가 유지한다. 행 JSX는 그대로라 diff가 작다(+52줄).

재현이 곧 설득 - before/after

먼저 실제 5,000행 화면에서 그대로 찍은 before/after다. 클릭 한 번의 INP(Interaction to Next Paint)가 가상화 전 56,912ms(poor) 에서 후 48ms(good) 로 떨어졌다 - 콘솔의 click·setTimeout 핸들러도 34초대에서 사라졌다.

가상화 전 - 5,000행 로드 시 INP 56,912ms (poor), click·setTimeout 핸들러가 34초대가상화 후 - 같은 5,000행 화면에서 INP 48ms (good)
실제 5,000행 화면(size=5000) 직접 측정 - (좌) 가상화 전 INP 56,912ms, (우) 후 48ms.

아래는 같은 개선을 조건 고정 하네스(median of 3)로 지표별로 나눠 본 것이다.

첫 행 표시까지−85%
before
7,218ms
after
1,085ms
로드 TBT (Total Blocking Time)−92%
before
5,496ms
after
452ms
Long Task 최대 (로드 중)−90%
before
3,389ms
after
332ms
전체 선택 클릭 → 반응 (INP)−93%
before
1,181ms
after
82ms
정렬 클릭 → 반응 (INP)−90%
before
1,266ms
after
132ms
가상화 전가상화 후막대 길이는 지표별 before=100% 기준 상대값 · dev 모드 하네스 3회 median
전체 측정표 펼치기 (12항목 — DOM 노드·JS heap·스크롤 지표 포함)
지표가상화 전가상화 후개선
첫 행 표시까지7,218ms1,085ms-85%
LCP7,318ms1,231ms-83%
로드 Long Task 최대3,389ms332ms-90%
로드 TBT5,496ms452ms-92%
DOM 노드205,1968,683-96%
렌더된 행(tbody tr)5,00035-99.3%
JS heap250MB161MB-36%
단일 행 선택 INP173ms60ms-65%
전체 선택 INP1,181ms82ms-93%
정렬 클릭 INP1,266ms132ms-90%
스크롤 TBT100ms24ms-76%
스크롤 후 렌더 행5,00036가상화 동작 확인
  • INP 기준(good ≤200ms): 전체선택·정렬이 poor(1.2s) → good(82/132ms).
  • renderedRows 35는 결과이면서 검증이다 - 5000이 찍히면 서버가 옛 코드를 서빙 중이라는 뜻(stale 가드).
  • 네트워크를 mock으로 0초로 만들어도 before는 첫 행까지 7.2초 걸렸다 - 그 시간이 전부 렌더였다는 분리 증명.

스크롤 빈 화면의 정체 - DOM에 있어도 픽셀에는 없다

가상화 전에도 스크롤을 확 내리면 화면이 잠깐 비어 보였다. 5,000행이 전부 DOM에 있는데 왜 그랬을까. 브라우저는 20만px 페이지 전체를 픽셀로 들고 있지 않는다. 보이는 영역 근처만 타일 단위로 래스터해 두고, 스크롤은 컴포지터가 그 타일을 밀어서 보여준다. 확 내리면 래스터해 둔 범위를 벗어나 빈 화면이 노출되는데(체커보드), before는 타일에 들어가는 내용이 무거워 래스터가 느렸고 메인 스레드도 바빠서 공백이 길었다. 가상화 후에도 아주 빠른 스크롤에서는 빈 행이 잠깐 보일 수 있지만 원인이 다르다 - React가 아직 안 그린 것이고, 35행이라 한두 프레임 안에 채워진다. 값과 화면이 별개였던 것처럼, DOM과 픽셀도 별개의 층이다.

왜 이제서야 드러났나

기본 페이지 사이즈 20에서는 이 비용이 보이지 않는다. 데이터가 쌓이고(한 워크스페이스에 바이어 3,960명), 대량 조회가 실제 시나리오가 되면서(어드민 이관 검수, 브로드캐스트 대상 전체선택) 원래부터 있던 렌더 비용이 보이게 된 것뿐이다. 갑자기 생긴 문제가 아니라 데이터가 자라서 드러난 문제다.

부산물

  • buyers 목록에 e2e 커버리지가 없었는데, 이번에 만든 MSW 5,000행 목이 그 기반이 됐다.
  • 측정 하네스(스펙+리포터)가 리포에 남아 다음 성능 작업에서 재사용할 수 있다.
  • 후속 후보도 명확해졌다: 어드민 buyers 테이블은 별도 컴포넌트라 같은 처방이 필요하고, 배포 후에는 Datadog RUM의 Long Task·INP를 버전 태그로 갈라 프로덕션에서 재검증한다.

남긴 것

  • 응답 속도와 체감 속도는 다른 문제다. Network 탭이 끝났는데 화면이 멈춰 있으면 다음 볼 곳은 Performance 탭이다.
  • 상태 변경은 화면 변경의 요청일 뿐이다. isLoading은 0.5초에 꺼졌고, 화면은 렌더 파이프라인이 한 바퀴 돌아야 바뀐다. 그 한 바퀴가 5,000행만큼 길었다.
  • "버벅인다"로는 못 고친다. "Long Task 3.4초, 클릭 대기 15초"로 번역해야 고칠 대상과 성공 기준이 생긴다.
  • 스크롤 버벅임의 원인이 스크롤이 아닐 수 있다. 측정 전 짐작과 측정 후 사실이 달랐다.
  • 같은 페이지의 느림도 레이어가 다르면 다른 문제다. BE 쿼리·불필요 호출·렌더를 섞으면 서로의 개선이 안 보인다. mock으로 네트워크를 0으로 만든 이유다.
  • 조건을 고정하지 않은 before/after는 우연과 구분되지 않는다. 같은 데이터, 같은 시나리오, median - 하네스가 비교를 성립시킨다.
  • 렌더 비용의 상한이 무엇에 묶여 있는지 봐야 한다. 데이터 크기에 묶인 코드는 데이터가 자라면 같이 느려진다. 가상화는 그 상한을 화면 크기로 바꾸는 작업이다.