- Published on
CLS 폭증 원인 - 폰트 로딩·이미지 비율 고정 실무
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
CLS(Cumulative Layout Shift)는 “페이지가 로딩되는 동안 요소가 얼마나 많이 밀렸는가”를 수치화합니다. 실무에서 CLS가 폭증하는 순간은 대개 두 갈래로 수렴합니다.
- 웹폰트 로딩으로 텍스트 폭/줄바꿈이 바뀌며 레이아웃이 재배치(FOUT/FOIT)
- 이미지/비디오/광고/임베드의 높이가 처음에 확정되지 않아 뒤늦게 공간이 생기며 콘텐츠가 밀림
특히 성능 최적화를 한다고 폰트를 바꾸거나, 이미지 CDN/리사이징 파이프라인을 손대거나, Next.js/SSR/ISR에서 마크업이 바뀌는 순간 CLS는 “갑자기” 튀기 쉽습니다(어제까지 0.02였는데 오늘 0.25…). 이 글은 폰트 로딩과 이미지 비율 고정이라는 두 축을 중심으로, 원인 규명부터 수정 코드까지 실전적으로 정리합니다.
참고로 배포/캐시 전략 변경으로 HTML/리소스 버전이 섞이면(구버전 HTML + 신버전 CSS/폰트) 레이아웃이 흔들리는 경우도 있습니다. 이런 유형은 Next.js ISR 캐시 꼬임으로 404·구버전 뜰 때 해결처럼 캐시 무결성 관점에서도 함께 점검하는 게 좋습니다.
1) CLS가 폭증하는 전형적인 시나리오
1-1. 폰트: 늦게 로딩된 웹폰트가 줄바꿈을 바꾼다
- 초기 렌더: 시스템 폰트(대체 폰트)로 텍스트가 렌더링
- 폰트 로딩 완료: 실제 폰트로 바뀌면서 글자 폭/자간/행간이 달라짐
- 결과: 제목이 1줄→2줄로 바뀌거나, 버튼 높이가 바뀌며 아래 콘텐츠가 이동
이 변화가 “사용자 입력 후 500ms 이내” 같은 조건을 만족하면(특히 상단 히어로 영역) CLS에 강하게 반영됩니다.
1-2. 이미지/미디어: 높이가 0으로 시작했다가 뒤늦게 확정된다
<img>에width/height가 없고, CSS로도 고정하지 않음- 브라우저는 이미지가 로드되기 전까지 정확한 레이아웃 박스를 계산하기 어렵고, 종종 높이를 0 또는 추정치로 둠
- 이미지 로드 후 높이가 생기며 아래 콘텐츠가 밀림
요즘은 브라우저가 width/height 속성을 활용해 aspect ratio를 계산하므로, “속성만 제대로 넣어도” 많은 CLS가 사라집니다.
1-3. 덜 알려졌지만 자주 만나는 원인
- 상단에 쿠키 배너/앱 설치 배너를 “나중에” 삽입 (DOM 추가로 밀림)
- 스켈레톤 없이 데이터 로딩 후 카드 높이가 크게 변함
- 광고/서드파티 위젯(iframe)이 늦게 리사이즈
:hover가 아닌 로딩 과정에서 폰트/스타일이 바뀌어 reflow 유발
다만 이 글에서는 가장 빈도가 높은 “폰트”와 “이미지 비율 고정”에 초점을 맞춥니다.
2) 진단: 어디서 레이아웃이 밀리는지 빠르게 찾는 법
2-1. Chrome DevTools: Performance + Layout Shifts
- DevTools → Performance
- “Web Vitals” 또는 “Experience” 관련 체크(버전에 따라 다름)
- Record 후 페이지 로드
- Timings/Experience 섹션에서 “Layout Shift” 이벤트 클릭
각 shift 이벤트를 클릭하면 “어떤 요소가 얼마나 이동했는지”가 하이라이트됩니다. 폰트라면 보통 텍스트 블록이, 이미지라면 이미지 아래 콘텐츠가 통째로 내려갑니다.
2-2. Lighthouse/CrUX: 실험실 vs 실제 사용자 데이터 구분
- Lighthouse(실험실): 네트워크/CPU를 고정 조건으로 재현하기 쉬움
- CrUX/필드 데이터: 실제 사용자 환경에서만 터지는 폰트/캐시/서드파티 문제를 잘 보여줌
실무 팁: “특정 라우트에서만” 폭증한다면 그 페이지의 히어로 이미지/카드 리스트/폰트 사용량을 먼저 의심하세요.
2-3. web-vitals로 CLS 이벤트를 로깅하기
필드에서 “언제, 어떤 요소 때문에” CLS가 커지는지 추적하려면 CLS 값을 단순히 보내는 것보다, attribution(원인 요소)을 같이 수집하는 것이 좋습니다.
// npm i web-vitals
import { onCLS } from 'web-vitals';
onCLS((metric) => {
// metric.value: CLS 점수
// metric.entries: LayoutShift 이벤트들
// 최신 브라우저에서는 attribution 정보가 포함되기도 함
console.log('CLS', metric.value, metric);
// 예: analytics로 전송
// sendToAnalytics({ name: metric.name, value: metric.value, id: metric.id });
});
이 로깅을 배포 전후로 비교하면 “폰트 변경 후 특정 페이지에서만” 같은 패턴이 선명해집니다.
3) 폰트 로딩이 만드는 CLS: 원인과 해결책
3-1. 핵심 원리: 폰트 교체 시 메트릭 차이가 레이아웃을 흔든다
웹폰트는 로딩이 끝나기 전까지 브라우저가 대체 폰트를 사용하거나(FOIT/FOUT 정책), 텍스트를 숨겼다가 나타내기도 합니다. 대체 폰트와 실제 폰트의 다음 값이 다르면 레이아웃이 바뀝니다.
- glyph width(글자 폭)
- kerning(자간)
- line-height(행 높이)
따라서 “폰트를 빨리 받게 하거나” + “대체 폰트를 실제 폰트와 비슷하게 맞추거나” + “교체 시 레이아웃 변화를 줄이게” 해야 합니다.
3-2. font-display로 FOIT를 피하되, FOUT로 인한 shift를 최소화
@font-face {
font-family: "MySans";
src: url("/fonts/mysans.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap; /* 텍스트 숨김 방지 */
}
swap: 텍스트는 즉시 보이지만 폰트 교체 시 shift 가능optional: 네트워크가 느리면 웹폰트를 아예 포기하고 시스템 폰트를 유지(shift 감소, 브랜드 폰트 포기 가능)
실무적으로 CLS만 보면 optional이 유리한 경우가 많습니다. 특히 본문 폰트는 optional, 로고/헤딩만 swap 같은 “혼합 전략”이 효과적입니다.
3-3. preload + preconnect로 폰트 도착 시간을 앞당기기
폰트가 늦게 도착하면 “대체 폰트로 그려진 시간”이 길어지고, 교체 순간의 shift가 사용자에게 더 자주 노출됩니다.
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/mysans-400.woff2"
crossorigin
>
주의점
- preload는 “정말 바로 쓰는 폰트”만: 과도하면 초기 대역폭 경쟁으로 오히려 LCP/CLS에 악영향
crossorigin누락 시 폰트가 이중 요청되거나 캐시가 분리될 수 있음
3-4. size-adjust로 대체 폰트와 메트릭을 맞추기(강력)
최근 브라우저는 @font-face의 폰트 메트릭 오버라이드로 대체 폰트를 실제 폰트에 가깝게 맞출 수 있습니다. 이 방법은 “swap을 쓰면서도 shift를 크게 줄이는” 실전 카드입니다.
@font-face {
font-family: "MySans";
src: url("/fonts/mysans.woff2") format("woff2");
font-display: swap;
}
/* 대체 폰트를 실제 폰트 메트릭에 가깝게 보정 */
@font-face {
font-family: "MySans Fallback";
src: local("Arial");
size-adjust: 102%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "MySans", "MySans Fallback", system-ui, -apple-system, sans-serif;
}
- 핵심은 “fallback이 렌더링될 때의 박스”를 실제 폰트와 최대한 맞춰 레이아웃 변화를 줄이는 것
- 값은 폰트마다 달라서 측정/튜닝이 필요하지만, CLS 폭증을 잡는 데 효과가 큽니다
3-5. 폰트 서빙/캐시 문제로 교체 타이밍이 흔들리는 경우
폰트 파일이 S3/CloudFront 같은 외부 스토리지에 있고, 권한/캐시가 꼬여 403/재시도가 발생하면 폰트 로딩이 지연되어 CLS가 더 자주 발생합니다. “폰트가 가끔 늦게 뜬다”는 제보가 있다면 네트워크 탭에서 403/캐시 미스/중복 다운로드를 확인하세요. S3 권한 이슈는 S3 AccessDenied 403 진단 - 버킷 정책·SCP·VPCE 같은 관점으로도 점검할 수 있습니다.
4) 이미지 비율 고정으로 CLS를 없애는 실무 패턴
4-1. 가장 쉬운 해법: <img width height>를 반드시 넣기
브라우저는 width/height를 통해 이미지의 고유 비율(aspect ratio)을 계산하고, 로딩 전에도 레이아웃 공간을 예약할 수 있습니다.
<img
src="/images/hero.jpg"
width="1200"
height="630"
alt="hero"
loading="eager"
fetchpriority="high"
>
포인트
- 실제 픽셀 크기와 동일할 필요는 없지만 “비율”은 정확해야 합니다.
- 반응형은 CSS로 폭을 조절하되, 비율은 속성으로 고정합니다.
img {
max-width: 100%;
height: auto;
display: block; /* inline 이미지 아래 여백으로 인한 흔들림 방지 */
}
4-2. CSS aspect-ratio로 컨테이너 공간을 먼저 확보하기
카드 썸네일처럼 이미지가 동적으로 바뀌거나, <img>가 아니라 background-image를 쓰는 경우 aspect-ratio가 특히 유용합니다.
<div class="thumb">
<img src="/images/item.jpg" alt="item" loading="lazy">
</div>
.thumb {
aspect-ratio: 16 / 9;
width: 100%;
overflow: hidden;
background: #f2f3f5; /* 로딩 중 자리 표시 */
}
.thumb > img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
이 패턴의 장점은 이미지가 늦게 와도 컨테이너 높이는 확정이라 아래 콘텐츠가 밀리지 않는다는 점입니다.
4-3. Next.js를 쓴다면 next/image의 “자리 예약”을 활용
Next.js의 <Image>는 기본적으로 레이아웃 공간을 예약하도록 설계되어 CLS에 강합니다. 다만 잘못 쓰면 다시 흔들릴 수 있습니다.
import Image from 'next/image';
export function Hero() {
return (
<div style={{ maxWidth: 1200 }}>
<Image
src="/images/hero.jpg"
alt="hero"
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, 1200px"
/>
</div>
);
}
주의
fill을 쓸 경우 부모에 명확한 높이/aspect-ratio가 없으면 CLS가 다시 생길 수 있음sizes를 안 주면 잘못된 리소스 선택으로 로딩이 늦어져(간접적으로) shift 노출이 늘 수 있음
4-4. 광고/iframe/임베드: “최대 높이”를 선점하라
서드파티는 로딩 후 자기 높이를 바꾸는 경우가 많습니다. 가장 현실적인 대응은 “예상 높이”를 미리 확보하고, 내부에서만 로딩 상태를 바꾸게 만드는 것입니다.
<div class="ad-slot">
<iframe src="https://ad.example.com" title="ad" loading="lazy"></iframe>
</div>
.ad-slot {
min-height: 250px; /* 예: 300x250 */
background: #f2f3f5;
}
.ad-slot iframe {
width: 100%;
height: 250px;
border: 0;
display: block;
}
“반응형 광고라 높이가 유동적”이라면 브레이크포인트별로 슬롯 높이를 고정하거나, 가능한 범위의 최대치를 확보한 뒤 내부를 스크롤 처리하는 방식도 고려합니다(UX 트레이드오프 존재).
5) 실무 체크리스트: 배포 전에 CLS 폭증을 막는 방법
5-1. 폰트 체크리스트
font-display를 명시했는가? (swap/optional 전략)- 주요 폰트를 preload 했는가? (과도한 preload는 금지)
- 폰트가 cross-origin이면
crossorigin이 일관적인가? - 대체 폰트 메트릭 튜닝(
size-adjust등)을 고려했는가? - 폰트 파일이 간헐적으로 403/timeout/재시도 나지 않는가?
5-2. 이미지/미디어 체크리스트
- 모든
<img>에width/height또는 컨테이너aspect-ratio가 있는가? display:block적용으로 baseline gap을 제거했는가?- lazy-load 이미지가 로딩되며 위 콘텐츠를 밀지 않는가? (보통 아래로 밀림은 덜하지만, 그리드에서 높이 변화가 문제)
fill/background-image 사용 시 부모 높이 고정이 되어 있는가?
5-3. CI에서 회귀 방지(간단한 예)
Lighthouse CI로 CLS 임계치를 걸어두면, 폰트/이미지 변경 PR에서 바로 감지할 수 있습니다.
// lighthouserc.js
module.exports = {
ci: {
collect: {
url: ['http://localhost:3000/'],
startServerCommand: 'npm run start',
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['warn', { minScore: 0.8 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
},
},
},
};
실제 서비스는 페이지별로 임계치를 다르게 두는 편이 현실적입니다(콘텐츠/광고 유무 등).
6) 결론: CLS 폭증은 “늦게 확정되는 레이아웃”을 없애면 끝난다
CLS가 튀는 문제는 복잡해 보이지만, 본질은 단순합니다.
- 폰트는 “교체되더라도 레이아웃이 바뀌지 않게” (preload + 메트릭 보정 + 적절한 display)
- 이미지는 “로드 전에 공간을 예약하게” (
width/height또는aspect-ratio)
여기에 배포/캐시 무결성과(예: ISR/캐시 꼬임) 서드파티 슬롯 높이 선점을 더하면, 대부분의 CLS 폭증은 재발 없이 안정화됩니다. 다음에 CLS가 갑자기 튄다면, DevTools의 Layout Shift 이벤트에서 ‘첫 번째로 크게 밀린 요소’가 폰트인지 이미지인지부터 잡아내고, 위 패턴을 그대로 적용해 보세요.