Published on

크롬 CLS 튐 잡기 - 폰트·이미지·광고 레이아웃

Authors

서버가 빠르고 JS가 가벼워도, 사용자가 체감하는 품질은 ‘화면이 얼마나 안정적으로 그려지느냐’에 크게 좌우됩니다. 특히 크롬에서 스크롤 중 요소가 갑자기 밀리거나, 버튼이 이동해 오클릭이 발생하는 현상은 대부분 CLS(Cumulative Layout Shift) 때문입니다. CLS는 Core Web Vitals의 핵심 지표라 SEO와 광고 수익(뷰어 체류/클릭)에도 직접 영향을 줍니다.

이 글에서는 폰트 로딩, 이미지/미디어, 광고/동적 영역 3가지가 만드는 CLS를 크롬 DevTools로 정확히 잡아내고, “왜 튀는지 → 어떻게 고정하는지”를 코드로 정리합니다. (운영 이슈를 빠르게 좁혀가는 방식은 systemd 서비스가 자꾸 재시작될 때 7단계 진단처럼, 증상→원인 후보→검증 순으로 접근하면 훨씬 빠릅니다.)

CLS를 먼저 ‘측정 가능한 문제’로 만들기

CLS의 의미(실무 관점)

CLS는 페이지 수명 동안 발생한 예상치 못한 레이아웃 이동의 누적 점수입니다. 실무에서 중요한 포인트는 다음 두 가지입니다.

  • 예상치 못한 이동: 사용자가 클릭/입력 같은 의도적 액션을 하지 않았는데 발생하는 이동
  • 누적: 작은 이동이 여러 번 반복되면 점수가 쌓여서 나빠짐

권장 기준(대략):

  • Good: 0.1 이하
  • Needs improvement: 0.1~0.25
  • Poor: 0.25 이상

크롬 DevTools로 “누가 밀었는지” 찾기

  1. DevTools → Performance
  2. “Web Vitals” 또는 “Experience” 관련 체크(크롬 버전에 따라 UI가 다름)
  3. Record 후 페이지 로드/스크롤
  4. 타임라인에서 Layout Shift 이벤트 클릭
  5. “Affected nodes”로 어떤 요소가 이동했는지 확인

추가로, 개발 중에는 아래처럼 Layout Shift API로 콘솔에 원인을 찍어두면 재현이 쉬워집니다.

<script>
  // 개발/스테이징에서만 사용 권장
  new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 사용자가 입력 중인 경우 제외
      if (entry.hadRecentInput) continue;

      console.log('[CLS]', {
        value: entry.value,
        sources: entry.sources?.map((s) => ({
          node: s.node,
          previousRect: s.previousRect,
          currentRect: s.currentRect,
        }))
      });
    }
  }).observe({ type: 'layout-shift', buffered: true });
</script>

이제부터는 “CLS가 있다”가 아니라 “어떤 요소가 어떤 시점에 어떤 이유로 이동한다”까지 좁혀서 해결할 수 있습니다.

1) 폰트 로딩으로 인한 CLS: FOUT/FOIT와 메트릭 불일치

왜 폰트가 레이아웃을 밀까?

웹폰트는 로딩 전/후에 글꼴 메트릭(자폭, 글자폭, 줄높이)이 달라질 수 있습니다. 초기에는 시스템 폰트로 렌더링되다가(혹은 숨겼다가) 웹폰트가 적용되면서 텍스트 박스 크기가 바뀌면 주변 레이아웃이 이동합니다.

대표 케이스:

  • FOUT(Flash of Unstyled Text): 시스템 폰트 → 웹폰트로 바뀌며 폭이 변해 줄바꿈이 달라짐
  • FOIT(Flash of Invisible Text): 텍스트가 숨겨졌다가 나타나며 주변 요소가 밀림

해결 전략 A: font-display로 렌더링 전략 고정

가장 기본은 @font-facefont-display를 지정해, 브라우저의 폰트 스왑 전략을 통제하는 것입니다.

