CLS 0.76을 고치며 버린 가설 두 개 — 문제는 배치 수가 아니라 도색 시점이었다
지난번 Lighthouse 최적화에서 만들어 둔 측정 스크립트로 담당 라우트를 전수 측정했습니다. 그때는 세 페이지만 봤는데, 이번엔 여섯 개를 돌렸습니다.
결과에서 두 개가 튀었습니다.
| 페이지 | desktop 점수 | CLS |
|---|---|---|
| 템플릿 목록 | 59 | 0.761 |
| 스튜디오 홈 | 63 | 0.447 |
| 나머지 4개 | 85~97 | 0.000~0.011 |
CLS 임계값은 0.1입니다. 0.761은 7배 넘습니다.
시작하기 전에 하나 정리해야 했습니다. 기본 측정 목록에 /ko/login이 있었는데, 이 경로는 nginx 상 다른 앱 소관이라 우리가 고칠 수 없는 페이지였습니다. 측정 목록을 담당 라우트로 교체하는 게 첫 커밋이 됐습니다. 남의 페이지 숫자를 매주 쳐다보는 건 비용만 듭니다.
0. 리포트가 알려주지 않는 것
Lighthouse 리포트는 "CLS 0.761"과 위반 요소 목록까지는 줍니다. 하지만 그 요소가 어디서 어디로 움직였는지는 요약에 안 나옵니다. 원인 규명에는 그게 필요합니다.
브라우저에서 직접 캡처하는 게 빠릅니다. PerformanceObserver의 layout-shift 엔트리에는 sources[]가 있고, 각 source에 previousRect/currentRect가 들어 있습니다.
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.hadRecentInput) continue; // 사용자 입력 직후 시프트는 CLS 집계 대상이 아니다
for (const s of e.sources ?? []) {
log(desc(s.node), s.previousRect, '→', s.currentRect);
}
}
}).observe({ type: 'layout-shift', buffered: true });
이걸 헤드리스 브라우저에 addInitScript로 심고 페이지를 열었습니다. 나온 그림은 이랬습니다.
t=1089ms v=0.155 div [Masonry.tsx:155] [0,0,0,0] → [281,361,190,365]
t=1347ms v=0.155 div [Masonry.tsx:155] [0,0,0,0] → [491,207,190,134]
t=1355ms v=0.223 div [Masonry.tsx:135] [0,0,0,0] → [1085,515,262,226]
t=1688ms v=0.120 div [Masonry.tsx:155] [0,0,0,0] → [1121,515,190,134]
t=2038ms v=0.053 div [Masonry.tsx:155] [0,0,0,0] → [701,823,190,117]
previousRect가 전부 [0,0,0,0]입니다. 아이템들이 크기 없는 상태에서 제자리로 들어오고 있었습니다. 그리고 그게 1초에 걸쳐 다섯 번 나눠서 일어났습니다.
DOM을 250ms 간격으로 같이 찍어보니 이유가 분명해졌습니다.
~1.1s 그리드 컨테이너 없음
1.1s 6개 height 519px
1.4s 12개 height 689px
1.7s 18개 height 750px
2.0s 21개 (끝)
6개씩 네 번. 무한스크롤 페이지 크기가 6이었고, 데스크톱 첫 화면(5열 × 3행)을 채우는 데 순차 요청 네 번이 필요했던 것입니다.
1. 첫 번째 가설 — 그리고 기각
여기까지 오면 답이 보이는 것 같습니다. 배치를 하나로 합치면 시프트도 하나가 될 테니까요. 페이지 크기를 6에서 24로 올렸습니다. 서버에서 prefetch하는 첫 페이지 하나로 첫 화면이 덮이니, 아이템이 한 번에 렌더됩니다.
측정했습니다.
| shift 건수 | CLS | |
|---|---|---|
| size=6 | 4건 | 0.4826 |
| size=24 | 1건 | 0.4826 |
건수는 4분의 1이 됐는데 CLS는 소수점 넷째 자리까지 똑같았습니다.
당연한 결과였습니다. CLS는 시프트 건수의 평균이 아니라 영향 면적의 합입니다(정확히는 세션 윈도우 안의 합). 21개 아이템이 최종적으로 차지하는 영역은 네 번에 나눠 들어오든 한 번에 들어오든 같습니다. 나눠 담은 물을 한 컵에 부었을 뿐입니다.
시프트 건수를 줄이는 것과 CLS를 줄이는 것은 다른 일입니다. 배치를 합치는 최적화는 후자에 대해 거의 항상 무효입니다.
이 커밋을 되돌리진 않았습니다. CLS는 못 줄여도 순차 라운드트립 4회가 1회가 되는 건 그 자체로 이득이고(LCP·레이턴시), 스크롤 중 추가 로드 횟수도 줄기 때문입니다. 다만 커밋 메시지에 "CLS 0.705 제거"라고 써둔 건 과장이라, 후속 커밋 본문에서 정정했습니다. 공유 브랜치라 amend는 하지 않았습니다.
2. 곁가지: 로컬에서만 재현된 교착
가설을 검증하려고 로컬에서 같은 측정을 돌렸는데, 이상한 게 나왔습니다. 아이템이 6개에서 멈추고 더 이상 늘지 않았습니다. 10초를 기다려도, 스크롤을 세 번 해도 6개였습니다. 배포 환경에서는 21개까지 잘 갔는데 말입니다.
무한스크롤 콜백은 이렇게 생겼습니다.
const onRequestAppend = async (e) => {
if (isLast) { e.wait(); return; }
// 진행 중이면 보류. 그냥 return 하면 @egjs 가 미처리로 보고 반복 발화한다.
if (isAppendingRef.current || isFetchingNextPage || isLoading) { e.wait(); return; }
...
};
주석까지 달려 있는 걸 보면 과거에 한 번 고친 자리입니다. 그냥 return하면 이벤트가 폭주해서 e.wait()로 보류하도록 바꾼 것이죠. 계약은 지켰습니다 — 모든 분기에서 wait() 또는 ready() 중 하나를 부른다.
문제는 보류를 풀어주는 쪽이 없다는 것이었습니다. 라이브러리 소스를 열어 확인했습니다.
// @egjs/infinitegrid
__proto.wait = function (dir) { this._waitType = dir; };
// _waitType 을 지우는 곳은 여기 하나뿐이다
__proto.ready = function () { this._waitType = ''; };
__proto._onRequestInsert = function (direction, eventType, e) {
// 보류 중이면 이벤트를 발화하지 않고 그대로 빠져나간다
if (this._waitType) return;
this.trigger(...);
};
_waitType이 서 있으면 requestAppend 이벤트가 아예 발생하지 않습니다. 스크롤해도 소용이 없습니다. 그러니 첫 요청이 isLoading이 아직 true인 순간에 도착하면, 그 그리드는 영구히 멈춥니다.
배포 환경에서 안 걸린 이유는 단순합니다. 서버가 빨라서 첫 요청 시점에 이미 로딩이 끝나 있었던 것뿐입니다. 느린 로컬이 우연히 프로덕션 버그를 재현해 준 경우였습니다. 3G 사용자에게는 이게 기본 동작이었을 겁니다.
같은 함정이 하나 더 있었습니다. isLast로 보류한 뒤 사용자가 카테고리를 바꾸면 isLast가 다시 false가 되는데, _waitType은 그대로 남아 있어 새 카테고리의 무한스크롤이 죽은 채로 시작합니다.
수정은 보류 시점에 해제 함수를 예약해 두는 것입니다.
const pendingReadyRef = useRef<OnRequestAppend['ready'] | null>(null);
const isBlocked = isLast || isFetchingNextPage || isLoading;
useEffect(() => {
if (isBlocked) return;
const ready = pendingReadyRef.current;
if (!ready) return;
pendingReadyRef.current = null;
ready();
// ready() 는 _waitType 만 지운다. 재발화 트리거는 그리드 리렌더
// (react 래퍼 componentDidUpdate → _renderer.updated())라 한 번 더 렌더를 돌린다.
forceRender();
}, [isBlocked]);
ready() 다음에 강제 렌더를 한 번 더 도는 게 핵심입니다. 해제만 하면 _waitType은 지워지지만 이벤트를 다시 쏘게 만드는 트리거가 없습니다. 이것도 라이브러리 소스를 읽고서야 알았습니다.
이벤트 계약이 있는 라이브러리에서 "모든 분기에서 wait 또는 ready를 부른다"는 절반입니다. 나머지 절반은
wait()한 것을 누가 언제 푸는가입니다.
3. 진짜 원인 — 도색된 채로 움직였다
다시 CLS로 돌아옵니다. 배치를 합쳐도 0.48이 남았으니, 원인은 배치가 아니라 한 배치 안에서 일어나는 일입니다.
DOM 타임라인에 답이 있었습니다. 아이템의 인라인 스타일이 이랬습니다.
width: 189.6px; position: absolute; top: -9999px; left: -9999px;
메이슨리 라이브러리는 아이템을 일단 화면 밖에 렌더한 뒤 JS로 크기를 재고 제자리로 옮깁니다. 절대 위치 기반 레이아웃이라 당연한 구현입니다. 문제는 그 이동이 도색된 상태에서 일어난다는 것이었습니다. 브라우저 입장에선 21개 요소가 각각 화면 밖에서 화면 안으로 순간이동한 것이고, 그게 전부 레이아웃 시프트로 집계됐습니다.
여기까지 오니 해법은 한 줄로 정리됩니다. 배치가 끝날 때까지 안 그리면 됩니다.
export const measuring = style({
visibility: 'hidden',
});
visibility: hidden은 공간은 유지하되 paint에서 제외됩니다. 도색되지 않은 요소의 이동은 시프트로 집계되지 않고, 배치가 끝난 뒤 보이게 하는 것은 위치 변화가 없으니 역시 시프트가 아닙니다. opacity: 0이 아니라 visibility인 이유가 여기 있습니다.
노출 시점은 라이브러리의 renderComplete 이벤트로 잡되, 조건이 하나 붙습니다.
const handleRenderComplete = () => {
// renderComplete 는 아이템 0개일 때도 발화한다 — 마운트 직후 노출되면 게이트가 무효
if (items.length > 0) setHasLaidOut(true);
};
// 안전망: 어떤 이유로든 renderComplete 가 안 오면 시프트를 감수하고 노출한다
// (목록이 영구히 안 보이는 게 CLS 보다 훨씬 나쁘다)
useEffect(() => {
if (hasLaidOut || items.length === 0) return;
const timer = setTimeout(() => setHasLaidOut(true), 1500);
return () => clearTimeout(timer);
}, [hasLaidOut, items.length]);
안전망 타이머는 방어적 중복처럼 보이지만 성격이 다릅니다. CLS를 위해 콘텐츠를 숨기는 코드는 실패했을 때 콘텐츠가 영영 안 보이는 실패 모드를 가집니다. 그건 원래 문제보다 훨씬 나쁩니다.
측정: 0.4826 → 0.0072. 남은 0.0072는 로딩 스켈레톤이 23px 움직인 것이었습니다.
4. 두 번째 가설 — 그리고 또 기각
두 번째 페이지, 스튜디오 홈의 CLS 0.447은 원인이 훨씬 명확해 보였습니다. 전체의 99%가 한 요소였습니다.
div[data-modal-type="WhatsNew"] y466 h44 → y36 h904
첫 방문자에게 자동으로 열리는 공지 모달이 44px 크기로 열린 뒤 904px로 커지고 있었습니다. 44px는 사실상 빈 껍데기입니다.
그 모달은 코드 분할돼 있었습니다.
const WhatsNewModal = dynamic(() => import('./WhatsNewModal/index.tsx'), { ssr: false });
그림이 그려집니다. 셸(다이얼로그)은 즉시 열리고, 내용물 청크는 나중에 도착합니다. 그렇다면 청크를 먼저 받아두고 열면 되겠죠. 모달을 여는 함수 하나만 고치면 됩니다.
export const openWhatsNewModal = async (openModal) => {
await import('./index'); // 캐시 히트로 즉시 렌더될 것이다 — 라고 생각했다
openModal({ ... });
};
측정했습니다. y466 h44 → y36 h904, 그대로였습니다.
next/dynamic 구현을 열어보고서야 이해했습니다.
// next/dist/shared/lib/lazy-dynamic/loadable.js
const defaultOptions = { loader: ..., loading: null, ssr: true };
const Lazy = React.lazy(() => opts.loader().then(convertModule));
const fallbackElement = Loading ? <Loading .../> : null;
// loading 미지정 → <Suspense fallback={null}> 로 감싼다
loading 옵션을 주지 않으면 <Suspense fallback={null}>입니다. 그리고 React.lazy는 모듈이 이미 캐시에 있어도 첫 렌더에서 한 번은 서스펜드합니다. 그 한 커밋 동안 fallback인 null이 그려지고, 다이얼로그는 내용물 없는 44px로 도색됩니다. 웹팩 모듈 캐시를 데워두는 것과 React lazy의 상태를 진행시키는 것은 별개였습니다.
즉 이것도 성능 최적화 문서에서 익숙한 그 안티패턴 — fallback={null} — 이었습니다. 우리 저장소 규칙 문서에 "Suspense fallback은 null 대신 치수 맞춤 스켈레톤"이라고 제가 써둔 항목이 있는데, next/dynamic은 옵션을 생략하면 조용히 그 상태가 된다는 걸 몰랐습니다.
수정은 최종 치수를 점유하는 자리표시자입니다.
const WhatsNewModal = dynamic(() => import('./WhatsNewModal/index.tsx'), {
ssr: false,
loading: () => <WhatsNewSkeleton />,
});
여기서 진짜 문제는 "스켈레톤의 높이를 몇으로 둘 것인가"였습니다. 모달 높이가 콘텐츠에서 파생되고 있었기 때문입니다. 세 개의 뷰포트에서 실측해 규칙을 찾았습니다.
| 뷰포트 높이 | 실제 모달 높이 |
|---|---|
| 700 | 685 |
| 940 | 872 |
| 1200 | 899 |
min(760px, 78vh) + 139px. 본문 최대 높이에 헤더·탭·푸터 139px을 더한 값이고, 세 지점 모두 정확히 맞았습니다.
그런데 이 숫자를 스켈레톤에만 적어두면 위험합니다. 나중에 누가 헤더 패딩을 바꾸면 두 값이 어긋나고, CLS가 조용히 되살아납니다. 그래서 치수를 상수로 선언하고 모달 본체와 스켈레톤이 같은 값을 읽게 했습니다. 모달 높이도 "콘텐츠에서 파생"에서 "명시 선언"으로 바꿨습니다 — 실측과 일치하니 시각 변화는 없습니다.
// const.ts — VE(.css.ts)가 아니라 순수 상수 파일에 두는 이유:
// 스켈레톤을 쓰는 쪽(초기 번들)이 모달 스타일 청크를 끌어오지 않게 하려면 순수해야 한다.
export const WHATS_NEW_DESKTOP_WIDTH = '1272px';
export const WHATS_NEW_BODY_MAX_HEIGHT = 'min(760px, 78vh)';
export const WHATS_NEW_DESKTOP_HEIGHT = `calc(${WHATS_NEW_BODY_MAX_HEIGHT} + 139px)`;
측정: 0.4443 → 0.0128. 남은 것은 모달이 열릴 때 스크롤 락이 걸리며 배경 섹션이 움직이는 0.0026과, 모달 안 타이핑 연출의 캐럿 몇 픽셀이었습니다.
렌더도 확인했습니다 — 데스크톱 1272×872, 모바일 308×716(= 100dvh - 96px). 명시 높이로 바꿨는데 레이아웃이 그대로인지는 눈으로 봐야 합니다.
5. 남은 것
두 페이지 다 0.1 아래로 내려왔지만, 로컬 헤드리스 측정치입니다. 실제 Lighthouse 점수는 배포 후 재측정해야 확정됩니다. 그리고 홈에 남은 0.0026 — 모달이 열릴 때 스크롤 락이 배경 레이아웃을 건드리는 것 — 은 아직 안 건드렸습니다. 임계값 대비 여유가 크고, 스크롤 락 구현은 과거에 iOS 회귀를 낸 이력이 있는 코드라 별도 작업으로 미뤘습니다.
정리
숫자로는 템플릿 목록 0.4826 → 0.0072, 스튜디오 홈 0.4443 → 0.0128입니다. 하지만 이 작업에서 실제로 배운 건 두 번의 기각이었습니다.
- shift 건수와 CLS는 다른 값입니다. 배치를 합치는 건 CLS에 거의 무효입니다. 면적을 줄이거나, 애초에 안 그리거나 둘 중 하나여야 합니다.
- 절대 위치 레이아웃 라이브러리는 화면 밖에 그렸다가 옮깁니다. 그 이동이 도색된 채 일어나면 전부 CLS입니다.
visibility: hidden으로 배치를 paint 밖에서 끝내면 사라집니다. - 콘텐츠를 숨겨서 CLS를 고칠 땐 항상 안전망을 답니다. 안 보이는 목록은 흔들리는 목록보다 나쁩니다.
next/dynamic은loading을 생략하면fallback={null}입니다. 모달처럼 크기가 큰 것을 이렇게 열면 빈 껍데기가 한 프레임 그려집니다. 청크를 미리 받아두는 걸로는 안 고쳐집니다 —React.lazy는 캐시돼 있어도 한 번 서스펜드하니까요.- 자리표시자 치수는 본체와 같은 상수를 읽게 합니다. 숫자를 두 곳에 적으면 언젠가 어긋나고, 그 실패는 에러가 아니라 조용한 CLS 재발입니다.
- 느린 로컬 환경은 버그 재현 장치입니다. "배포에선 되는데 로컬에서만 멈춘다"를 환경 탓으로 넘기지 않았더니 프로덕션 교착 버그가 나왔습니다.
가설이 틀렸다는 걸 두 번 다 측정이 알려줬다는 점이 가장 중요한 것 같습니다. 두 가설 모두 그럴듯했고, 코드를 읽는 것만으로는 반증할 수 없었습니다. 첫 번째는 커밋 메시지까지 써놓은 뒤에 틀린 걸 알았습니다. 고치기 전에 재고, 고친 뒤에 다시 재는 것 말고는 방법이 없습니다.