- Published on
Chrome CLS 급증? Layout Shift 원인 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
CLS(Cumulative Layout Shift)는 “페이지가 로드/실행되는 동안 사용자가 보게 되는 레이아웃 점프”를 수치화한 지표입니다. 문제는 CLS가 어느 날 갑자기 급증하는 케이스가 많다는 점입니다. 광고 스크립트, 폰트, 이미지, SPA 라우팅, A/B 테스트, 쿠키 배너 등 “내 코드 밖”에서 발생하는 변화가 레이아웃을 흔들어 버리기 때문이죠.
이 글에서는 Chrome(특히 CrUX/PSI, Lighthouse, Web Vitals 확장, DevTools Performance/Rendering)을 기준으로 Layout Shift의 대표 원인 7가지를 짚고, 각 원인별로 “어떻게 재현·진단하고 무엇을 고치면 되는지”를 코드와 함께 정리합니다.
> 참고: 운영 환경에서 갑자기 지표가 튀는 문제는 대개 “관측 → 원인 좁히기 → 재현 → 고정”의 루프로 해결됩니다. 성능 이슈를 진단하는 접근은 인프라/앱을 막론하고 비슷합니다. 예를 들어 Jenkins 빌드가 갑자기 느려질 때 원인 7가지처럼, 갑작스러운 변화는 대부분 외부 의존성/환경/릴리즈 단위의 조합에서 발생합니다.
CLS 급증을 먼저 ‘증거’로 잡는 방법
원인 7가지를 보기 전에, DevTools에서 “무엇이 움직였는지”를 빠르게 캡처하는 방법부터 정리합니다.
1) DevTools에서 Layout Shift 이벤트 찾기
- Chrome DevTools → Performance
- Record(새로고침 포함) 후
- 타임라인에서 Experience 섹션 또는 Layout Shift 이벤트를 클릭
- 오른쪽 패널에서 Affected nodes(영향받은 DOM) 확인
추가로,
- Rendering 탭 → Layout Shift Regions 체크: 화면에서 이동한 영역이 하이라이트됩니다.
2) 런타임에서 CLS/Shift 원인 로그 찍기(실전용)
아래 스니펫은 layout-shift 엔트리를 수집해 어떤 요소가 언제 이동했는지를 콘솔에 남깁니다. (운영에서는 샘플링/PII 주의)
<script>
(() => {
let cls = 0;
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 사용자 입력(클릭/키보드) 직후의 shift는 CLS 계산에서 제외됨
if (entry.hadRecentInput) continue;
cls += entry.value;
const sources = (entry.sources || []).map(s => {
const node = s.node;
return {
node: node ? node.tagName + (node.id ? `#${node.id}` : '') : null,
previousRect: s.previousRect,
currentRect: s.currentRect,
};
});
console.log('[layout-shift]', {
value: entry.value,
cls,
startTime: entry.startTime,
sources,
});
}
});
po.observe({ type: 'layout-shift', buffered: true });
})();
</script>
이 로그가 있으면 “광고가 들어오면서 어떤 컨테이너가 밀렸는지”, “폰트 로딩 이후 어떤 텍스트 블록이 재배치됐는지”를 훨씬 빨리 특정할 수 있습니다.
원인 1) 이미지/비디오/iframe의 고정 크기 미지정
가장 흔한 원인입니다. 브라우저는 이미지가 다운로드되기 전까지 intrinsic size를 확정할 수 없고, 결과적으로 레이아웃이 “일단 대충 배치 → 로드 후 재배치”되며 shift가 발생합니다.
해결
img에width/height를 지정하거나- CSS의
aspect-ratio로 비율을 고정하고 - 반응형이라면
max-width: 100%와 함께 사용합니다.
<!-- BAD: 크기 미지정 -->
<img src="/hero.jpg" alt="hero">
<!-- GOOD: 고정 크기(브라우저가 공간을 미리 확보) -->
<img src="/hero.jpg" alt="hero" width="1200" height="630" style="max-width:100%;height:auto;">
<!-- GOOD: 비율 고정(동적 레이아웃에서 유용) -->
<div class="media">
<iframe src="..." loading="lazy"></iframe>
</div>
<style>
.media {
aspect-ratio: 16 / 9;
width: 100%;
}
.media iframe {
width: 100%;
height: 100%;
border: 0;
}
</style>
추가 팁:
loading="lazy"는 CLS를 줄여주기도 하지만, lazy 로딩으로 인해 늦게 삽입되는 요소가 위/아래를 밀면 오히려 shift가 커질 수 있습니다. 핵심은 “늦게 로드돼도 공간은 미리 확보”입니다.
원인 2) 광고/서드파티 위젯이 ‘나중에’ DOM을 밀어냄
광고 슬롯, 추천 위젯, 채팅 위젯, 소셜 임베드 등은 로드 타이밍이 불규칙하고, 종종 높이가 나중에 확정됩니다. 특히 상단(above the fold)에서 발생하면 CLS에 치명적입니다.
해결
- 광고/위젯 영역에 최소 높이(min-height) 또는 고정 슬롯을 미리 잡습니다.
- “없으면 제거”가 아니라 “없으면 빈 슬롯 유지”가 CLS 관점에서 유리할 때가 많습니다.
<div class="ad-slot" id="ad-top"></div>
<style>
.ad-slot {
/* 실측 기반으로 보수적으로 확보 */
min-height: 250px;
background: #f6f7f9;
}
</style>
<script>
// 광고가 로드되면 내부에 삽입되더라도 바깥 레이아웃은 유지
loadAdInto(document.getElementById('ad-top'));
</script>
추가로, 서드파티 스크립트가 DOM 최상단에 배너를 “삽입”하는 형태라면, 삽입 위치를 하단 fixed로 바꾸거나(사용성 고려), 상단에 배너 컨테이너를 미리 렌더링해 그 안에서만 변화가 일어나도록 제한하세요.
원인 3) 웹폰트 로딩으로 인한 FOIT/FOUT 및 줄바꿈 변화
폰트가 늦게 로드되면 텍스트가 fallback 폰트로 렌더링되었다가 웹폰트로 바뀌며 폭/자간/행간이 달라져 레이아웃이 흔들립니다. 특히 제목, 버튼, 내비게이션처럼 정렬/폭에 민감한 텍스트가 문제를 일으킵니다.
해결
font-display: swap(또는 상황에 따라optional) 적용- 가능한 경우 fallback 폰트를 “메트릭이 비슷한 폰트”로 설정
- 중요한 폰트는
preload로 앞당김
@font-face {
font-family: "MyFont";
src: url("/fonts/myfont.woff2") format("woff2");
font-display: swap;
}
body {
font-family: "MyFont", system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif;
}
<link rel="preload" href="/fonts/myfont.woff2" as="font" type="font/woff2" crossorigin>
실전 팁:
- 타이포그래피가 빡빡한 헤더/탭 메뉴는
min-width/line-height를 안정적으로 잡아두면 폰트 교체 시 흔들림이 줄어듭니다.
원인 4) 동적 콘텐츠 삽입(쿠키 배너, 공지, 로그인 상태, A/B 테스트)
“사용자 상태에 따라” 또는 “실험군에 따라” 상단에 배너가 생기거나, 로그인 후 사용자 메뉴가 커지거나, 공지 영역이 추가되는 식의 변화는 CLS의 단골입니다.
해결
- 상단에 등장할 가능성이 있는 UI는 초기 렌더 시점부터 자리(공간)를 확보합니다.
- 조건부 렌더링 대신, 기본은 숨김(
visibility) 또는 빈 상태 skeleton을 두고 내용만 교체합니다.
<header class="site-header">
<div class="cookie-banner" id="cookie" aria-hidden="true"></div>
<nav>...</nav>
</header>
<style>
.cookie-banner {
/* 배너가 뜰 수 있는 최대 높이만큼 확보 */
min-height: 56px;
}
.cookie-banner[aria-hidden="true"] {
visibility: hidden; /* 공간은 유지 */
}
</style>
<script>
if (shouldShowCookieBanner()) {
const el = document.getElementById('cookie');
el.textContent = '쿠키 사용에 동의해 주세요...';
el.setAttribute('aria-hidden', 'false');
}
</script>
A/B 테스트 도구가 DOM을 조작해 shift를 만든다면, 실험 스크립트를 head에서 너무 늦게 로드하지 않도록 하거나(초기 페인트 전에 결정), 실험 대상 영역의 높이를 고정해 “변화는 내부에서만” 일어나게 하세요.
원인 5) 스켈레톤/로딩 상태와 실제 콘텐츠의 크기 불일치
스켈레톤 UI를 넣어도, 실제 카드/텍스트/이미지의 높이가 스켈레톤과 다르면 로딩 완료 시점에 shift가 발생합니다. 특히 리스트/피드 UI에서 누적 CLS가 커집니다.
해결
- 스켈레톤을 “대충” 만들지 말고, 실제 컴포넌트의 레이아웃(패딩/타이포/라인수)을 최대한 맞춥니다.
- 텍스트는
line-clamp/고정line-height로 높이를 예측 가능하게 만듭니다.
.card-title {
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: calc(1.3em * 2); /* 2줄 높이 확보 */
}
.skeleton-title {
height: calc(1.3em * 2);
}
원인 6) 상단 고정 헤더/배너의 “나중 적용” (CSS/JS 순서 문제)
초기에는 일반 흐름에 있던 헤더가 CSS 로딩 후 position: fixed로 바뀌거나, JS가 실행되며 헤더 높이를 재계산해 padding-top을 바꾸는 경우가 있습니다. 이런 “스타일 적용 타이밍” 문제는 로컬에서는 재현이 어려운데, 네트워크가 느린 환경에서 잘 터집니다.
해결
- critical CSS(레이아웃에 영향 큰 스타일)를 가능한 한 빨리 적용
- fixed 헤더라면 초기부터
body에 헤더 높이만큼padding-top을 주고, 나중에 바꾸지 않도록 설계
<style>
:root { --header-h: 64px; }
body { padding-top: var(--header-h); }
.header {
position: fixed;
top: 0; left: 0; right: 0;
height: var(--header-h);
}
</style>
<header class="header">...</header>
<main>...</main>
JS로 높이를 측정해 반영해야 한다면, 첫 페인트 이후에 값을 크게 바꾸지 않도록 “최대 높이” 기반으로 잡거나, 폰트 로드/뷰포트 변화 등과 경쟁하지 않게 주의하세요.
원인 7) 애니메이션/트랜지션에서 layout 속성(top/left/height 등)을 변경
애니메이션 자체가 나쁜 건 아니지만, top/left/height/margin 같은 레이아웃을 다시 계산하게 만드는 속성을 애니메이션하면 주변 요소까지 밀리며 shift처럼 느껴지거나 실제 Layout Shift로 기록될 수 있습니다.
해결
- 가능한 한
transform과opacity로 애니메이션(컴포지터 레벨) - 아코디언/드롭다운은 “레이아웃을 밀어내는 방식” 대신, 오버레이(absolute)로 띄우거나, 공간을 미리 확보
/* BAD: height 변화로 레이아웃 재계산 */
.panel { transition: height 200ms ease; }
/* GOOD: transform으로 시각적 이동 */
.toast {
position: fixed;
bottom: 16px;
right: 16px;
transform: translateY(20px);
opacity: 0;
transition: transform 200ms ease, opacity 200ms ease;
will-change: transform, opacity;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
운영에서 “갑자기 CLS가 튄 날”을 추적하는 체크리스트
CLS 급증은 보통 배포/설정 변경/서드파티 변경과 맞물립니다. 아래 순서로 좁히면 빠릅니다.
- CrUX/PSI에서 URL 단위 vs Origin 단위로 동시에 확인(특정 페이지 문제인지 전반 문제인지)
- 최근 배포 내역에서
- 광고/태그매니저/분석 스니펫 변경
- 폰트/이미지 CDN 변경
- 헤더/배너/로그인 영역 변경
- 실험(A/B) 시작 여부
- DevTools Performance에서 Layout Shift 이벤트의 Affected nodes 확인
- 해당 노드가 “내 코드”인지 “서드파티 삽입”인지 분리
- 재현이 어렵다면 네트워크/CPU throttling으로 “느린 환경”에서 다시 측정
비슷한 맥락으로, 환경에 따라 재현이 갈리는 문제를 진단할 때는 로그/관측을 먼저 깔아두는 게 핵심입니다. 예를 들어 Linux OOM Killer 로그 추적과 메모리 누수 진단처럼 “언제/무엇이/왜”를 남기면 원인 규명이 빨라집니다.
마무리: CLS를 낮추는 핵심은 ‘공간을 예약’하는 것
7가지 원인을 관통하는 공통 해법은 단순합니다.
- **늦게 로드/결정되는 것(이미지, 광고, 폰트, 상태 UI)**은
- 레이아웃 공간을 미리 예약하고
- 변화는 내부에서만 일어나게 만들 것
마지막으로, 팀 차원에서 재발을 막으려면 “컴포넌트 가이드”를 만들어 img/iframe 크기 규칙, 광고 슬롯 규칙, 폰트 로딩 규칙, 스켈레톤 규칙을 린트/리뷰 체크리스트로 고정하는 것이 가장 효과적입니다.
원하시면 사용 중인 프레임워크(Next.js/React/Vue)와 광고/태그 구성(GTM, AdSense, Prebid 등)을 알려주시면, 해당 스택 기준으로 CLS를 가장 많이 만드는 지점을 우선순위로 잡아 구체적인 개선안을 더 좁혀드릴 수 있습니다.