@font-face {
  font-family: "MyWebFont";
  src: url("/fonts/mywebfont.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap; /* 또는 optional */
}

body {
  font-family: "MyWebFont", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
  • swap: 즉시 대체 폰트로 그린 뒤 웹폰트 로드되면 교체(교체 시 미세 shift 가능)
  • optional: 네트워크 상황이 나쁘면 웹폰트 교체 자체를 포기(shift를 더 줄이는 방향)

해결 전략 B: 폰트 프리로드 + 올바른 CORS

폰트가 늦게 도착할수록 교체 타이밍이 뒤로 밀려 CLS에 불리합니다. 핵심 폰트를 프리로드하면 초기 렌더링 안정성이 좋아집니다.

<link rel="preload" href="/fonts/mywebfont.woff2" as="font" type="font/woff2" crossorigin>

주의:

  • CDN/서브도메인에서 폰트를 받으면 crossorigin과 서버의 CORS 헤더가 맞아야 합니다.

해결 전략 C: 메트릭 호환 폰트/오버라이드로 ‘폭 차이’ 줄이기

대체 폰트와 웹폰트의 폭 차이를 줄이면 스왑이 있어도 레이아웃 이동이 크게 줄어듭니다.

  • 가능하면 메트릭 호환 폰트(예: system-ui 계열)로 폴백 구성
  • CSS font-size-adjust(지원 범위 확인) 또는 폰트 자체의 메트릭을 고려한 설계

텍스트가 큰 영역(헤더/타이틀/카드 리스트)에서 줄바꿈이 바뀌면 CLS가 급증하니, 해당 영역부터 우선 적용하는 것이 효율적입니다.

2) 이미지/미디어 CLS: “자리 예약”이 없어서 생기는 문제

왜 이미지가 밀까?

이미지의 실제 높이를 브라우저가 로딩 전에는 알 수 없으면, 일단 0px 높이로 두었다가 로드 후 높이가 생기면서 아래 콘텐츠를 밀어냅니다. 특히 피드/리스트/카드형 UI에서 흔합니다.

해결 전략 A: width/height로 고정 비율 확보(가장 효과적)

현대 브라우저는 이미지 태그의 width/height를 통해 aspect ratio(종횡비) 를 계산해 자리를 예약합니다.

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

CSS로 반응형 처리하더라도, HTML 속성으로 비율을 알려주는 게 핵심입니다.

img {
  max-width: 100%;
  height: auto;
}

해결 전략 B: 컨테이너에 aspect-ratio로 자리 예약

이미지 소스가 상황에 따라 바뀌거나(동적), <picture>를 쓰는 경우엔 컨테이너에 비율을 박아두면 안정적입니다.

<div class="thumb">
  <img src="/images/thumb.webp" alt="thumb" loading="lazy" decoding="async">
</div>
.thumb {
  aspect-ratio: 16 / 9;
  width: 100%;
  overflow: hidden;
  background: #f2f2f2; /* 로딩 중 배경 */
}

.thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

해결 전략 C: Lazy-load는 하되, “높이는 먼저”

loading="lazy" 자체가 CLS를 만들진 않지만, lazy 대상 요소가 뷰포트 근처에 등장할 때 자리 예약이 없으면 그 순간 밀립니다.

  • Lazy-load를 쓰더라도 width/height 또는 aspect-ratio로 공간을 선점
  • 무한 스크롤/피드에서는 특히 필수

흔한 함정: 스켈레톤/로딩 UI가 실제 콘텐츠와 높이가 다름

스켈레톤을 넣었는데도 CLS가 발생한다면, 대개 스켈레톤의 높이와 실제 카드 높이가 다릅니다. 스켈레톤은 “대충 회색 박스”가 아니라 최종 레이아웃과 동일한 박스 모델이어야 합니다.

3) 광고/동적 위젯 CLS: 가장 자주, 가장 크게 튄다

광고는 CLS의 주범입니다. 이유는 간단합니다.

  • 광고 스크립트 로드 타이밍이 늦음
  • 광고 소재 크기가 고정이 아님(반응형/리치미디어)
  • 빈 슬롯 → 광고가 채워지며 높이가 변함

해결 전략 A: 광고 슬롯에 “최대 높이”를 미리 예약

가장 실전적인 해결책은 광고 컨테이너에 고정 높이(또는 최소 높이) 를 먼저 주는 것입니다.

<div class="ad-slot ad-300x250" id="ad-1"></div>
.ad-slot {
  width: 100%;
  display: block;
  overflow: hidden;
  background: #fafafa;
}

/* 대표 사이즈를 슬롯별로 고정 */
.ad-300x250 { min-height: 250px; }
.ad-728x90  { min-height: 90px; }

/* 모바일/데스크톱 분기 시 미디어쿼리로 제어 */
@media (max-width: 768px) {
  .ad-728x90 { min-height: 100px; } /* 모바일용으로 다른 규격이면 맞춰 예약 */
}

포인트:

  • “광고가 안 뜨면 빈 공간이 생기지 않나?” → 맞습니다. 하지만 UX/SEO 관점에서 레이아웃 안정성이 우선인 페이지(콘텐츠/검색 유입 랜딩)에서는 빈 공간이 더 낫습니다.
  • 빈 공간이 싫다면, 아래 전략 B를 같이 씁니다.

해결 전략 B: 광고가 없으면 슬롯을 접되, ‘초기 렌더 후’가 아니라 ‘결정된 시점’에만

