CLS 완벽 가이드: 레이아웃 시프트 원인 분석과 최적화 전략 (2026)

CLS(Cumulative Layout Shift) 최적화 가이드. 이미지 사이즈 고정, 웹폰트 size-adjust, 광고 슬롯 공간 확보, CSS containment, bfcache까지 2026년 기준 실무에서 바로 쓸 수 있는 레이아웃 안정성 개선 기법을 총정리합니다.

웹사이트를 스크롤하다가 갑자기 콘텐츠가 아래로 밀려나면서 엉뚱한 버튼을 클릭한 경험, 다들 한 번쯤은 있으시죠? 뉴스 기사를 읽고 있는데 광고가 느닷없이 끼어들면서 텍스트가 확 밀려나거나, 결제 버튼을 누르려다가 레이아웃이 바뀌면서 "취소" 버튼을 누르게 되는… 솔직히 이런 경험은 정말 최악입니다. 이런 시각적 불안정성을 측정하는 지표가 바로 CLS(Cumulative Layout Shift)입니다.

앞선 가이드에서 INP(응답성)와 LCP(로딩 성능)를 다뤘으니, 이번에는 Core Web Vitals의 세 번째 축인 CLS를 깊이 파헤쳐 보겠습니다. 2026년 현재, CLS는 Google 검색 순위에 직접 영향을 미칠 뿐 아니라 사용자 경험과 전환율에도 엄청난 영향을 줍니다.

레이아웃 시프트가 많은 페이지에서 사용자의 이탈률이 최대 40%까지 증가한다는 연구 결과도 있을 정도니까요.

CLS란 정확히 무엇인가

CLS(Cumulative Layout Shift)는 페이지의 전체 수명 동안 발생하는 예기치 않은 레이아웃 이동의 누적 점수를 측정합니다. 여기서 핵심 단어는 "예기치 않은(unexpected)"인데요. 사용자가 버튼을 클릭해서 드롭다운이 열리거나, 탭을 눌러서 콘텐츠가 바뀌는 건 예상된 이동이라 CLS에 포함되지 않습니다. 구체적으로, 사용자 상호작용 후 500ms 이내에 발생한 레이아웃 이동은 CLS에서 제외됩니다.

Google이 정의한 CLS 임계값은 다음과 같습니다:

  • 좋음(Good): 0.1 이하
  • 개선 필요(Needs Improvement): 0.1 ~ 0.25
  • 나쁨(Poor): 0.25 초과

다른 Core Web Vitals와 마찬가지로, 페이지 방문의 75번째 백분위수(75th percentile) 기준으로 평가합니다. 즉, 방문자의 75%가 0.1 이하의 CLS를 경험해야 "좋음" 등급을 받을 수 있어요.

CLS 점수는 어떻게 계산되는가

CLS 점수를 제대로 이해하려면 먼저 두 가지 개념을 알아야 합니다. 영향 분율(Impact Fraction)거리 분율(Distance Fraction)이죠.

영향 분율 (Impact Fraction)

영향 분율은 불안정한 요소가 두 프레임 사이에서 뷰포트에 미치는 영향을 측정합니다. 이전 프레임과 현재 프레임에서 해당 요소가 차지하는 뷰포트 영역의 합집합이 영향 분율이 되는 건데요. 예를 들어, 뷰포트의 50%를 차지하는 요소가 아래로 25% 이동하면, 이전 위치(50%)와 새 위치의 합집합이 뷰포트의 75%이므로 영향 분율은 0.75가 됩니다.

거리 분율 (Distance Fraction)

거리 분율은 불안정한 요소가 뷰포트 대비 이동한 가장 큰 거리를 측정합니다. 위 예시에서 요소가 뷰포트의 25%만큼 아래로 이동했으니 거리 분율은 0.25입니다.

레이아웃 시프트 점수 계산

개별 레이아웃 시프트 점수는 영향 분율과 거리 분율의 곱입니다:

레이아웃 시프트 점수 = 영향 분율 × 거리 분율
예시: 0.75 × 0.25 = 0.1875

