- Published on
크롬 CLS 폭증? 폰트·이미지 레이아웃 시프트 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 빠른데도 크롬에서 CLS(Cumulative Layout Shift) 점수가 갑자기 치솟아 사용자 경험이 망가지는 경우가 있습니다. 특히 “첫 화면은 잘 뜨는데 스크롤 중 텍스트가 튀고 버튼이 밀린다”, “폰트가 바뀌면서 문단이 흔들린다”, “이미지가 늦게 로드되며 아래 콘텐츠가 내려간다” 같은 증상은 대부분 공간을 미리 확보하지 못한 렌더링에서 시작합니다.
이 글은 크롬(Chrome) 기준으로 CLS 폭증을 재현/추적하는 방법과, 폰트·이미지·동적 컴포넌트에서 레이아웃 시프트를 줄이는 7가지 처방을 코드와 함께 정리합니다. (참고로 성능 이슈가 TTFB 쪽이라면 Next.js 14 RSC 느림? TTFB 급증 7가지 해결도 함께 점검하면 좋습니다.)
CLS를 먼저 ‘측정 가능’하게 만들기
CLS는 “화면에 보이는 요소가 예고 없이 이동한 정도”를 누적한 값입니다. 중요한 포인트는 실사용자 환경에서만 터지는 CLS가 많다는 점입니다(폰트 캐시, 네트워크 상태, 광고 응답, A/B 스크립트 등).
1) Chrome DevTools로 Layout Shift 이벤트 잡기
- DevTools → Performance 탭
Web Vitals또는Screenshots옵션 켜고 녹화- 결과 타임라인에서 Layout Shift 이벤트 클릭
- “Affected nodes”에서 어떤 DOM이 밀었는지 확인
또는 DevTools → Rendering → “Layout Shift Regions”를 켜면, 시프트가 발생한 영역이 시각적으로 표시됩니다.
2) web-vitals로 실측 로깅(필수)
로컬 재현이 안 되면, 실제 사용자에게서 CLS를 수집해야 원인을 찾습니다.
<script type="module">
import { onCLS } from 'https://unpkg.com/web-vitals@4?module';
onCLS((metric) => {
// metric.value: CLS 점수
// metric.entries: LayoutShiftEntry 목록
console.log('CLS', metric.value, metric.entries);
// 예: 서버로 전송
navigator.sendBeacon('/vitals', JSON.stringify({
name: metric.name,
value: metric.value,
entries: metric.entries.map(e => ({
startTime: e.startTime,
value: e.value,
sources: e.sources?.map(s => ({
node: s.node?.nodeName,
previousRect: s.previousRect,
currentRect: s.currentRect,
}))
}))
}));
});
</script>
이렇게 entries.sources까지 수집하면 “어떤 노드가 어떤 방향으로 얼마나 밀었는지”가 로그로 남아, 원인 추적이 급격히 쉬워집니다.
폰트·이미지 레이아웃 시프트 7가지 처방
아래 7가지는 실무에서 CLS 폭증의 대부분을 차지하는 케이스입니다. 특히 1~4번(폰트/이미지/동적 영역)은 우선순위가 높습니다.
1) 이미지/비디오에 width/height(또는 aspect-ratio)로 ‘공간 예약’
가장 흔한 CLS 원인입니다. 이미지가 늦게 로드되면 브라우저는 높이를 모른 채로 레이아웃을 잡고, 로드 순간 아래 콘텐츠가 밀립니다.
해결
img에width/height명시 (가장 확실)- 또는 CSS
aspect-ratio로 비율 고정
<img
src="/hero.jpg"
width="1200"
height="630"
alt="hero"
loading="lazy"
decoding="async"
/>
.card-thumb {
aspect-ratio: 16 / 9;
width: 100%;
object-fit: cover;
display: block;
}
> 팁: 반응형에서도 width/height는 “비율 예약” 역할을 하므로 유지하는 게 좋습니다.
2) 폰트 로딩으로 인한 FOIT/FOUT 최소화(font-display + preload)
크롬에서 CLS가 ‘문단 단위로’ 튀는 경우, 폰트가 늦게 적용되며 글자 폭이 바뀌는 것이 원인일 때가 많습니다.
해결 1: font-display: swap 또는 optional
@font-face {
font-family: "MyFont";
src: url("/fonts/myfont.woff2") format("woff2");
font-display: swap;
}
swap: 즉시 시스템 폰트로 렌더 후 폰트 도착 시 교체(시프트 가능성은 남음)optional: 네트워크가 느리면 교체 자체를 포기해 시프트를 더 줄일 수 있음(브랜딩 요구와 트레이드오프)
해결 2: 핵심 폰트 preload
<link
rel="preload"
href="/fonts/myfont.woff2"
as="font"
type="font/woff2"
crossorigin
/>
> preload는 남발하면 오히려 초기 네트워크 혼잡을 만들 수 있으니, 첫 화면에서 실제로 쓰는 폰트 1~2개 정도로 제한하세요.
3) 폰트 메트릭 불일치 줄이기(size-adjust, ascent-override)
swap을 쓰면 “시스템 폰트 → 웹폰트” 교체가 발생합니다. 이때 두 폰트의 메트릭(높이/폭)이 다르면 레이아웃이 흔들립니다.
해결: fallback 폰트를 웹폰트 메트릭에 맞추기
@font-face {
font-family: "MyFont";
src: url("/fonts/myfont.woff2") format("woff2");
font-display: swap;
}
/* 폴백 폰트 메트릭을 조정해 교체 시 레이아웃 변화 최소화 */
@font-face {
font-family: "MyFont Fallback";
src: local("Arial");
size-adjust: 102%;
ascent-override: 92%;
descent-override: 20%;
line-gap-override: 0%;
}
body {
font-family: "MyFont", "MyFont Fallback", system-ui, sans-serif;
}
수치는 폰트마다 달라서 약간의 튜닝이 필요하지만, 적용 후 문단 점프가 눈에 띄게 줄어드는 경우가 많습니다.
4) 광고/추천/댓글 위젯: ‘나중에 생기는 영역’은 최소 높이 확보
광고 슬롯, 추천 콘텐츠, 댓글 위젯처럼 응답이 늦거나 조건부로 렌더링되는 블록은 CLS의 주범입니다. “아무 것도 없다가 갑자기 큰 박스가 생기며 아래가 밀리는” 패턴이죠.
해결: 스켈레톤 + min-height로 공간 예약
<section class="ad-slot" aria-label="Sponsored">
<div class="ad-skeleton"></div>
<!-- 광고 스크립트가 이 영역에 렌더링 -->
</section>
.ad-slot {
min-height: 250px; /* 실제 광고 크기에 맞게 */
}
.ad-skeleton {
height: 250px;
background: #f2f3f5;
}
> 광고는 뷰포트/디바이스에 따라 크기가 달라질 수 있으니, 반응형 브레이크포인트별로 min-height를 다르게 두는 전략이 안정적입니다.
5) 상단 배너/공지/쿠키 배너: ‘push-down’ 대신 overlay로
상단에 공지 배너나 쿠키 동의 배너가 페이지 로드 후 삽입되면, 전체 콘텐츠가 아래로 밀리며 CLS가 크게 발생합니다.
해결: 레이아웃을 밀지 말고 overlay로 띄우기
.cookie-banner {
position: fixed;
left: 16px;
right: 16px;
bottom: 16px;
z-index: 9999;
}
또는 꼭 상단에 넣어야 한다면, 초기부터 배너 높이만큼 padding을 예약하거나, 배너 영역을 SSR로 포함해 “처음부터 존재”하게 만드세요.
6) 이미지 lazy-load로 인한 점프: placeholder + content-visibility 활용
loading="lazy" 자체가 CLS를 만들진 않지만, 공간 예약이 없으면 늦게 로드되며 점프합니다. 또한 리스트/피드가 길 때는 렌더링 비용이 커서 스크롤 중 끊김과 함께 시프트가 더 눈에 띌 수 있습니다.
해결: placeholder + content-visibility
.feed-item {
content-visibility: auto;
contain-intrinsic-size: 400px; /* 대략적 높이 추정치 */
}
content-visibility: auto는 화면 밖 콘텐츠의 렌더링을 지연contain-intrinsic-size로 대략적인 공간을 예약해 스크롤 점프를 줄임
> 이 조합은 CLS뿐 아니라 스크롤 성능에도 유효합니다.
7) 동적 텍스트/숫자 카운터/번역 적용: 고정 폭 또는 tabular-nums
가격/카운터/타이머가 “9 → 10 → 100”처럼 자릿수가 바뀌면, 주변 레이아웃이 미세하게 흔들립니다. 특히 버튼 옆 숫자, 카드 헤더의 수치가 바뀌는 UI에서 자주 보입니다.
해결 1: 숫자 폭 고정(tabular nums)
.stat {
font-variant-numeric: tabular-nums;
}
해결 2: 최소 폭 예약
.price {
display: inline-block;
min-width: 6ch; /* 대략 6글자 폭 예약 */
text-align: right;
}
크롬에서 CLS 폭증을 빨리 줄이는 점검 순서
실무에서는 “원인 후보가 너무 많다”가 문제라서, 아래 순서로 좁히는 게 효율적입니다.
- DevTools Performance에서 Layout Shift 이벤트의 affected node 확인
- 첫 화면에서 큰 시프트가 있으면:
- 이미지/히어로/광고 슬롯의
width/height,aspect-ratio,min-height부터 확인
- 이미지/히어로/광고 슬롯의
- 문단이 통째로 흔들리면:
- 폰트
preload,font-display, 메트릭 조정(size-adjust 등) 확인
- 폰트
- 특정 사용자/세션에서만 크면:
- web-vitals로 CLS entries를 수집해 어떤 DOM이 원인인지 로그로 확정
성능 이슈는 종종 “렌더링(클라이언트) 문제”와 “서버 응답(백엔드) 문제”가 엮여 나타납니다. 예를 들어 서버가 느려서 위젯이 늦게 도착하면 그 자체가 레이아웃 시프트의 트리거가 되기도 합니다. 백엔드 지연이 의심되면 앞서 언급한 Next.js 14 RSC 느림? TTFB 급증 7가지 해결처럼 TTFB 원인도 함께 제거하는 것이 전체 UX를 안정화하는 지름길입니다.
마무리: CLS는 ‘공간 예약’과 ‘예측 가능한 렌더링’의 문제
CLS 폭증의 본질은 단순합니다. 나중에 나타날(또는 크기가 바뀔) 요소의 공간을 미리 확보하지 못했기 때문입니다.
- 이미지/비디오:
width/height또는aspect-ratio - 광고/위젯:
min-height+ 스켈레톤 - 폰트:
font-display+preload+ 메트릭 튜닝 - 배너: 레이아웃을 밀지 말고 overlay 또는 SSR로 고정
- 동적 숫자: 폭 고정(tabular-nums) 또는 최소 폭 예약
위 7가지를 적용하면 대부분의 크롬 CLS 폭증은 유의미하게 내려갑니다. 다음 단계로는 실제 사용자 CLS 로그를 기반으로 “어떤 페이지/컴포넌트가 기여도가 큰지”를 정량화해, 가장 큰 시프트부터 제거하는 방식으로 운영하면 됩니다.