CLS 0.440의 94%는 스켈레톤 하나였다 — 측정 기준선부터 세운 레이아웃 시프트 개선
성능 측정값이 없던 예약 페이지에 Lighthouse 합성 측정 기준선을 세우고, layout-shift attribution으로 CLS 0.440의 원인을 스켈레톤과 실제 갤러리의 박스 불일치로 특정해 0.074로 낮춘 기록.
관련 프로젝트: 멀티테넌트 예약 SaaS
배경 — "느린 것 같다"만 있고 숫자가 없었다
예약 페이지는 실제 결제가 일어나는 B2C 화면인데, 성능 측정값이 하나도 없었다. 느리다·빠르다를 체감으로만 이야기했고, 무언가를 고쳐도 효과를 증명할 방법이 없었다. 그래서 개선보다 기준선을 먼저 세웠다.
기준선 — 무엇을, 어떤 조건으로 잴지부터 고정
| 항목 | 결정 |
|---|---|
| 대상 경로 | 숙박 상세 · 레저 상세 · 예약 생성 · 결제 복귀 (빌드 출력상 정적 페이지는 헬스체크 하나뿐, 나머지 전부 SSR) |
| 도구 | Lighthouse 합성 측정, desktop preset, headless Chrome |
| 반복 | 경로당 3회, median 사용 |
| 기록 | 회차별 값 · 측정 일시 · 버전 · 재측정 명령을 함께 남김 |
| 범위 밖 | INP — 아직 실사용자 트래픽이 없어 수집 경로가 없다. 합성 측정만으로 한정 |
전 경로가 SSR이라 LCP에 서버 응답시간이 그대로 실린다. 그래서 TTFB를 따로 적어 서버 구간과 렌더 구간을 분리해 읽었다. 결과는 TTFB는 200ms 안팎으로 문제없고, TBT도 대부분 0에 가까웠다. 눈에 띈 건 CLS였다. 레저 상세가 0.440으로 임계값 0.1을 네 배 넘게 초과하고 있었다.
원인 — 추측 대신 attribution
CLS가 높다는 사실만으로는 무엇을 고칠지 알 수 없다. 후보는 두 개 있었다. 히어로 갤러리의 스켈레톤과 옵션 리스트의 로딩 상태. 둘 다 그럴듯했기 때문에 코드를 보고 고르지 않고, 기준선 리포트의 layout-shifts 감사 항목을 열었다.
| score | 움직인 요소 |
|---|---|
| 0.4119 | 히어로 카드 바로 아래 콘텐츠 컬럼 |
| 0.0276 | 공통 푸터 |
합계 0.4395 중 94%가 히어로 아래 컬럼 하나였다. 이 컬럼은 위에 있는 히어로 블록의 높이가 바뀔 때만 움직인다. 옵션 리스트는 별도 시프트 항목으로 잡히지 않았다. 범위가 한 곳으로 좁혀졌다.
히어로 블록을 열어 보니 스켈레톤과 실제 갤러리가 서로 다른 모양이었다.
| 스켈레톤 | 실제 갤러리 | 차이 | |
|---|---|---|---|
| 데스크톱 | 정사각 5칸 그리드 → 약 211px | 고정 높이 360px | +149px |
| 모바일(375px) | 정사각 2칸×3행 → 약 518px | 17:12 비율 → 약 242px | −276px |
게다가 갤러리는 뷰포트 진입 훅으로 지연 렌더되는데, 그 훅이 첫 페인트에서 항상 false라 스켈레톤 → 갤러리 교체가 매 로드마다 일어났다. 데스크톱에서는 내려가고 모바일에서는 올라가는, 방향만 다른 같은 시프트였다.
수정 — 스켈레톤을 "자리"로 만든다
스켈레톤의 역할은 로딩 중임을 보여주는 것만이 아니라 최종 콘텐츠가 차지할 자리를 미리 잡는 것이다. 5칸 그리드를 걷어내고, 실제 갤러리와 같은 박스 하나로 바꿨다.
// before: 정사각 셀 5개 — 실제 갤러리와 높이가 다르다
<div className="grid grid-cols-2 gap-1 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="aspect-square w-full rounded-lg" />
))}
</div>
// after: 갤러리와 동일한 박스 (모바일 17:12, 데스크톱 360px)
<Skeleton className="aspect-[17/12] w-full lg:h-banner-360" />로컬에서 1350px일 때 스켈레톤 높이 360px, 375px일 때 비율 1.417(=17/12)로 갤러리와 일치하는 것을 확인한 뒤 배포했다.
결과 — 같은 조건으로 다시 잰다
기준선과 같은 URL · 같은 Lighthouse 버전 · 같은 preset · 3회 median으로 재측정했다.
| before | after | |
|---|---|---|
| CLS | 0.440 | 0.074 |
| 히어로 아래 컬럼 시프트 | 0.4119 | 0.0433 |
임계값 0.1 안으로 들어왔고, attribution으로 원인이 그 박스였다는 것도 다시 확인됐다.
일부러 성과로 쓰지 않은 숫자도 있다. LCP·TTFB도 이번 측정에서 좋아졌지만, 개선 대상이 아니었고 첫 회차 콜드 요청 편차가 커서 이 작업의 효과라고 말할 근거가 없다. 남은 0.043은 옵션 리스트 플레이스홀더 교체분으로 추정하지만, attribution이 컬럼 단위라 확정하지 못했다. 임계값 안이라 범위 밖으로 기록만 해 뒀다.
배운 점
- 개선보다 기준선이 먼저다. 측정 조건을 고정해 두지 않았다면 before/after를 비교하는 것 자체가 불가능했다.
- 원인 후보가 여럿이면 코드가 아니라 측정 결과로 고른다. 두 후보 중 하나는 시프트 항목에 아예 없었다.
- 스켈레톤은 모양이 아니라 치수가 계약이다. 로딩 상태와 최종 상태가 같은 박스를 공유해야 레이아웃이 움직이지 않는다.
- 합성 측정의 한계를 같이 적는다. 실사용자 지표(INP 등)는 트래픽이 생긴 뒤의 과제로 남겼다.