광고 로드 후 실패를 감지하고 슬롯을 접을 때도 CLS가 발생할 수 있습니다. 그래서 다음 원칙이 중요합니다.

  • 접을 거면 초기부터 접힌 상태로 시작하거나
  • “광고 유무”를 가능한 한 빨리 판단(서버/헤더/실험 플래그)해서 첫 페인트 전에 결정

클라이언트에서 타임아웃 후 접는 패턴은 대부분 CLS를 유발합니다.

해결 전략 C: 동적 삽입은 transform/오버레이로 처리(레이아웃에 영향 최소화)

쿠키 배너, 프로모션 바, 추천 위젯을 상단에 삽입하면 본문이 통째로 밀립니다. 가능하면 레이아웃을 밀지 말고 오버레이로 띄우거나, 이미 예약된 영역 안에서만 변화시키세요.

.promo-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  transform: translateY(0);
  will-change: transform;
}

/* 등장/퇴장은 transform으로(레이아웃 영향 없음) */
.promo-bar.is-hidden {
  transform: translateY(110%);
}

단, fixed 배너가 콘텐츠를 가리면 또 다른 UX 문제가 생기니, 안전 영역(inset)과 닫기 버튼, 노출 빈도 제어가 필요합니다.

재현 → 수정 → 회귀 방지: 실무 체크리스트

1) “어느 페이지에서”가 아니라 “어느 컴포넌트에서”로 쪼개기

CLS는 페이지 단위로 보면 막연합니다. DevTools의 Layout Shift 이벤트로 원인 노드를 특정한 뒤, 컴포넌트 단위로 수정하세요.

2) 변경 후 Lighthouse만 보지 말고 필드 데이터도 확인

  • Lighthouse(랩 데이터): 재현과 개선 확인에 좋음
  • CrUX/GA/Real User Monitoring(필드 데이터): 실제 사용자 환경 반영

3) 회귀 방지: 레이아웃 예약을 ‘코딩 규칙’으로 만들기

팀 규칙 예시:

  • 이미지: img는 원칙적으로 width/height 필수
  • 카드 썸네일: 컨테이너 aspect-ratio 필수
  • 광고: 슬롯별 min-height 필수
  • 웹폰트: font-display + 핵심 폰트 preload

TypeScript를 쓴다면, 이미지 컴포넌트 props에 width/height를 강제하는 식으로 “실수 자체를 불가능하게” 만드는 방식이 효과적입니다. (타입으로 실수를 막는 접근은 TypeScript 5.5+ noUncheckedIndexedAccess 오류 실전해결 같은 글에서 다룬 방식과 유사합니다.)

Next.js/React 환경에서 자주 터지는 CLS 패턴

1) 클라이언트에서만 렌더되는 컴포넌트가 상단을 밀어버림

예: 로그인 상태 배너, AB 테스트 위젯, 추천 영역 등. SSR/SSG로 초기 마크업에 포함하거나, 최소한 초기 높이를 고정하세요.

2) 이미지 컴포넌트 래퍼가 레이아웃을 바꿈

Next.js의 <Image>를 쓰더라도, 외부 래퍼 div의 스타일(특히 display, position, line-height)이 바뀌면 shift가 납니다. “이미지 자체”뿐 아니라 이미지를 담는 박스 모델을 고정하세요.

3) 스트리밍/서스펜스 경계에서 fallback 높이가 다름

RSC/Streaming 환경에서는 fallback UI가 실제 UI와 높이가 달라 CLS가 생길 수 있습니다. fallback을 “작게” 만드는 게 능사가 아니라, 최종 레이아웃과 동일한 그리드로 맞추는 게 핵심입니다. 캐시/스트리밍으로 UI가 예상과 다르게 바뀌는 문제를 겪는다면 Next.js 15 RSC 캐시로 stale UI 뜰 때 해결법도 함께 점검하면 원인 분리에 도움이 됩니다.

마무리: CLS는 ‘속도’가 아니라 ‘레이아웃 계약’ 문제다

CLS를 줄이는 가장 강력한 원칙은 하나로 요약됩니다.

  • 늦게 로드되는 모든 것(폰트/이미지/광고/위젯)은, 도착하기 전에 이미 자리를 갖고 있어야 한다.

폰트는 스왑 전략과 메트릭 차이를 줄이고, 이미지는 종횡비로 공간을 예약하고, 광고는 슬롯 크기를 설계 단계에서 고정하세요. DevTools로 “누가 밀었는지”를 찾고, 컴포넌트 규칙으로 회귀를 막으면 크롬에서 보이는 ‘툭툭 튐’은 대부분 사라집니다.