Published on

CLS 튐 해결 - 폰트 로딩·이미지 예약공간 8가지

Authors

서버 성능이 빠른데도 “스크롤하다가 버튼이 도망간다”, “텍스트가 갑자기 커지며 레이아웃이 흔들린다” 같은 피드백을 받는 경우가 있습니다. 대부분은 CLS(Cumulative Layout Shift) 문제입니다. CLS는 로딩 중 요소의 위치가 얼마나 이동했는지 누적해 점수화하며, 특히 폰트 로딩이미지/미디어의 예약공간 미확보가 대표 원인입니다.

이 글에서는 Next.js 같은 현대 프론트엔드 환경에서 바로 적용 가능한 8가지 해결책을 폰트와 이미지 중심으로 정리합니다. (광고/배너, 동적 컴포넌트도 함께 다룹니다.)

CLS를 먼저 “재현 가능하게” 만들기

CLS 최적화는 감으로 하기보다, 원인을 특정하고 재현하는 게 먼저입니다.

1) Chrome DevTools로 Layout Shift 확인

  • DevTools Performance 탭에서 기록 후, Experience 섹션의 Layout Shift 이벤트를 확인합니다.
  • Rendering 패널에서 Layout Shift Regions를 켜면 이동 영역이 하이라이트됩니다.

또는 콘솔에서 간단히 관측할 수도 있습니다.

// 브라우저 콘솔에서 실행
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('layout-shift', {
      value: entry.value,
      hadRecentInput: entry.hadRecentInput,
      sources: entry.sources?.map((s) => s.node)
    });
  }
}).observe({ type: 'layout-shift', buffered: true });

hadRecentInputtrue인 이동(사용자 입력 직후)은 CLS 계산에서 제외되는 경우가 많습니다. 문제는 보통 입력 없이 발생하는 이동입니다.

폰트 로딩으로 인한 CLS: 4가지 처방

웹폰트는 텍스트 렌더링 타이밍과 폴백 폰트의 메트릭 차이 때문에 레이아웃을 흔듭니다. 해결은 “보이는 텍스트의 폭/높이를 안정화”하는 방향으로 접근합니다.

1) font-display 전략을 명시한다

가장 간단하면서 효과적인 1차 처방입니다. 기본값(브라우저/폰트 로더에 따라 다름)은 FOIT(글자 안 보임)나 늦은 스왑으로 이어질 수 있습니다.

  • swap: 즉시 폴백으로 렌더링 후 웹폰트로 교체(대체로 권장)
  • optional: 네트워크가 느리면 웹폰트 스왑을 포기해 CLS를 더 줄임
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: swap;
}

swap은 텍스트가 빨리 보이는 장점이 있지만, 폴백과 웹폰트 폭 차이가 크면 스왑 순간에 CLS가 발생할 수 있습니다. 그래서 다음 처방이 중요합니다.

2) 폴백 폰트 메트릭을 맞춘다(size-adjust 등)

핵심은 폴백 폰트가 웹폰트와 최대한 비슷한 폭/높이를 갖도록 “가짜 폰트”를 정의하는 것입니다. CSS Fonts Level 4의 size-adjust, ascent-override, descent-override, line-gap-override를 이용하면 스왑 시 레이아웃 변화를 크게 줄일 수 있습니다.

@font-face {
  font-family: 'MyFontFallback';
  src: local('Arial');
  size-adjust: 102%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'MyFont', 'MyFontFallback', system-ui, -apple-system, 'Segoe UI', sans-serif;
}

팁:

  • 값은 폰트에 따라 달라서, DevTools로 실제 스왑 전후 줄바꿈/폭 차이를 보고 조정합니다.
  • 특히 제목(H1)처럼 큰 폰트에서 메트릭 차이가 더 크게 체감됩니다.

3) Next.js라면 next/font로 “사전 최적화된 로딩”을 사용한다

Next.js는 next/font를 통해 폰트 파일을 자동 최적화하고, 불필요한 레이아웃 변화를 줄이는 방향으로 도와줍니다. 로딩 타이밍을 예측 가능하게 만들고, 폰트 적용이 일관되게 이뤄지도록 합니다.

// app/layout.tsx
import localFont from 'next/font/local'

const myFont = localFont({
  src: [
    { path: './fonts/MyFont-Regular.woff2', weight: '400', style: 'normal' },
    { path: './fonts/MyFont-Bold.woff2', weight: '700', style: 'normal' },
  ],
  display: 'swap',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body className={myFont.className}>{children}</body>
    </html>
  )
}

display를 명시하고, 필요한 weight만 포함해 폰트 전환 타이밍을 안정화하는 게 포인트입니다.

4) 중요한 폰트만 “선로딩”하고, 과도한 폰트 세트를 줄인다

폰트 파일이 많아질수록 스왑 타이밍이 제각각이 되어 CLS 가능성이 커집니다.

  • 실제로 쓰는 weight만 포함(예: 400, 700만)
  • 서브셋(한글 전체가 필요 없다면 subset 고려)
  • 가장 중요한 폰트 1개만 선로딩
<link rel="preload" href="/fonts/MyFont-Regular.woff2" as="font" type="font/woff2" crossorigin>

주의:

  • 무분별한 preload는 오히려 초기 네트워크 경쟁을 유발합니다.
  • “첫 화면에서 반드시 필요한 폰트”에만 적용하세요.

이미지/미디어 예약공간: 4가지 처방