그런데 CLS는 단순히 모든 시프트를 합산하는 게 아닙니다. 페이지의 전체 수명 동안 발생한 모든 예기치 않은 레이아웃 시프트 점수 중, 세션 윈도우를 기반으로 가장 큰 버스트를 취합합니다. 세션 윈도우란 연속된 레이아웃 시프트들의 묶음으로, 각 시프트 사이 간격이 1초 이내이고 전체 윈도우 길이가 최대 5초인 구간을 말해요. 이 중 점수 합이 가장 큰 세션 윈도우의 합산값이 최종 CLS 점수가 됩니다.

이 세션 윈도우 방식은 2021년에 도입됐는데, SPA(Single Page Application)처럼 오래 열어두는 페이지에서 시간이 지남에 따라 CLS가 무한히 누적되는 문제를 해결하기 위한 것이었습니다. 꽤 영리한 접근이죠.

CLS를 발생시키는 주요 원인들

CLS를 효과적으로 줄이려면 원인부터 정확히 이해해야 합니다. 자, 그럼 실무에서 가장 흔하게 마주치는 원인들을 하나씩 살펴보겠습니다.

1. 크기가 지정되지 않은 이미지와 미디어

이건 CLS의 1등 원인입니다. 전체 레이아웃 시프트의 약 60%가 여기서 발생해요. 이미지에 width와 height 속성을 지정하지 않으면, 브라우저는 이미지가 다운로드될 때까지 해당 요소의 크기를 알 수 없습니다. 그래서 처음에는 0×0 크기로 렌더링한 뒤, 이미지가 로드되면 실제 크기로 확장하면서 아래 콘텐츠를 밀어내죠.

2. 웹폰트로 인한 텍스트 리플로우

웹폰트가 로드되기 전에 시스템 폴백 폰트로 텍스트가 먼저 렌더링되고, 웹폰트 다운로드가 완료되면 폰트가 교체됩니다. 이때 두 폰트의 크기 차이 때문에 텍스트가 리플로우되면서 레이아웃 시프트가 발생하는데, 이를 FOUT(Flash of Unstyled Text)라고 합니다. CLS 문제의 약 25%를 차지하는 꽤 비중 있는 원인이에요.

3. 동적으로 삽입되는 콘텐츠

광고 배너, 쿠키 동의 팝업, 뉴스레터 구독 폼 같은 요소가 DOM에 동적으로 삽입되면서 기존 콘텐츠를 밀어내는 경우입니다. 특히 광고는 사이즈가 예측 불가능하고 로딩 시점도 일정하지 않아서 CLS의 주범이 되곤 하죠.

4. 늦게 적용되는 CSS

클라이언트 사이드 렌더링(CSR) 애플리케이션에서 JavaScript가 실행된 후에야 CSS가 적용되는 경우, 초기 렌더링과 최종 렌더링 사이에 레이아웃 차이가 생깁니다. CSS-in-JS 라이브러리를 사용할 때 서버 사이드 렌더링을 설정하지 않으면 이 문제가 특히 두드러집니다.

5. 비동기 컴포넌트와 지연 로딩

React의 lazy loading이나 Vue의 비동기 컴포넌트처럼, JavaScript 번들이 필요한 시점에 로드되는 패턴도 CLS를 유발할 수 있습니다. 컴포넌트가 로드되기 전에는 빈 공간이었다가 로드 후 콘텐츠가 나타나면서 레이아웃이 변하기 때문이에요.

이미지와 미디어 CLS 최적화

가장 큰 원인부터 잡아야겠죠. 좋은 소식은, 이미지 관련 CLS 문제는 대부분 간단한 방법으로 해결할 수 있다는 점입니다.

width와 height 속성 항상 명시하기

가장 기본적이면서도 가장 효과적인 방법입니다. HTML에 width와 height를 명시하면, 브라우저가 이미지가 로드되기 전에도 올바른 가로세로 비율(aspect ratio)을 계산해서 공간을 미리 확보합니다.

<!-- 나쁜 예: width/height 없음 - CLS 발생 -->
<img src="/hero.jpg" alt="히어로 이미지">

