- Published on
CLS 폭증 원인? 폰트 로딩·이미지 크기 고정 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
CLS(Cumulative Layout Shift)는 사용자가 보고 있던 화면의 요소가 예상치 못하게 밀리는 정도를 점수로 나타냅니다. Lighthouse/CrUX에서 “CLS 폭증”이 보이면 대개 체감 품질이 급락합니다. 특히 폰트 로딩(FOIT/FOUT), 이미지/광고/iframe의 크기 미지정, 늦게 삽입되는 상단 배너가 결합되면 점수가 급격히 튀는 경우가 많습니다.
이 글은 “왜 CLS가 폭증하는지”를 원리로 설명하고, 실제 서비스에서 바로 적용 가능한 폰트 로딩 전략과 이미지/미디어 크기 고정을 중심으로 재현·수정·검증 흐름으로 정리합니다. 프론트가 Next.js/React이든, 서버 렌더링이든 핵심은 같습니다.
참고로, 레이아웃이 흔들리는 이슈는 때때로 SSR/CSR 경계에서 DOM이 바뀌는 문제(하이드레이션 불일치)와도 함께 나타납니다. 관련해서는 Next.js Hydration Mismatch 5가지 원인과 해결법도 같이 보면 원인 분리가 빨라집니다.
CLS가 폭증하는 “진짜” 메커니즘
CLS는 단순히 “요소가 움직였다”가 아니라, 브라우저가 기존에 할당해둔 레이아웃 박스가 바뀌면서 발생합니다. 흔한 패턴은 다음과 같습니다.
- 초기 레이아웃 계산 시점에 크기를 모름
- 이미지가
width/height없이 로딩됨 - iframe/광고 슬롯의 높이가 나중에 결정됨
- 이미지가
- 초기 렌더 이후에 폰트가 바뀌며 텍스트 줄바꿈/행높이가 변경
- 폴백 폰트로 그렸다가 웹폰트 적용되며 폭이 달라짐(FOUT)
- 폰트가 늦게 떠서 텍스트가 뒤늦게 나타남(FOIT)
- 뷰포트 상단에 콘텐츠가 늦게 삽입
- 쿠키 배너/공지 배너/프로모션 바가 JS로 삽입
- 상단 내비게이션 높이가 비동기 데이터로 변경
핵심은 “나중에 들어올 것”을 처음부터 자리(공간)를 확보해 두는 것입니다.
1) 폰트 로딩이 CLS를 만드는 방식과 해결
폰트로 인한 CLS 시나리오
- 초기: 시스템 폰트(예:
Arial,Apple SD Gothic Neo)로 텍스트 렌더 - 이후: 웹폰트(예:
Pretendard,Noto Sans KR) 다운로드 완료 - 결과: 글자 폭/자간/행높이가 달라져 줄바꿈이 바뀌고, 아래 콘텐츠가 밀림 → CLS 증가
해결 전략 A: font-display: swap + 폰트 메트릭 맞추기
swap은 “일단 폴백으로 빨리 그린 뒤, 웹폰트가 오면 교체” 전략입니다. 다만 폴백과 웹폰트의 메트릭 차이가 크면 교체 순간에 레이아웃이 흔들립니다.
- 기본적으로
font-display: swap적용
/* fonts.css */
@font-face {
font-family: "Pretendard";
src: url("/fonts/Pretendard.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
body {
font-family: "Pretendard", system-ui, -apple-system, "Segoe UI", Roboto,
"Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
}
- 폴백 폰트를 “가까운 것”으로 선택
- 한글은 폰트별 폭 차이가 커서, 폴백을 적절히 고르는 것만으로도 CLS가 줄어듭니다.
- 가능하면
size-adjust/ascent-override등 메트릭 오버라이드(지원 브라우저 확인)
@font-face {
font-family: "Pretendard";
src: url("/fonts/Pretendard.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
/* 폰트 교체 시 레이아웃 변화 완화(브라우저 지원 확인) */
size-adjust: 102%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
> 팁: 메트릭 오버라이드는 “교체 순간의 줄바꿈 변화”를 줄이는 데 유효하지만, 모든 환경에서 동일하게 동작하진 않습니다. 먼저 swap+폴백 최적화로 1차 개선 후, 필요할 때만 추가하세요.
해결 전략 B: 폰트 프리로드(Preload)로 교체 시점 앞당기기
교체를 없애진 못해도, 교체가 빨리 일어나면 사용자 상호작용 전에 안정화되어 CLS가 줄 수 있습니다.
<link
rel="preload"
href="/fonts/Pretendard.woff2"
as="font"
type="font/woff2"
crossorigin
/>
주의할 점:
- 프리로드는 “초기 크리티컬 리소스”에만 제한적으로 사용해야 합니다. 너무 많이 프리로드하면 오히려 초기 로딩이 느려질 수 있습니다.
해결 전략 C: Next.js에서의 실전 적용(next/font)
Next.js를 쓴다면 next/font가 폰트 최적화(서브셋, preload, CSS 주입)를 도와 CLS를 줄이는 데 유리합니다.
// app/layout.tsx (Next.js App Router 예시)
import localFont from "next/font/local";
const pretendard = localFont({
src: [
{ path: "../public/fonts/Pretendard-Regular.woff2", weight: "400" },
{ path: "../public/fonts/Pretendard-Bold.woff2", weight: "700" }
],
display: "swap",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" className={pretendard.className}>
<body>{children}</body>
</html>
);
}
2) 이미지 크기 미지정이 CLS를 만드는 방식과 해결
이미지는 “다운로드 완료 전까지” 브라우저가 실제 픽셀 크기를 모를 수 있습니다. 이때 img가 차지할 공간이 0에 가깝게 계산되었다가, 로딩 후 갑자기 높이가 생기면 아래 요소가 밀리며 CLS가 발생합니다.
해결 전략 A: width/height를 반드시 지정
가장 확실하고 비용이 낮은 해결책입니다. 최신 브라우저는 width/height를 기반으로 **종횡비(aspect ratio)**를 계산해 로딩 전에도 공간을 잡습니다.
<img
src="/images/hero.jpg"
alt="서비스 소개"
width="1200"
height="630"
loading="eager"
/>
CSS로 반응형 처리 시에도 width/height는 “레이아웃 예약” 용도로 유지하고, 표시 크기는 CSS로 조정합니다.
.hero img {
width: 100%;
height: auto;
display: block; /* 이미지 아래 여백/라인박스 이슈 방지 */
}
해결 전략 B: aspect-ratio로 레이아웃 슬롯 고정
이미지 원본 크기를 HTML에 넣기 어렵다면(예: CMS), 최소한 종횡비라도 고정해 공간을 확보합니다.
<div class="thumb">
<img src="/images/card.jpg" alt="카드 이미지" loading="lazy" />
</div>
.thumb {
aspect-ratio: 16 / 9;
width: 100%;
overflow: hidden;
background: #f2f2f2; /* 로딩 중 스켈레톤 역할 */
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
해결 전략 C: Next.js next/image를 올바르게 사용
next/image는 기본적으로 레이아웃 안정화를 돕지만, 사용 방식에 따라 CLS가 생길 수 있습니다.
width/height또는fill+ 부모 컨테이너의 크기 고정이 핵심
import Image from "next/image";
export function Card() {
return (
<div className="media">
<Image
src="/images/card.jpg"
alt="카드"
fill
sizes="(max-width: 768px) 100vw, 33vw"
style={{ objectFit: "cover" }}
priority={false}
/>
</div>
);
}
.media {
position: relative;
aspect-ratio: 16 / 9;
width: 100%;
background: #f2f2f2;
}
3) “상단에 늦게 생기는 UI”가 만드는 CLS: 배너/쿠키/공지
CLS 폭증의 범인은 의외로 이미지가 아니라 상단 고정 영역인 경우가 많습니다.
안티 패턴: 렌더 후에 상단 배너 DOM 삽입
- 초기에는 헤더 높이 64px
- JS로 쿠키 배너를 붙이며 48px 추가
- 전체 페이지가 아래로 밀림 → 큰 CLS
해결: 처음부터 공간을 예약하거나, 오버레이로 띄우기
방법 A) 배너 슬롯을 미리 확보(추천)
<header class="header">
<div class="top-slot" id="top-slot"></div>
<nav class="nav">...</nav>
</header>
.top-slot {
min-height: 48px; /* 배너가 없을 때도 공간은 확보 */
}
.top-slot.is-empty {
min-height: 0;
}
배너가 “없다”는 것이 확정되는 시점(서버에서 쿠키 확인 가능하면 SSR에서)에는 is-empty로 줄여 CLS를 피합니다. 핵심은 클라이언트에서 갑자기 높이가 늘어나지 않게 만드는 것입니다.
방법 B) 레이아웃을 밀지 않는 오버레이
대신 콘텐츠를 가릴 수 있으므로 접근성/UX 고려가 필요합니다.
.cookie-banner {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
}
4) 측정과 재현: “무엇이 밀었는지” 찾아내는 법
CLS는 결과 점수만 보면 원인 추적이 어렵습니다. 다음 순서가 효율적입니다.
- Chrome DevTools → Performance 기록
- “Experience” 섹션에서 Layout Shift 이벤트 확인
- 어떤 노드가 이동했는지, 이동량이 얼마인지 확인
- Lighthouse는 실험실 지표, CrUX는 실사용 지표
- Lighthouse에서 개선돼도 CrUX가 안 내려가면 “특정 디바이스/네트워크에서만” 발생할 수 있음
- RUM(Real User Monitoring)으로 CLS 이벤트 수집
- 페이지별/컴포넌트별로 “어느 화면에서” 폭증하는지 먼저 좁히는 게 중요
간단한 CLS 수집 예시(Web Vitals):
// web-vitals (예: https://github.com/GoogleChrome/web-vitals)
import { onCLS } from "web-vitals";
onCLS((metric) => {
// metric.value: CLS 값
// metric.entries: layout shift entries
navigator.sendBeacon(
"/rum",
JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
url: location.href,
})
);
});
5) 실전 체크리스트(폰트·이미지 중심)
폰트
font-display: swap적용했는가?- 폴백 폰트가 웹폰트와 메트릭이 과도하게 다르지 않은가?
- 핵심 폰트 1~2개만 preload 했는가?
- (Next.js)
next/font로 서브셋/프리로드를 일관되게 관리하는가?
이미지/미디어
- 모든
img에width/height또는aspect-ratio가 있는가? next/image fill을 쓸 때 부모 컨테이너의 크기(특히 높이)가 고정되어 있는가?display:block으로 인라인 이미지의 베이스라인 여백을 제거했는가?- 광고/iframe 슬롯은 최소 높이를 예약했는가?
결론
CLS 폭증은 대부분 “늦게 로딩되는 리소스(폰트/이미지/광고)가 처음에 공간을 예약하지 않았기 때문”에 발생합니다. 해결의 우선순위는 명확합니다.
- 폰트:
font-display: swap+ 폴백 최적화 + 필요 시 preload - 이미지:
width/height지정 또는aspect-ratio로 슬롯 고정 - 상단 UI: 렌더 후 삽입 대신 초기부터 자리 확보
위 3가지만 제대로 적용해도 CLS는 체감되게 내려갑니다. 만약 수정 후에도 특정 페이지에서만 흔들린다면, 렌더링 단계에서 DOM이 바뀌는 문제(하이드레이션/조건부 렌더)가 섞였을 가능성이 있으니 Next.js Hydration Mismatch 5가지 원인과 해결법처럼 “렌더 일관성” 관점도 함께 점검해보세요.