이미지/비디오/iframe은 로딩 전까지 크기를 모르면, 브라우저가 일단 0 높이로 배치했다가 로딩 후 밀어내며 CLS를 발생시킵니다. 해결은 “처음부터 자리(비율)를 확보”하는 것입니다.

5) 모든 이미지에 width/height 또는 aspect-ratio를 준다

가장 기본이면서 가장 강력한 처방입니다.

<img
  src="/images/hero.jpg"
  width="1200"
  height="630"
  alt="hero"
  loading="eager"
/>

반응형이라면 aspect-ratio로 비율을 고정하고, 실제 렌더 크기는 CSS로 조절합니다.

.hero-img {
  width: 100%;
  height: auto;
  aspect-ratio: 1200 / 630;
  object-fit: cover;
}

aspect-ratio는 레이아웃 예약공간을 만들기 때문에, 이미지가 늦게 로드돼도 아래 콘텐츠가 밀리지 않습니다.

6) Next.js라면 next/image로 예약공간을 자동 확보한다

next/image는 기본적으로 크기 정보를 기반으로 공간을 잡아 CLS를 줄여줍니다.

import Image from 'next/image'

export function Hero() {
  return (
    <Image
      src="/images/hero.jpg"
      alt="hero"
      width={1200}
      height={630}
      priority
    />
  )
}

반응형 레이아웃에서는 fill을 쓰되, 부모에 높이(또는 비율)가 있어야 합니다.

<div className="hero">
  <Image src="/images/hero.jpg" alt="hero" fill sizes="100vw" priority />
</div>
.hero {
  position: relative;
  width: 100%;
  aspect-ratio: 1200 / 630;
}

fill만 쓰고 부모 높이를 안 주면, 로딩 후 높이가 생기면서 CLS가 발생할 수 있습니다.

7) 스켈레톤/플레이스홀더는 “최종 높이와 동일”해야 한다

스켈레톤 UI는 CLS를 줄이기 위한 도구지만, 스켈레톤과 실제 콘텐츠의 높이가 다르면 오히려 튐이 생깁니다.

  • 카드 리스트: 카드 높이를 고정하거나 줄 수를 제한
  • 텍스트: line-height와 줄 수를 맞춤
.card {
  height: 220px; /* 스켈레톤과 실콘텐츠 동일 */
}

.skeleton {
  height: 220px;
  background: #eee;
}

React에서 조건부 렌더링을 할 때도 “없다가 생기는” 방식보다 “자리만 먼저 확보”하는 방식이 안전합니다.

export function ProductCard({ data }: { data?: { title: string } }) {
  return (
    <div className="card">
      {data ? (
        <h3>{data.title}</h3>
      ) : (
        <div className="skeleton" />
      )}
    </div>
  )
}

8) 광고/임베드/동적 위젯은 반드시 고정 슬롯을 만든다

CLS의 숨은 주범은 광고, 추천 위젯, 소셜 임베드, 지도 iframe입니다. 이들은 로딩 후 높이가 확정되며 주변을 밀어냅니다.

해결책은 “슬롯을 고정”하는 것입니다.

<div class="ad-slot">
  <!-- 광고 스크립트가 이 안에 렌더링된다고 가정 -->
</div>
.ad-slot {
  min-height: 250px; /* 예상 높이 */
}

@media (min-width: 1024px) {
  .ad-slot {
    min-height: 280px;
  }
}

정확한 높이를 모르면 min-height로 최악의 경우를 방어하고, 실제 렌더된 높이를 측정해 다음 배포에 반영하는 식으로 점진 개선합니다.

실전 점검 체크리스트

아래 항목을 “첫 화면(Above the fold)”부터 우선 적용하면 체감이 큽니다.

  • 폰트
    • font-display가 명시되어 있는가
    • 폴백 메트릭 조정(size-adjust 등)으로 줄바꿈 변화가 줄었는가
    • 폰트 weight/서브셋이 과도하지 않은가
  • 이미지/미디어
    • 모든 이미지에 width/height 또는 aspect-ratio가 있는가
    • next/image에서 fill 사용 시 부모에 비율/높이가 있는가
    • 스켈레톤이 최종 콘텐츠 높이와 동일한가
    • 광고/iframe 슬롯에 min-height로 예약공간이 있는가

마무리: CLS는 “자리 확보”와 “스왑 최소화”의 합

CLS 튐을 줄이는 핵심은 두 가지로 요약됩니다.

  1. 폰트는 스왑이 일어나도 레이아웃이 바뀌지 않게 메트릭을 맞춘다
  2. 이미지/미디어/광고는 로딩 전에 이미 자리를 확보한다

성능 문제를 다룰 때는 원인별로 재현과 진단 루틴을 갖추는 게 중요합니다. 장애 원인을 체계적으로 쪼개는 접근은 다른 분야에서도 동일하게 통합니다. 예를 들어 배포 파이프라인에서 실패 원인을 단계별로 분리해 해결하는 글로는 GitHub Actions OIDC로 AWS 배포 - AssumeRole 실패 해결이 참고가 되고, 런타임 이슈를 관측과 증거 기반으로 좁혀가는 방식은 Spring Boot OOM 원인추적과 힙덤프 분석 실전과도 결이 같습니다.

다음 단계로는 실제 페이지에서 Layout Shift 이벤트의 sources를 수집해 “어떤 컴포넌트가 몇 점을 만들었는지”를 로그로 남기고, 상위 3개만 잡아도 CLS는 빠르게 안정화됩니다.