<!-- 좋은 예: width/height 명시 -->
<img 
  src="/hero.jpg" 
  alt="히어로 이미지"
  width="1200" 
  height="600"
>

<!-- 반응형에서도 CSS와 함께 사용 -->
<style>
img {
  max-width: 100%;
  height: auto;
}
</style>
<img 
  src="/hero.jpg" 
  alt="히어로 이미지"
  width="1200" 
  height="600"
>

width="1200" height="600"을 명시하면, CSS에서 max-width: 100%; height: auto;를 적용해도 브라우저는 2:1 비율을 유지하면서 반응형으로 이미지를 표시합니다. 이미지가 로드되기 전에도 정확한 공간이 확보되므로 CLS가 0이 됩니다. 이렇게 간단한데 효과는 극적이죠.

CSS aspect-ratio로 비율 고정하기

최신 브라우저들은 img 요소의 width/height 속성을 보고 자동으로 aspect-ratio를 설정하지만, 더 명시적으로 제어하고 싶거나 컨테이너에 비율을 적용하고 싶을 때는 CSS aspect-ratio 속성을 직접 사용할 수 있습니다.

/* 이미지 컨테이너에 비율 고정 */
.hero-image-container {
  aspect-ratio: 16 / 9;
  width: 100%;
  overflow: hidden;
}

.hero-image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* 비디오 임베드에도 활용 */
.video-wrapper {
  aspect-ratio: 16 / 9;
  width: 100%;
}

.video-wrapper iframe {
  width: 100%;
  height: 100%;
}

이 방식은 반응형 비디오 임베드에 특히 유용합니다. 과거에는 padding-top 핵(padding-top: 56.25%)을 사용했었는데, 이제는 aspect-ratio로 훨씬 깔끔하게 처리할 수 있어요. 2026년 기준 모든 주요 브라우저가 이 속성을 지원하니 걱정 없이 쓰셔도 됩니다.

반응형 이미지와 srcset

다양한 화면 크기에 최적화된 이미지를 제공하면서도 CLS를 방지하는 방법입니다:

<img 
  src="/images/article-800.avif"
  srcset="
    /images/article-400.avif 400w,
    /images/article-800.avif 800w,
    /images/article-1200.avif 1200w,
    /images/article-1600.avif 1600w
  "
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 75vw, 800px"
  width="1200"
  height="800"
  alt="기사 이미지"
  loading="lazy"
  decoding="async"
>

핵심은 srcset과 sizes를 쓰더라도 반드시 width와 height를 기본 이미지 기준으로 명시하는 것입니다. 이 비율 정보가 있어야 브라우저가 이미지 로드 전에 올바른 공간을 확보할 수 있거든요.

웹폰트 CLS 최적화

웹폰트로 인한 레이아웃 시프트는 이미지 다음으로 흔한 CLS 원인입니다. 다행히 2026년에는 이 문제를 해결하기 위한 도구와 기법이 충분히 성숙해졌습니다.

font-display 전략

font-display 속성은 웹폰트 로딩 중 텍스트를 어떻게 표시할지 결정합니다. CLS 관점에서 각 옵션을 한번 비교해 볼까요:

  • font-display: swap — 폴백 폰트를 즉시 보여주고, 웹폰트 로드 완료 시 교체합니다. 텍스트가 빠르게 보이지만, 폰트 교체 시 레이아웃 시프트가 발생할 수 있습니다.
  • font-display: optional — 매우 짧은 차단 기간 후, 웹폰트가 이미 캐시에 있으면 사용하고 없으면 폴백 폰트를 유지합니다. CLS에 가장 유리한 옵션이에요. 첫 방문에서는 폴백 폰트만 보일 수 있지만, 레이아웃 시프트는 발생하지 않습니다.
  • font-display: fallback — 짧은 차단(약 100ms) 후 폴백을 보여주고, 약 3초 이내에 웹폰트가 로드되면 교체합니다. swap과 optional의 중간 정도 전략입니다.
/* CLS를 최소화하려면 optional을 고려 */
@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/Pretendard-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: optional;
}

/* swap을 쓴다면 size-adjust로 보완 */
@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/Pretendard-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

size-adjust와 폰트 메트릭 오버라이드

