- Published on
CLS 폭증? Chrome 레이아웃 시프트 원인 추적
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 응답도 빠르고 JS도 가벼운데, 어느 날부터 Chrome CrUX나 Lighthouse에서 CLS가 갑자기 튀는 경우가 있습니다. 체감은 “뭔가 화면이 흔들린다” 정도로 모호한데, 지표는 빨간색으로 경고를 띄우죠. CLS는 사용자가 콘텐츠를 읽거나 클릭하려는 순간에 레이아웃이 밀리면 누적되기 때문에, 원인을 정확히 잡지 못하면 릴리즈마다 재발합니다.
이 글은 “CLS 폭증”을 실제로 추적할 때의 순서를 증상 재현 → 시프트 이벤트 수집 → 원인 요소 특정 → 코드/스타일 수정 → 회귀 방지 흐름으로 정리합니다. INP나 Long Task처럼 성능 이슈를 같이 보는 경우도 많으니, 필요하면 Chrome INP 폭증 원인 추적 - Long Task·TBT 해결도 함께 참고하면 좋습니다.
CLS를 ‘수치’가 아니라 ‘이벤트’로 보기
CLS는 단일 원인이 아니라 “레이아웃이 바뀐 순간들의 합”입니다. 따라서 핵심은 다음 질문에 답하는 것입니다.
- 언제 시프트가 발생했는가
- 무엇이 움직였는가
- 왜 그 시점에 레이아웃이 재계산되었는가
Lighthouse 점수만 보면 “이미지에 width/height 넣어라”처럼 뻔한 결론으로 끝나기 쉽습니다. 하지만 실무에서 CLS 폭증의 주범은 더 다양합니다.
- 늦게 로드되는 웹폰트로 인한 글자 폭 변화
- 광고/추천 위젯이 뒤늦게 삽입되며 상단 콘텐츠를 밀어냄
- 스켈레톤과 실제 콘텐츠의 높이 불일치
- 동적 배너, 쿠키 배너, 고정 헤더가 늦게 등장
- SPA 라우팅 이후 늦게 붙는 CSS, hydration mismatch
- 이미지 비율 미지정,
aspect-ratio누락, 반응형에서만 깨짐
1) DevTools로 “레이아웃 시프트”를 눈으로 잡기
Rendering 패널의 Layout Shift Regions
- Chrome DevTools 열기
- Command Menu에서
Show Rendering실행 - Rendering 패널에서
Layout Shift Regions체크
이 상태로 페이지를 새로고침하면, 시프트가 발생한 영역이 색으로 표시됩니다. 먼저 여기서 “어느 영역이 흔들리는지”를 대략적으로 확보합니다.
Performance 패널에서 Layout Shift 이벤트 확인
- DevTools
Performance탭 - Record 후 새로고침 또는 문제 동작 재현
- 타임라인에서
Experience섹션 확인
여기서 Layout Shift 이벤트를 클릭하면, 어떤 노드가 영향을 받았는지와 시프트 점수에 대한 단서를 얻을 수 있습니다.
팁: CLS 폭증이 “특정 사용자군에서만” 발생한다면, 반응형 브레이크포인트(모바일)와 네트워크(3G Slow) 조건을 걸고 재현하는 게 중요합니다. 느린 환경에서만 뒤늦게 삽입되는 요소가 원인인 경우가 많습니다.
2) 코드로 Layout Shift 원인을 로그로 남기기
DevTools만으로는 “왜 그 시점에 DOM이 바뀌었는지”까지는 부족할 때가 많습니다. 이때 PerformanceObserver로 layout-shift 엔트리를 수집하면, 시프트에 기여한 요소와 발생 시점을 콘솔로 남길 수 있습니다.
아래 코드는 운영에 넣기보다는, 디버그 빌드나 특정 쿼리 파라미터에서만 활성화하는 방식이 안전합니다.
// layout-shift 디버깅용 (Chrome)
// 주의: 운영 상시 활성화는 비권장
(function observeCLS() {
if (!('PerformanceObserver' in window)) return;
let cls = 0;
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 사용자의 입력(클릭/키입력) 직후의 시프트는 CLS에서 제외됨
if (entry.hadRecentInput) continue;
cls += entry.value;
const sources = (entry.sources || []).map((s) => {
const node = s.node;
return {
value: entry.value,
startTime: entry.startTime,
// node는 콘솔에서 클릭 추적 가능
node,
previousRect: s.previousRect,
currentRect: s.currentRect,
};
});
console.group('LayoutShift');
console.log('entry.value:', entry.value);
console.log('startTime(ms):', entry.startTime);
console.log('sources:', sources);
console.log('CLS so far:', cls);
console.groupEnd();
}
});
// buffered: true 로 초기 로딩 중 발생한 시프트도 받음
po.observe({ type: 'layout-shift', buffered: true });
})();
이 로그에서 중요한 건 sources.node입니다. 콘솔에서 해당 노드를 클릭해 DOM 위치를 찾고, “이 노드가 왜 저 시점에 크기나 위치가 바뀌었는지”를 역추적합니다.
3) CLS 폭증의 대표 원인과 ‘추적 포인트’
원인 A: 이미지/비디오/iframe의 고정 크기 미지정
가장 흔하지만, 반응형에서만 터지거나 특정 컴포넌트에서만 누락되어 놓치기 쉽습니다.
- 추적 포인트: 시프트 직전 해당 영역에 이미지가 늦게 로드됨
- 해결:
width/height또는 CSSaspect-ratio로 자리 선점
<!-- width/height로 고정 비율 확보 -->
<img src="/banner.jpg" width="1200" height="400" alt="banner" />
/* 반응형에서는 aspect-ratio가 더 깔끔한 경우가 많음 */
.hero-media {
width: 100%;
aspect-ratio: 16 / 9;
background: #eee;
}
.hero-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
원인 B: 웹폰트 로딩으로 인한 FOIT/FOUT
폰트가 바뀌면서 글자 폭이 달라지고 줄바꿈이 바뀌면, 콘텐츠 블록 높이가 변해 시프트가 발생합니다.
- 추적 포인트: 시프트 시점이
font리소스 로딩 직후 - 해결:
font-display: swap또는 적절한 preload, metric-compatible 폰트 사용
@font-face {
font-family: "MyFont";
src: url("/fonts/myfont.woff2") format("woff2");
font-display: swap;
}
<!-- 초기 로딩에서 폰트 지연을 줄이기 -->
<link rel="preload" href="/fonts/myfont.woff2" as="font" type="font/woff2" crossorigin />
추가로, 시스템 폰트와 커스텀 폰트의 메트릭 차이가 크면 swap 이후에도 레이아웃이 크게 변합니다. 가능하면 metric-compatible 폰트를 고려하거나, 폰트 스택을 조정해 변화 폭을 줄입니다.
원인 C: 동적 삽입 UI(쿠키 배너, 공지 배너, 광고, 추천 위젯)
처음에는 없던 요소가 상단에 삽입되면서 아래 콘텐츠를 밀면 CLS가 크게 누적됩니다.
- 추적 포인트: 시프트 직전에 특정 스크립트가 DOM에 새 노드를 삽입
- 해결: 공간을 미리 예약하거나(placeholder), 오버레이 방식으로 띄우기
/* 배너가 들어올 영역을 미리 확보 */
.top-banner-slot {
min-height: 64px;
}
/* 또는 레이아웃을 밀지 않는 고정/오버레이 방식 */
.cookie-banner {
position: fixed;
left: 0;
right: 0;
bottom: 0;
}
광고라면 “광고 높이가 확정되지 않는” 경우가 많습니다. 이때는 최소 높이를 예약하고, 광고 로딩 후 높이가 바뀌지 않도록 제한하는 편이 CLS 방어에 유리합니다.
원인 D: 스켈레톤과 실제 콘텐츠 높이 불일치
로딩 스켈레톤이 실제 카드/텍스트 레이아웃과 다르면, 데이터가 들어오는 순간 레이아웃이 흔들립니다.
- 추적 포인트: API 응답 직후 시프트가 발생
- 해결: 스켈레톤을 실제 레이아웃과 동일한 박스 모델로 설계
/* 카드 스켈레톤이 실제 카드와 동일한 padding/height를 갖도록 */
.card {
padding: 16px;
border: 1px solid #eee;
border-radius: 12px;
}
.card-skeleton {
padding: 16px;
border: 1px solid #eee;
border-radius: 12px;
}
.card-skeleton .title {
height: 20px;
margin-bottom: 12px;
}
.card-skeleton .line {
height: 14px;
margin-bottom: 8px;
}
원인 E: 늦게 적용되는 CSS, 라우팅 이후 스타일 로딩
SPA에서 라우팅 이후 특정 페이지의 CSS chunk가 늦게 로드되면, 기본 스타일로 렌더된 뒤 스타일이 적용되며 레이아웃이 크게 바뀔 수 있습니다.
- 추적 포인트: 시프트 시점에 CSS 리소스가 로드됨
- 해결: 핵심 스타일을 초기 번들에 포함하거나, critical CSS 전략 적용
Next.js라면 페이지 단위 CSS 분리가 도움이 되지만, “초기 렌더에 반드시 필요한 스타일”이 늦게 오지 않도록 구조를 점검해야 합니다.
4) “원인 요소를 찾았는데, 누가 바꿨는지” 추적하기
레이아웃 시프트는 대개 DOM 변경 또는 스타일 변경에서 시작합니다. 원인 코드가 복잡할 때는 다음 방법이 유효합니다.
MutationObserver로 DOM 삽입 추적
// 특정 컨테이너 아래에 무엇이 언제 추가되는지 추적
const target = document.querySelector('#app');
if (target) {
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList' && (m.addedNodes?.length || 0) > 0) {
console.group('DOM mutation');
console.log('addedNodes:', m.addedNodes);
console.log('target:', m.target);
console.trace('stack');
console.groupEnd();
}
}
});
mo.observe(target, { childList: true, subtree: true });
}
console.trace('stack')로 호출 스택을 남기면 “어느 코드가 삽입했는지”까지 따라가기 쉬워집니다.
특정 요소의 크기 변화를 감지하는 ResizeObserver
const el = document.querySelector('.top-banner-slot');
if (el && 'ResizeObserver' in window) {
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
console.log('Resize:', e.target, e.contentRect);
console.trace('resize stack');
}
});
ro.observe(el);
}
배너/광고/추천 영역처럼 “크기가 바뀔 가능성이 있는 컨테이너”에 붙이면, CLS 발생 시점과 resize 시점을 매칭하기 좋습니다.
5) 수정 가이드: CLS를 줄이는 설계 원칙
원칙 1: 위에서 아래로 밀지 말고, 공간을 예약하라
- 동적으로 들어올 요소는 placeholder로 자리 선점
- 최소 높이
min-height를 정하고, 로딩 완료 후에도 크게 바뀌지 않게 제한
원칙 2: 콘텐츠 상단에 늦게 삽입되는 UI를 피하라
쿠키 배너/공지 배너를 상단에 넣으면 CLS에 치명적입니다. 하단 고정 또는 오버레이를 고려하세요.
원칙 3: 폰트는 “로딩 속도”보다 “메트릭 변화”가 더 중요할 때가 있다
font-display: swap은 기본이지만, swap 이후 레이아웃 변화가 크면 오히려 CLS가 증가할 수도 있습니다. 이때는 preload, 폰트 서브셋, 메트릭 호환 폰트 등을 조합해 “변화 폭”을 줄이는 방향으로 접근합니다.
원칙 4: 애니메이션은 transform을 사용하고, 레이아웃을 건드리지 말라
top, left, height 같은 레이아웃 속성 변경은 주변 요소를 밀 수 있습니다. 가능하면 transform: translate로 처리해 레이아웃 재배치를 피합니다.
/* 나쁜 예: 레이아웃을 밀 가능성 */
.bad {
position: relative;
top: 10px;
}
/* 좋은 예: 레이아웃 영향 최소화 */
.good {
transform: translateY(10px);
}
6) 회귀 방지: “CLS 폭증”을 배포 전에 잡는 방법
Web Vitals를 수집하고, layout-shift 원인까지 붙여서 남기기
CLS만 숫자로 수집하면, 폭증 시 “왜?”를 다시 재현해야 합니다. 가능하다면 다음을 함께 남기세요.
- 라우트 정보
- 디바이스/뷰포트
- 주요 위젯 로딩 여부(광고, 추천)
- layout-shift 엔트리의
startTime과 주요 source selector(가능하면)
운영 환경에서 selector를 직접 수집할 때는 개인정보/보안 이슈가 없도록 주의하고, 필요 최소한의 정보만 익명화해 저장합니다.
실험 플래그와 A/B가 있다면 “배너/위젯 삽입 시점”부터 의심
CLS 폭증은 종종 기능 자체보다 “삽입 타이밍 변경”에서 시작됩니다. 예를 들어:
- 초기 렌더 후
setTimeout으로 위젯 삽입 - hydration 이후에만 렌더되는 컴포넌트
- 특정 조건에서만 추가되는 프로모션 배너
이런 변경은 코드 리뷰에서 놓치기 쉬우니, 성능 지표 알람과 함께 릴리즈 노트를 매칭하는 습관이 효과적입니다.
7) 빠른 체크리스트
- 이미지/iframe에
width/height또는aspect-ratio가 있는가 - 상단에 늦게 등장하는 배너/광고/쿠키 UI가 있는가
- 스켈레톤과 실제 콘텐츠의 높이가 같은가
- 폰트 로딩 이후 줄바꿈이 바뀌는가
- 라우팅 후 CSS가 늦게 적용되지는 않는가
- 애니메이션이 레이아웃 속성을 변경하고 있지는 않은가
마무리
CLS는 “한 번 고치면 끝”이 아니라, 제품이 성장하며 위젯과 실험이 늘어날수록 다시 튀기 쉬운 지표입니다. DevTools에서 Layout Shift 이벤트를 확인하고, PerformanceObserver로 시프트 엔트리를 수집해 원인 요소를 특정한 뒤, 공간 예약과 삽입 타이밍 제어로 재발을 막는 흐름을 팀의 기본 디버깅 루틴으로 만들어두면 좋습니다.
추적 과정에서 “시프트는 없는데 클릭이 늦다” 같은 문제까지 같이 보게 된다면, CLS와 함께 INP도 병행 점검하는 편이 효율적입니다. 위에서 소개한 INP 진단 글도 참고해 보세요.