본문으로 건너뛰기
오세두
프로젝트 목록

Pulse

1초 안에 첫 차트가 그려지는 분석 대시보드

연도
2024년
역할
프론트엔드 담당
팀 규모
6명
기술 스택
  • Next.js
  • React
  • D3
  • PostgreSQL
  • Vercel

로그인 직후 대시보드가 뜨기까지 4.1초가 걸렸습니다. 그 4.1초 동안 화면에는 아무것도 없었습니다. 스켈레톤도, 헤더도, 사이드바도 없었습니다. 이벤트 테이블에는 2,800만 행이 쌓여 있었고, 고객 지원 채널에는 "대시보드가 안 열립니다"라는 문의가 주마다 들어왔습니다. 실제로는 열리고 있었지만, 4초는 사용자에게 고장과 구분되지 않는 시간이었습니다.

Pulse는 그 대시보드를 다시 만든 작업입니다. 최종적으로 LCP는 4.1초에서 1.3초로 줄었습니다. 그런데 그 3초 가까운 차이 중에서 번들 최적화가 가져온 몫은 0.5초뿐이었습니다.

처음 3주는 엉뚱한 곳을 파고 있었습니다#

제 첫 진단은 번들이었습니다. 초기 JS가 1.1MB였고, D3와 차트 래퍼가 그 절반을 차지하고 있었으니 근거가 없는 판단은 아니었습니다. 라우트 단위로 코드 스플리팅을 넣고, 차트 모듈을 동적 import로 떼어내고, 안 쓰는 date 유틸을 걷어냈습니다. 초기 JS는 1.1MB에서 420KB로 내려갔습니다.

LCP는 4.1초에서 3.6초가 되었습니다.

숫자를 보고 나서야 워터폴을 제대로 읽었습니다. TTFB가 2.6초였습니다. 브라우저는 JS를 파싱하느라 늦은 게 아니라, 서버가 첫 바이트를 보내주기를 기다리고 있었습니다. 페이지 컴포넌트 최상단에서 getDashboardSummary()를 await하고 있었고, 그 쿼리 하나가 6개 패널에 필요한 모든 지표를 원본 이벤트 테이블에서 한 번에 집계하고 있었습니다. 평균 2.4초, p95는 4초를 넘었습니다.

즉 첫 페인트 전체가 그 쿼리 하나에 인질로 잡혀 있었습니다. 번들을 0바이트로 만들어도 사용자는 2.6초를 흰 화면으로 기다렸을 것입니다. 아무리 최적화해도 바닥이 정해져 있는 문제였습니다.

셸을 먼저 보내고, 패널은 각자 도착하게#

방향을 바꿨습니다. 페이지 전체를 하나의 응답으로 완성해서 보내는 대신, 데이터가 필요 없는 셸(헤더, 사이드바, 패널 자리와 크기)을 즉시 스트리밍하고 각 패널을 독립적인 Suspense 경계로 감쌌습니다.

// 셸은 즉시, 패널은 각자 준비되는 대로 도착합니다.
export default function Page() {
  return (
    <DashboardShell>
      <Suspense fallback={<SummarySkeleton />}>
        <SummaryCards />          {/* 일별 롤업만 읽음 */}
      </Suspense>
      <Suspense fallback={<ChartSkeleton height={320} />}>
        <TrendChart range="30d" /> {/* 원본 이벤트 집계 */}
      </Suspense>
    </DashboardShell>
  );
}

동시에 쿼리를 쪼갰습니다. 패널마다 필요한 것만 따로 조회하게 하고, 요약 카드가 쓰는 지표는 PostgreSQL에 일별 롤업 테이블을 만들어 미리 집계해 두었습니다. 요약 카드는 2,800만 행이 아니라 4만 행짜리 롤업을 읽습니다.

도착 순서패널데이터 출처도착 시점
0없음0.4s
1요약 카드일별 롤업1.3s
2추이 차트롤업 + 당일 이벤트1.9s
3유입 분해원본 집계2.8s
4코호트 표원본 집계 (무거움)3.9s

LCP를 결정하는 요소는 요약 카드입니다. 그래서 1.3초입니다. 흥미로운 건 가장 무거운 코호트 표가 여전히 3.9초에 도착한다는 점입니다. 전체가 끝나는 시간은 거의 그대로인데, 체감은 완전히 달라졌습니다. 문의가 사라졌습니다.

결론은 번들이 아니라 순서였습니다. 무엇을 언제 보여줄지 정하는 결정이 바이트를 깎는 것보다 체감 성능을 훨씬 크게 움직였습니다.

대신 치른 비용#

이 구조는 공짜가 아니었습니다.

로딩 상태가 6배로 늘었습니다. 이전에는 페이지 하나에 스피너 하나였습니다. 이제는 패널마다 로딩, 빈 상태, 에러 상태가 따로 있고, 그 조합을 QA가 재현하기 어렵습니다. 스켈레톤 높이를 실제 렌더 높이와 다르게 잡아 CLS가 0.19까지 올라간 버그를 배포 후에 발견했습니다.

캐시 무효화가 어려워졌습니다. 하나의 쿼리는 하나의 캐시 키였지만, 지금은 패널별 태그를 관리해야 합니다.

// 이벤트가 들어오면 어떤 패널이 오래됐는지 직접 판단해야 합니다.
revalidateTag(`summary:${workspaceId}`);
revalidateTag(`trend:${workspaceId}:30d`);

롤업이 아직 안 돌았는데 원본 집계 패널만 새 데이터를 보여주면, 같은 화면 안에서 두 숫자가 어긋납니다. 실제로 그 문의를 받았습니다.

차트가 부분 데이터를 다뤄야 합니다. 추이 차트는 롤업이 커버하는 구간과 당일 실시간 구간을 함께 그립니다. D3 스케일에 들어가는 데이터의 마지막 구간이 미확정일 수 있다는 뜻이고, 그래서 축과 툴팁 로직에 "여기부터는 잠정치"라는 개념이 들어갔습니다. 차트 코드가 눈에 띄게 복잡해졌습니다.

다시 한다면#

두 가지입니다.

롤업 테이블과 원본 이벤트 테이블의 정합성을 검증하는 도구를 나중에 만들었습니다. 그 사이 3주 동안 "숫자가 다른데요" 문의가 올 때마다 제가 직접 SQL을 짜서 대조했고, 그중 하나는 타임존 경계에서 롤업이 하루를 빠뜨린 실제 버그였습니다. 사전 집계를 도입하는 순간 두 번째 진실이 생긴다는 것을 알고 있었는데도 대조 잡을 뒤로 미뤘고, 그 대가를 사람 시간으로 치렀습니다.

그리고 에러를 패널 단위로 흩어 놓으면서 Sentry 그룹핑이 깨졌습니다. 코호트 패널 하나가 특정 워크스페이스에서 계속 실패하고 있었는데, 나머지 화면이 정상으로 보였기 때문에 2주 동안 아무도 알아채지 못했습니다. 부분 실패를 허용하는 구조를 만들 때는, 부분 실패를 관측하는 방법을 같은 PR에서 함께 넣어야 했습니다.