font-display: swap을 사용하면서도 CLS를 최소화하고 싶다면, 폴백 폰트의 메트릭을 웹폰트에 맞추는 방법이 있습니다. CSS의 size-adjust, ascent-override, descent-override, line-gap-override 속성을 활용하는 건데요.

/* 폴백 폰트의 메트릭을 웹폰트에 맞춤 */
@font-face {
  font-family: 'Pretendard Fallback';
  src: local('Apple SD Gothic Neo'), local('Malgun Gothic');
  size-adjust: 99.2%;
  ascent-override: 105%;
  descent-override: 22%;
  line-gap-override: 0%;
}

body {
  font-family: 'Pretendard', 'Pretendard Fallback', sans-serif;
}

이렇게 하면 폴백 폰트(Apple SD Gothic Neo 또는 맑은 고딕)의 크기와 높이를 Pretendard에 맞춰 조정하기 때문에, 폰트가 교체될 때 텍스트가 차지하는 공간이 거의 변하지 않습니다. 결과적으로 CLS가 크게 줄어들어요.

이 수치를 직접 계산하기 어렵다면 (솔직히 꽤 번거롭습니다), fontaine이나 @next/font 같은 도구가 자동으로 최적 값을 계산해 줍니다. Next.js를 사용한다면 next/font가 이 작업을 알아서 처리해주니 참 편하죠.

웹폰트 preload

웹폰트의 발견 시점을 앞당기면 폰트 교체로 인한 시프트가 줄어들거나 아예 없어질 수 있습니다.

<head>
  <link 
    rel="preload" 
    href="/fonts/Pretendard-Regular.woff2" 
    as="font" 
    type="font/woff2" 
    crossorigin="anonymous"
  >
  <link 
    rel="preload" 
    href="/fonts/Pretendard-Bold.woff2" 
    as="font" 
    type="font/woff2" 
    crossorigin="anonymous"
  >
</head>

단, preload할 폰트 수는 꼭 필요한 2~3개로 제한하세요. 모든 weight를 preload하면 초기 로드 시 대역폭 경쟁이 발생해서 오히려 역효과가 날 수 있습니다.

광고와 동적 콘텐츠 CLS 최적화

광고는 솔직히 CLS의 가장 다루기 까다로운 원인입니다. 광고 사이즈가 가변적이고, 로딩 시점을 완벽하게 제어하기 어렵기 때문이죠. 하지만 전략적으로 접근하면 상당 부분 해결할 수 있습니다.

광고 슬롯에 최소 높이 확보하기

가장 기본적인 전략은 광고 슬롯에 미리 공간을 확보하는 것입니다.

/* 광고 슬롯에 최소 크기 할당 */
.ad-slot-leaderboard {
  min-height: 90px;    /* 표준 리더보드 광고 높이 */
  min-width: 728px;
  background-color: #f5f5f5; /* 빈 공간이라도 자연스럽게 */
  display: flex;
  align-items: center;
  justify-content: center;
}

.ad-slot-rectangle {
  min-height: 250px;   /* 미디엄 렉탱글 */
  min-width: 300px;
  background-color: #f5f5f5;
}

/* 반응형 처리 */
@media (max-width: 768px) {
  .ad-slot-leaderboard {
    min-height: 50px;   /* 모바일 배너 */
    min-width: 320px;
  }
}

쿠키 동의 배너와 알림

쿠키 동의 배너는 CLS에 생각보다 큰 영향을 줄 수 있습니다. 위치와 스타일 설정이 중요해요:

/* CLS를 피하는 쿠키 배너 스타일 */
.cookie-consent {
  position: fixed;          /* 고정 위치 사용 */
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 9999;
  transform: translateY(100%);
  transition: transform 0.3s ease;
}

.cookie-consent.visible {
  transform: translateY(0);
}

position: fixed를 사용하면 요소가 일반 문서 흐름에서 빠지므로 다른 콘텐츠를 밀어내지 않습니다. 페이지 상단에 삽입하면서 position: static으로 하면 아래 콘텐츠 전체가 밀려나면서 큰 CLS를 유발하니 주의하세요.

