Published on

CLS 폭증 원인 - 폰트 로딩·이미지 비율 고정 실무

Authors

서론

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

  1. DevTools → Performance
  2. “Web Vitals” 또는 “Experience” 관련 체크(버전에 따라 다름)
  3. Record 후 페이지 로드
  4. 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 이벤트에서 ‘첫 번째로 크게 밀린 요소’가 폰트인지 이미지인지부터 잡아내고, 위 패턴을 그대로 적용해 보세요.