- Published on
Chrome CLS 급증? Layout Shift 원인 추적법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 지표를 보다가 CLS가 갑자기 급증하면 대부분 팀이 같은 함정에 빠집니다. "무슨 요소가 밀렸는지"는 대충 감으로 찾고, "왜 그 시점에 밀렸는지"는 재현이 안 돼서 방치하는 패턴입니다. CLS는 사용자 단말, 네트워크, 캐시 상태, 광고/서드파티 스크립트 타이밍에 따라 달라지기 때문에, 눈으로만 확인하면 원인을 놓치기 쉽습니다.
이 글은 Chrome에서 CLS가 튀는 상황을 대상으로, 재현 -> 증거 수집 -> 원인 분리 -> 수정 검증 흐름으로 Layout Shift를 추적하는 방법을 정리합니다. 마지막에는 실서비스에서 CLS 원인을 서버로 수집해 “어느 페이지에서, 어떤 요소가, 어떤 트리거로” 발생했는지 역추적하는 로깅 코드도 제공합니다.
또한 사용자 상호작용 지표(INP)와 함께 최적화해야 체감이 좋아지는 경우가 많습니다. INP까지 같이 다루고 싶다면 React/Next.js 프론트 최적화로 INP 200ms 달성도 이어서 참고하면 좋습니다.
1) CLS 급증의 전형적인 원인 지도
CLS(Layout Shift)는 “사용자가 의도하지 않았는데 화면 요소가 이동한 정도”를 누적한 점수입니다. 실무에서 자주 나오는 원인은 크게 아래로 묶입니다.
1-1. 공간 미예약(Reserved space 없음)
- 이미지
width/height미지정 - 동영상/iframe/광고 슬롯 높이 미지정
- 스켈레톤이 실제 콘텐츠와 크기가 다름
- 서버 응답 지연으로 늦게 붙는 컴포넌트가 레이아웃을 밀어냄
1-2. 폰트 로딩(FOIT/FOUT)
- 웹폰트 로딩 후 글자 폭이 바뀌며 줄바꿈 재계산
font-display전략 부재- 폰트 서브셋/프리로드 누락
1-3. 동적 UI 삽입
- 상단 배너, 쿠키 동의, 앱 설치 유도 바가 늦게 등장
- SPA 라우팅 후 광고/추천 위젯이 뒤늦게 DOM에 삽입
- A/B 테스트 도구가 스타일을 덮어쓰며 reflow 유발
1-4. CSS/레이아웃 트리거 실수
- 이미지 로딩 후
height: auto로 커짐 position: sticky가 특정 조건에서 레이아웃을 다시 계산:has()나 무거운 셀렉터로 스타일 재계산이 늦게 발생
이 중 무엇인지 빠르게 갈라내려면 “언제 shift가 발생했는지”와 “누가 밀렸는지”를 먼저 잡아야 합니다.
2) DevTools로 Layout Shift를 눈으로 잡는 방법
Chrome DevTools만으로도 1차 원인 파악이 가능합니다. 핵심은 Performance 패널에서 Layout Shift 이벤트를 증거로 남기는 것입니다.
2-1. Performance 패널에서 Layout Shift 이벤트 캡처
- DevTools 열기
Performance탭- 상단의
Screenshots활성화 Record후 문제 동작 재현(초기 로드, 라우팅, 배너 등장 등)- 기록을 멈춘 뒤 타임라인에서
Layout Shift이벤트를 찾기
Layout Shift 이벤트를 클릭하면, 어떤 노드가 이동했는지(affected nodes)와 shift score 단서가 나옵니다. 여기서 중요한 건 “스크린샷 시점”과 “이벤트 시점”을 맞춰 보는 것입니다.
2-2. Rendering 패널에서 Layout Shift Regions 켜기
DevTools More tools의 Rendering에서 Layout Shift Regions를 켜면, shift가 발생한 영역이 하이라이트됩니다. 이건 재현이 쉬운 케이스에 특히 좋습니다.
다만 실무에서는 "재현이 안 되는 CLS"가 더 골치입니다. 그래서 다음 단계로 넘어갑니다.
3) 재현이 어려운 CLS를 위한 계측: PerformanceObserver
실서비스에서 CLS가 튀는데 로컬에서 재현이 안 된다면, 브라우저가 제공하는 PerformanceObserver로 layout-shift 엔트리를 수집해야 합니다. 이 방식은 “어떤 요소가 이동했는지”를 DOM 노드 단위로 잡을 수 있고, 발생 시각도 남길 수 있습니다.
아래 코드는 Layout Shift를 관찰하고, shift에 관여한 요소의 CSS selector 비슷한 힌트를 만들어 콘솔 또는 로깅 엔드포인트로 보낼 수 있게 구성한 예시입니다.
// layout-shift-observer.js
function getSelectorHint(el) {
if (!el || el.nodeType !== 1) return "";
const id = el.id ? `#${el.id}` : "";
const cls = (el.className && typeof el.className === "string")
? "." + el.className.trim().split(/\s+/).slice(0, 3).join(".")
: "";
return `${el.tagName.toLowerCase()}${id}${cls}`;
}
export function observeLayoutShifts(onEntry) {
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 {
selector: getSelectorHint(node),
previousRect: s.previousRect,
currentRect: s.currentRect
};
});
onEntry({
name: entry.name,
value: entry.value,
startTime: entry.startTime,
cls,
sources
});
}
});
// layout-shift는 buffered 옵션을 주면 이미 발생한 엔트리도 받을 수 있음
po.observe({ type: "layout-shift", buffered: true });
return () => po.disconnect();
}
페이지에서 다음처럼 사용합니다.
import { observeLayoutShifts } from "./layout-shift-observer";
const stop = observeLayoutShifts((e) => {
// 1) 개발 중엔 콘솔로 보고
console.log("layout-shift", e);
// 2) 운영에선 샘플링해서 서버로 전송
// navigator.sendBeacon("/rum/layout-shift", JSON.stringify(e));
});
// 필요 시 stop()으로 해제
이제 “CLS가 튄다”가 아니라, "어느 시각에, 어떤 요소가, 얼마만큼"이 데이터로 남습니다.
4) 원인 분리: shift 트리거를 5가지로 나눠 잡기
Layout Shift 엔트리에서 sources의 selector 힌트를 얻었으면, 그 요소가 왜 움직였는지 트리거를 분리해야 합니다. 실무에서 빠르게 수렴하는 체크리스트는 아래 5개입니다.
4-1. 이미지/미디어 크기 미지정
증상:
img가 로드된 뒤 아래 콘텐츠가 밀림
해결:
img에width/height명시 또는 CSSaspect-ratio로 공간 예약
.card-thumb img {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
}
Next.js를 쓴다면 next/image의 고정 레이아웃(폭/높이 지정)을 활용해 공간을 확정하세요.
4-2. 폰트 로딩으로 인한 줄바꿈 변화
증상:
- 초기엔 시스템 폰트로 보이다가 웹폰트 적용 후 텍스트 블록 높이가 변함
해결:
font-display: swap또는optional- 가능한 경우 폰트 프리로드
- 폰트 메트릭 호환 폴백 설정
@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, sans-serif;
}
텍스트가 큰 히어로 영역에서 CLS가 많이 나오면 폰트가 1순위인 경우가 많습니다.
4-3. 상단 고정 배너/쿠키바가 늦게 DOM에 삽입
증상:
- 로드 후 0.5초~2초 사이에 상단 바가 나타나며 전체가 아래로 밀림
해결:
처음부터해당 높이를 예약하거나, overlay로 띄워 레이아웃을 밀지 않게 처리
/* 레이아웃을 밀지 않게 overlay로 처리 */
.cookie-banner {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
/* 본문은 padding-bottom을 미리 확보(배너 높이를 알고 있을 때) */
.page {
padding-bottom: 72px;
}
높이가 가변이면 “최대 높이”를 기준으로 예약하거나, 배너 내부 스크롤로 제한하는 식으로 레이아웃 안정성을 우선하세요.
4-4. 스켈레톤과 실제 콘텐츠의 크기 불일치
증상:
- 스켈레톤이 사라지고 실제 카드/리스트가 렌더되며 높이가 달라짐
해결:
- 스켈레톤을 실제 컴포넌트와 동일한 박스 모델로 맞추기
- 이미지 영역은
aspect-ratio로 고정
.product-card {
display: grid;
grid-template-rows: auto 1fr auto;
}
.product-card .thumb {
aspect-ratio: 1 / 1;
background: #f2f2f2;
}
4-5. 애니메이션/전환에서 레이아웃 속성을 건드림
증상:
- hover/진입 애니메이션에서
height,top,left를 변경해 reflow 발생
해결:
- 레이아웃을 바꾸는 속성 대신
transform과opacity사용
/* 나쁨: height 변경은 레이아웃을 밀 가능성이 큼 */
.bad-accordion.open { height: 240px; }
/* 좋음: transform으로 시각적 이동만 */
.toast {
transform: translateY(16px);
opacity: 0;
transition: transform 180ms ease, opacity 180ms ease;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
5) “어느 릴리즈에서 터졌는지”를 찾는 운영 추적 패턴
CLS 급증은 종종 특정 릴리즈 이후 발생합니다. 이때 필요한 건 RUM(Real User Monitoring) 형태의 최소 로깅입니다.
- 페이지 경로
- CLS 누적값
- 상위 N개의 shift entry(값이 큰 순)
- 빌드/커밋 버전
- 디바이스/뷰포트 폭
아래는 Web Vitals 스타일로 CLS를 수집해 전송하는 간단 예시입니다.
import { observeLayoutShifts } from "./layout-shift-observer";
export function startClsRum({ endpoint, release, sampleRate = 0.05 }) {
if (Math.random() > sampleRate) return () => {};
const entries = [];
const stop = observeLayoutShifts((e) => {
entries.push(e);
// 메모리 폭주 방지: 최근 20개만 유지
if (entries.length > 20) entries.shift();
});
function flush() {
const payload = {
type: "cls",
release,
url: location.href,
viewport: { w: window.innerWidth, h: window.innerHeight },
// 가장 마지막 cls 누적값
cls: entries.length ? entries[entries.length - 1].cls : 0,
entries
};
const body = JSON.stringify(payload);
if (navigator.sendBeacon) {
navigator.sendBeacon(endpoint, body);
} else {
fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body, keepalive: true });
}
}
// 페이지 이탈 시 전송
window.addEventListener("pagehide", flush);
return () => {
window.removeEventListener("pagehide", flush);
stop();
};
}
이 정도만 해도 “특정 릴리즈에서 특정 페이지의 특정 컴포넌트가 shift를 유발” 같은 결론이 데이터로 나옵니다. 이후엔 해당 컴포넌트의 로딩 순서, 조건부 렌더링, CSS 적용 타이밍을 집중적으로 보면 됩니다.
6) Chrome에서만 유독 튀는 케이스: 페인트/스크롤/contain 이슈
레이아웃 시프트 자체는 브라우저 공통 개념이지만, 특정 브라우저에서 더 잘 드러나는 경우가 있습니다. 예를 들어 스크롤/페인트 타이밍 문제로 레이아웃이 흔들려 보이거나, 큰 DOM에서 스타일 계산이 지연되며 “늦게 자리잡는” 현상이 나타날 수 있습니다.
이때는 contain 같은 CSS 격리로 영향을 줄이는 접근이 유효할 때가 있습니다. iOS Safari 사례지만 원리 자체는 레이아웃/페인트 격리와 연관이 있으니 Safari iOS 17 스크롤 끊김, CSS contain으로 해결하기도 같이 보면 문제를 구조적으로 바라보는 데 도움이 됩니다.
7) 수정 후 검증 체크리스트
마지막으로 “고쳤다”를 확정하려면 같은 방식으로 검증해야 합니다.
- DevTools Performance에서 Layout Shift 이벤트 수/크기 감소 확인
- 느린 네트워크(DevTools Network throttling)에서 재검증
- 캐시 초기화 상태(첫 방문)에서 재검증
- 다양한 뷰포트(모바일, 태블릿)에서 재검증
- 운영 RUM에서 릴리즈 전후 CLS 분포 비교
특히 광고/서드파티가 얽혀 있으면 로컬에서 좋아 보여도 운영에서 다시 튈 수 있습니다. 이 경우 위에서 소개한 PerformanceObserver 기반 로깅이 재발 방지에 가장 효과적입니다.
마무리: CLS는 “레이아웃 계약” 문제다
CLS 급증은 대개 “나중에 나타날 요소의 공간을 미리 계약하지 않았다”는 신호입니다. 이미지/폰트/배너/스켈레톤/애니메이션 중 어디에서 계약이 깨지는지 측정으로 특정하고, 해당 영역에 고정 크기 또는 격리를 적용하면 대부분 빠르게 안정화됩니다.
다음 액션을 한 줄로 정리하면 이렇습니다.
- DevTools에서 Layout Shift 이벤트를 캡처해 1차 범인을 찾고
PerformanceObserver로 운영에서 재현 불가 케이스를 수집한 뒤- 공간 예약과 렌더링 순서를 정리해 CLS를 구조적으로 제거하세요.