동적 콘텐츠를 위한 컨테이너 전략

API에서 가져온 데이터를 보여주는 동적 컴포넌트는 스켈레톤 UI를 활용해서 공간을 미리 확보하는 게 좋습니다:

<!-- 스켈레톤 UI 예시 -->
<div class="product-card" style="min-height: 380px;">
  <div class="skeleton-image" style="aspect-ratio: 4/3;"></div>
  <div class="skeleton-text" style="height: 1.2em; width: 80%;"></div>
  <div class="skeleton-text" style="height: 1em; width: 60%;"></div>
</div>

<style>
.skeleton-image,
.skeleton-text {
  background: linear-gradient(
    90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>

스켈레톤 UI는 두 가지 역할을 동시에 합니다. 첫째, 실제 콘텐츠와 비슷한 크기의 공간을 미리 확보해서 CLS를 방지합니다. 둘째, 사용자에게 "콘텐츠를 불러오고 있다"는 시각적 피드백을 줘서 체감 성능도 개선해줍니다. 일석이조인 셈이죠.

CSS 기법으로 CLS 방지하기

CSS만으로도 CLS를 상당히 줄일 수 있는 기법들이 있습니다. 제가 실무에서 자주 사용하는 것들 위주로 정리해봤습니다.

CSS Containment과 content-visibility

content-visibility: auto는 화면 밖 요소의 렌더링을 건너뛰어 초기 렌더링 속도를 크게 높여주는 속성입니다. 하지만 주의하지 않으면 오히려 CLS를 유발할 수 있어요. 요소가 뷰포트에 진입할 때 높이가 0에서 실제 높이로 바뀌면서 레이아웃 시프트가 발생하기 때문입니다.

이를 해결하려면 반드시 contain-intrinsic-size를 함께 사용해야 합니다:

/* content-visibility + contain-intrinsic-size 조합 */
.article-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px;  /* 대략적인 높이 지정 */
}

/* 카드 리스트 항목에 적용 */
.product-card {
  content-visibility: auto;
  contain-intrinsic-size: auto 380px;
}

/* 댓글 영역처럼 화면 아래에 있는 콘텐츠 */
.comments-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 2000px;
}

contain-intrinsic-size: auto 500px에서 auto 키워드는 브라우저가 요소를 한 번 렌더링한 후 실제 크기를 기억하게 합니다. 페이지를 스크롤했다가 다시 올라올 때 저장된 크기를 사용하므로 추가 시프트가 발생하지 않아요.

transform 기반 애니메이션 사용하기

레이아웃에 영향을 주는 속성(top, left, width, height, margin 등)을 애니메이션하면 CLS가 발생합니다. 대신 transformopacity를 사용하면 합성(compositing) 레이어에서 처리되므로 레이아웃 시프트가 발생하지 않죠.

/* 나쁜 예: 레이아웃에 영향을 주는 애니메이션 */
.notification-bad {
  transition: top 0.3s ease;
  top: -100px;
}
.notification-bad.show {
  top: 20px;   /* 레이아웃 시프트 발생! */
}

/* 좋은 예: transform 사용 */
.notification-good {
  position: fixed;
  top: 20px;
  transform: translateY(-150%);
  transition: transform 0.3s ease;
}
.notification-good.show {
  transform: translateY(0);  /* CLS 없음 */
}

will-change와 CSS contain

자주 변하는 요소에 will-changecontain을 적절히 설정하면 브라우저가 해당 요소를 별도 레이어로 관리하고, 변경이 다른 요소에 영향을 미치는 것을 방지합니다:

/* 자주 변하는 요소를 격리 */
.animated-element {
  will-change: transform;
  contain: layout style;
}

/* 독립적인 위젯 컨테이너 */
.widget-container {
  contain: layout style paint;
  overflow: hidden;
}

SPA에서의 CLS 최적화

Single Page Application은 전통적인 MPA보다 CLS 문제가 확실히 더 복잡합니다. 클라이언트 사이드 라우팅, 동적 데이터 페칭, 코드 스플리팅이 결합되면서 다양한 시프트 패턴이 나타나거든요.

클라이언트 사이드 라우팅 시 CLS 관리

SPA에서 페이지 전환 시 이전 콘텐츠가 사라지고 새 콘텐츠가 로드되면서 빈 화면이 깜빡이거나 레이아웃이 흔들릴 수 있습니다. 다음과 같은 전략으로 해결할 수 있어요:

// React에서 페이지 전환 시 최소 높이 유지
function PageWrapper({ children }) {
  const [minHeight, setMinHeight] = useState(0);
  const contentRef = useRef(null);

  useEffect(() => {
    if (contentRef.current) {
      const height = contentRef.current.offsetHeight;
      setMinHeight(height);
    }
  }, []);

  return (
    <div 
      ref={contentRef}
      style={{ minHeight: minHeight ? `${minHeight}px` : 'auto' }}
    >
      {children}
    </div>
  );
}

// View Transitions API를 활용한 부드러운 전환
function navigateTo(url) {
  if (document.startViewTransition) {
    document.startViewTransition(() => {
      updateDOM(url);
    });
  } else {
    updateDOM(url);
  }
}

Suspense와 스켈레톤 UI 결합

React의 Suspense와 함께 스켈레톤 UI를 사용하면, 비동기 컴포넌트가 로드되는 동안 동일한 크기의 플레이스홀더를 보여줄 수 있습니다:

import { Suspense, lazy } from 'react';

const ProductList = lazy(() => import('./ProductList'));

function ProductPage() {
  return (
    <div>
      <h1>상품 목록</h1>
      <Suspense fallback={<ProductListSkeleton />}>
        <ProductList />
      </Suspense>
    </div>
  );
}

// 실제 컴포넌트와 동일한 레이아웃의 스켈레톤
function ProductListSkeleton() {
  return (
    <div className="product-grid" style={{ minHeight: '800px' }}>
      {Array.from({ length: 8 }).map((_, i) => (
        <div key={i} className="product-card-skeleton"
          style={{ height: '380px' }}>
          <div className="skeleton-pulse" 
            style={{ aspectRatio: '4/3' }} />
          <div className="skeleton-pulse" 
            style={{ height: '20px', margin: '12px 0' }} />
          <div className="skeleton-pulse" 
            style={{ height: '16px', width: '60%' }} />
        </div>
      ))}
    </div>
  );
}

CLS 측정과 디버깅

CLS를 효과적으로 최적화하려면 정확한 측정과 디버깅이 필수입니다. 어떤 요소가 얼마나 이동했는지, 언제 발생했는지를 정확히 파악해야 하죠.

Layout Instability API로 실시간 모니터링

프로덕션 환경에서 CLS를 추적하는 가장 강력한 방법은 Layout Instability API를 사용하는 것입니다:

// 레이아웃 시프트를 실시간으로 추적
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // 사용자 입력에 의한 시프트는 무시
    if (entry.hadRecentInput) continue;
    
    console.log('Layout shift detected:', {
      value: entry.value,
      startTime: entry.startTime,
      sources: entry.sources?.map(source => ({
        element: source.node?.nodeName,
        previousRect: source.previousRect,
        currentRect: source.currentRect
      }))
    });
  }
});

observer.observe({ type: 'layout-shift', buffered: true });

entry.sources 배열은 레이아웃 시프트에 기여한 요소들을 영향 면적 순으로 제공합니다. previousRectcurrentRect를 비교하면 요소가 정확히 얼마나 이동했는지 알 수 있어요.

web-vitals 라이브러리의 CLS 어트리뷰션

구체적인 디버깅 정보가 필요할 때는 web-vitals 라이브러리의 어트리뷰션 빌드를 사용하는 걸 추천합니다:

import { onCLS } from 'web-vitals/attribution';

onCLS((metric) => {
  if (metric.rating !== 'good') {
    const attribution = metric.attribution;
    
    console.log('CLS 문제 감지:', {
      score: metric.value,
      rating: metric.rating,
      largestShiftTarget: attribution.largestShiftTarget,
      largestShiftValue: attribution.largestShiftValue,
      largestShiftTime: attribution.largestShiftTime,
      loadState: attribution.loadState
    });
    
    // 분석 서버로 전송
    navigator.sendBeacon('/api/cls-debug', JSON.stringify({
      url: window.location.href,
      cls: metric.value,
      target: attribution.largestShiftTarget,
      loadState: attribution.loadState,
      timestamp: Date.now()
    }));
  }
});

loadState 필드는 시프트가 발생한 시점의 페이지 로드 상태를 알려주는데, 이게 상당히 유용합니다. "loading" 중인지, "dom-interactive" 상태인지, "dom-content-loaded" 상태인지에 따라 최적화 방향이 완전히 달라지거든요.

Chrome DevTools로 시각적 디버깅

Chrome DevTools의 Performance 패널은 CLS를 시각적으로 디버깅하는 가장 좋은 도구입니다:

  1. DevTools를 열고 Performance 탭으로 이동합니다.
  2. 녹화를 시작하고 페이지를 리로드합니다.
  3. 녹화를 멈추면 타임라인에서 "Layout Shift" 항목을 확인할 수 있습니다.
  4. 각 시프트를 클릭하면 어떤 요소가 얼마나 이동했는지 시각적으로 보여줍니다.

2026년 현재 Chrome DevTools의 Performance 패널에는 AI 지원 기능도 추가되어서, 성능 문제의 원인을 한결 쉽게 분석할 수 있습니다. 또한 CrUX의 실제 사용자 데이터가 직접 통합되어 필드 데이터와 랩 데이터를 한 화면에서 비교할 수 있게 됐어요.

bfcache 최적화로 CLS 제거하기

bfcache(back/forward cache)는 CLS를 줄이는 데 굉장히 효과적인 전략입니다. bfcache는 사용자가 다른 페이지로 이동할 때 현재 페이지의 전체 스냅샷을 메모리에 저장하고, 뒤로가기나 앞으로가기를 할 때 즉시 복원합니다.

완전히 렌더링된 상태가 복원되므로 레이아웃 시프트가 전혀 발생하지 않습니다. 사실 이거 하나만 제대로 해도 네비게이션 관련 CLS는 거의 사라집니다.

bfcache 호환성 확인

bfcache를 무효화하는 흔한 원인들과 해결책을 정리해봤습니다:

// 나쁜 예: unload 이벤트는 bfcache를 무효화
window.addEventListener('unload', () => {
  // 이 핸들러가 있으면 bfcache 사용 불가!
  sendAnalytics();
});

// 좋은 예: pagehide 사용
window.addEventListener('pagehide', (event) => {
  if (!event.persisted) {
    // 페이지가 정말로 종료될 때만 실행
    sendAnalytics();
  }
});

// bfcache 복원 시 상태 갱신
window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    // bfcache에서 복원됨 - 필요한 상태 업데이트
    updateTimestamps();
    refreshAuthState();
  }
});

bfcache를 무효화하는 주요 원인들:

  • unload 이벤트 리스너 사용 → pagehide로 교체
  • 열린 WebSocket 연결 → pagehide에서 닫고 pageshow에서 재연결
  • Cache-Control: no-store 헤더 → 가능하면 no-cache 또는 max-age로 변경
  • 열린 IndexedDB 연결 → pagehide에서 트랜잭션 정리

Chrome DevTools의 Application 패널에서 "Back/forward cache" 섹션을 확인하면 페이지가 bfcache 자격이 있는지, 아니라면 어떤 이유 때문인지 바로 알 수 있습니다.

서버 사이드 렌더링(SSR)과 CLS

SSR은 LCP에는 유리하지만, 잘못 구현하면 오히려 CLS를 악화시킬 수 있습니다. 서버에서 렌더링한 HTML과 클라이언트에서 하이드레이션 후의 DOM이 다르면, 전환 과정에서 레이아웃 시프트가 발생하거든요.

하이드레이션 불일치 방지

// 나쁜 예: 서버와 클라이언트가 다른 결과를 렌더링
function UserGreeting() {
  const isLoggedIn = typeof window !== 'undefined' 
    ? checkAuth() 
    : false;  // 서버에서는 항상 false!
  
  return isLoggedIn 
    ? <DashboardNav />    // 다른 높이
    : <LoginPrompt />;    // 다른 높이 → CLS!
}

// 좋은 예: 초기 렌더링을 동일하게 유지
function UserGreeting() {
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  const [isHydrated, setIsHydrated] = useState(false);
  
  useEffect(() => {
    setIsHydrated(true);
    setIsLoggedIn(checkAuth());
  }, []);
  
  // 하이드레이션 전에는 로그인 상태와 무관한 공통 레이아웃
  if (!isHydrated) {
    return <NavSkeleton height="64px" />;
  }
  
  return isLoggedIn ? <DashboardNav /> : <LoginPrompt />;
}

CLS 최적화 체크리스트

마지막으로, 실무에서 바로 활용할 수 있는 CLS 최적화 체크리스트를 정리합니다. 프로젝트에 적용할 때 이 목록을 옆에 두고 참고하세요.

이미지와 미디어

  • 모든 <img> 요소에 width와 height 속성을 명시했는가?
  • 반응형 이미지에 max-width: 100%; height: auto;가 적용되어 있는가?
  • 비디오 임베드에 aspect-ratio로 비율을 고정했는가?
  • lazy loading 이미지에 placeholder 또는 고정 크기를 설정했는가?

웹폰트

  • font-display 전략을 설정했는가? (optional 또는 swap + size-adjust)
  • 핵심 폰트를 preload하고 있는가?
  • 폴백 폰트의 메트릭을 size-adjust 등으로 조정했는가?

동적 콘텐츠

  • 광고 슬롯에 min-height를 설정했는가?
  • 배너와 알림에 position: fixed를 사용하는가?
  • 비동기 데이터에 스켈레톤 UI를 적용했는가?

CSS와 렌더링

  • 애니메이션이 transform/opacity만 사용하는가?
  • content-visibility: auto 사용 시 contain-intrinsic-size를 함께 지정했는가?
  • CSS-in-JS가 서버에서도 올바르게 렌더링되는가?

브라우저 캐시와 네비게이션

  • bfcache 호환성을 확인했는가? (unload 리스너 제거)
  • SSR 하이드레이션 불일치를 확인하고 수정했는가?

측정과 모니터링

  • web-vitals 라이브러리로 CLS를 모니터링하고 있는가?
  • CLS 어트리뷰션 정보를 분석 서버로 보내고 있는가?
  • 배포마다 CLS 회귀를 체크하는 CI 파이프라인이 있는가?

마치며

CLS는 Core Web Vitals 3대 지표 중 아마 가장 "직관적으로 불쾌한" 지표일 겁니다. LCP가 느리면 "좀 느리네" 정도로 넘어갈 수 있고, INP가 좋지 않으면 "반응이 좀 둔하네" 정도일 수 있지만, 레이아웃 시프트는 사용자가 읽고 있던 텍스트를 빼앗고 누르려던 버튼을 다른 것으로 바꿔놓습니다. 사용자의 제어권을 침해하는 거죠.

다행히 CLS는 세 지표 중 가장 개선하기 쉬운 지표이기도 합니다.

이미지에 사이즈를 명시하고, 폰트 로딩 전략을 세우고, 동적 콘텐츠에 공간을 미리 확보하는 것만으로도 대부분의 CLS 문제를 해결할 수 있습니다. 이 가이드에서 다룬 기법들을 하나씩 적용해 보세요. 여러분의 웹사이트가 시각적으로 훨씬 안정적이고, 사용자가 신뢰할 수 있는 경험을 제공하게 될 겁니다.

INP 가이드와 LCP 가이드에서 다뤘던 것처럼, 핵심은 "측정 → 분석 → 최적화 → 모니터링"의 사이클을 꾸준히 돌리는 것입니다. Layout Instability API와 web-vitals 라이브러리를 활용해서 지속적으로 CLS를 모니터링하고, 문제가 발생하면 빠르게 대응하세요. 웹 성능 최적화는 한 번으로 끝나는 게 아니라 계속 이어지는 여정이니까요.

저자 소개 Editorial Team

Our team of expert writers and editors.