페이지가 처음 뜰 때는 멀쩡해 보였는데, 몇 초 뒤 갑자기 텍스트가 아래로 밀리거나 버튼이 옆으로 튀는 경험은 생각보다 치명적입니다. 읽던 위치를 잃게 만들고, 잘못 누르게 만들고, 무엇보다 사이트가 덜 신뢰감 있게 느껴지게 만듭니다. 이게 바로 CLS, 즉 Cumulative Layout Shift가 말하는 문제입니다.

web.dev 문서에 따르면 CLS는 얼마나 많은 콘텐츠가, 얼마나 멀리, 예상치 않게 이동했는지를 합산해서 보는 지표입니다. 좋은 CLS는 0.1 이하를 목표로 보고, 특히 모바일과 데스크톱 각각에서 75퍼센타일 기준으로 확인하는 편이 좋습니다. 중요한 건 CLS가 단순히 “로딩이 느리다”의 문제가 아니라 “보이던 레이아웃이 뒤늦게 바뀌었다”는 문제라는 점입니다.

실무에서 CLS는 보통 거대한 한 가지 원인보다, 사소하지만 반복되는 습관에서 생깁니다. 이미지 크기를 미리 안 잡아두고, 상단에 늦게 배너를 꽂고, 스켈레톤 크기와 실제 카드 크기가 다르고, 광고 슬롯 높이를 나중에 계산하고, 웹폰트가 늦게 들어오는데 대체 폰트 폭 차이를 전혀 고려하지 않는 식입니다. 그래서 CLS는 “한 번에 크게 고친다”보다 “컴포넌트를 만드는 습관을 바꾼다”가 더 잘 먹힙니다.

이 글에서는 제가 콘텐츠형 사이트를 만들 때 CLS를 줄이기 위해 기본값처럼 가져가게 된 습관들을 정리해보겠습니다. 앞서 Next.js 이미지 최적화로 LCP를 줄이는 글에서 이미지의 발견 시점과 다운로드 크기를 다뤘다면, 이번 글은 그 이미지와 UI가 화면에서 얼마나 안정적으로 고정되는지를 중심으로 봅니다.

경험/운영레이아웃 안정성

CLS를 줄이기 위해 콘텐츠 레이아웃에서 바꾼 습관들

레이아웃 점프를 줄이기 위해 컴포넌트를 어떻게 만들고 배치해야 하는지 정리합니다. 이미지, iframe, 광고 슬롯, 웹폰트, 스켈레톤, 상단 배너처럼 자주 흔들리는 요소를 실제 습관 단위로 설명합니다.

핵심 1

먼저 기준부터: CLS는 “예상치 못한 이동”을 줄이는 문제다

핵심 2

늦게 나타날 모든 요소는 먼저 자리를 만든다

핵심 3

이미지 컴포넌트는 “비율부터 고정”하는 방식으로 만든다

레이아웃 안정성경험/운영reducing cls in content layout
공간을 먼저 예약하지 않아 콘텐츠가 밀려나는 나쁜 예와, 이미지 비율과 슬롯 높이를 먼저 확보해 레이아웃 점프를 막는 좋은 예

먼저 기준부터: CLS는 “예상치 못한 이동”을 줄이는 문제다

CLS를 이야기할 때 한 가지 먼저 기억해두면 좋은 점이 있습니다. 모든 레이아웃 변화가 다 문제는 아닙니다. web.dev의 최적화 가이드는 사용자 입력 후 500밀리초 안에 일어나는 일부 변화는 기대된 변화로 간주될 수 있다고 설명합니다. 예를 들어 아코디언을 직접 눌렀고 바로 펼쳐진다면, 그건 어느 정도 예상 가능한 움직임입니다.

반대로 아래 같은 변화는 문제가 되기 쉽습니다.

  • 스크롤하던 중 lazy-loaded 콘텐츠가 들어오면서 본문이 밀림
  • 광고나 iframe이 뒤늦게 높이를 키우면서 문단이 움직임
  • 이미지 비율이 늦게 확정되면서 카드 높이가 바뀜
  • 웹폰트가 늦게 들어와 제목 줄 수가 바뀜
  • 상단 공지나 쿠키 배너가 페이지 위를 밀어내며 등장함

즉 CLS는 “움직였느냐”보다 “사용자가 예상하지 못한 순간에 레이아웃이 밀렸느냐”를 보는 쪽에 가깝습니다.

1. 늦게 나타날 모든 요소는 먼저 자리를 만든다

제가 제일 먼저 바꾼 습관은 아주 단순합니다. 나중에 나타날 요소라면, 먼저 그 자리를 만들어 둡니다. 이 한 가지 원칙만 잘 지켜도 CLS 대부분이 줄어듭니다.

web.dev는 poor CLS의 가장 흔한 원인으로 다음 네 가지를 꼽습니다.

  • 크기를 모르는 이미지
  • 크기를 모르는 광고, embed, iframe
  • 크기를 모르는 동적 삽입 콘텐츠
  • 웹폰트

이 네 가지를 한 줄로 바꾸면 “늦게 로드되는데 공간을 예약하지 않은 것들”입니다. 그래서 저는 컴포넌트를 만들 때 먼저 이렇게 묻습니다.

  • 이 박스는 나중에 내용이 들어오나
  • 들어올 때 높이나 폭이 바뀔 수 있나
  • 그렇다면 비어 있어도 차지할 자리를 먼저 줄 수 있나

이 질문을 습관처럼 반복하면 문제의 절반은 디자인 단계에서 끝납니다.

2. 이미지 컴포넌트는 “비율부터 고정”하는 방식으로 만든다

이미지는 CLS의 가장 전형적인 원인입니다. web.dev는 이미지와 비디오에는 항상 widthheight를 넣거나, 최소한 CSS aspect-ratio 같은 방식으로 필요한 공간을 예약하라고 설명합니다. 핵심은 브라우저가 이미지가 다 오기 전에도 어느 정도 공간을 먼저 계산할 수 있어야 한다는 점입니다.

Next.js를 쓴다면 정적 import 이미지가 편합니다. 비율 정보를 프레임워크가 알고 있기 때문입니다.

src/components/article-hero.tsx

import Image from "next/image";
import coverImage from "@/public/article-cover.webp";

export function ArticleHero() {
  return (
    <Image
      src={coverImage}
      alt="글 대표 이미지"
      sizes="100vw"
      style={{ width: "100%", height: "auto" }}
    />
  );
}

원격 이미지라면 폭과 높이를 직접 적는 편이 좋습니다.

<Image
  src="https://images.example.com/post/cover.webp"
  alt="글 대표 이미지"
  width={1600}
  height={900}
  sizes="100vw"
  style={{ width: "100%", height: "auto" }}
/>

그리고 카드 썸네일처럼 비율이 항상 같은 요소는 아예 래퍼에서 고정하는 편이 더 안정적입니다.

.cardThumb {
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: 1rem;
}

.cardThumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

이 방식이 좋은 이유는 실제 이미지가 늦게 와도 카드 전체 높이가 먼저 정해진다는 점입니다. 저는 이제 카드, 대표 이미지, 갤러리 썸네일처럼 반복되는 UI는 거의 무조건 “비율부터 예약한다”는 기준으로 만듭니다.

3. 광고, iframe, embed는 “들어오면 커지는 박스”가 아니라 “이미 높이가 있는 슬롯”으로 다룬다

애드센스를 포함한 광고 슬롯, 유튜브 embed, 지도 iframe, 소셜 미리보기는 CLS를 자주 만드는 요소입니다. web.dev도 광고와 embeds, iframes without dimensions를 대표 원인으로 직접 언급합니다. 이건 콘텐츠 사이트에서 특히 중요합니다. 광고를 늦게 불러오는 것 자체보다, 광고가 들어오며 본문을 밀어내는 구조가 더 해롭습니다.

그래서 저는 이 요소들을 “나중에 채워질 콘텐츠”가 아니라 “이미 페이지 설계 안에 있는 빈 슬롯”으로 봅니다.

예를 들면 이런 식입니다.

export function AdSlot() {
  return (
    <aside className="adSlot" aria-label="광고 영역">
      <div className="adSlot__inner">광고 로딩 영역</div>
    </aside>
  );
}
.adSlot {
  min-height: 280px;
  border-radius: 1rem;
  background: #f5f5f3;
}

.adSlot__inner {
  min-height: 280px;
}

중요한 건 실제 광고가 늦게 로드되어도 본문은 이미 그 높이를 알고 있다는 점입니다. 모바일과 데스크톱에서 슬롯 높이가 다를 수 있다면, 반응형으로 각각의 최소 높이를 먼저 잡는 편이 낫습니다.

.embedBox {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: #111827;
}

.embedBox iframe {
  width: 100%;
  height: 100%;
  border: 0;
}

즉 iframe이나 광고는 “필요할 때 높이가 생기는 박스”가 아니라 “처음부터 자리가 있는 박스”가 되어야 합니다.

4. 상단 배너는 페이지를 밀어내기보다, 겹치거나 이미 예약된 자리 안에서 움직이게 만든다

CLS를 실제로 자주 만드는 요소 중 하나가 공지 배너, 쿠키 배너, 긴급 알림 바입니다. 페이지가 그려진 뒤 상단에 새 바가 끼어들면, 아래 텍스트와 버튼이 통째로 밀리기 쉽습니다. 특히 사용자가 막 스크롤을 시작했을 때 이게 일어나면 체감이 더 나쁩니다.

그래서 저는 상단 배너를 만들 때 두 가지 중 하나만 고릅니다.

  1. 처음부터 해당 배너 자리를 레이아웃에 포함한다
  2. 문서를 밀지 않는 overlay 또는 fixed 패턴으로 띄운다

예를 들어 쿠키 배너라면 하단 fixed overlay가 훨씬 안정적입니다.

export function CookieBanner() {
  return (
    <div className="cookieBanner" role="dialog" aria-live="polite">
      <p>사이트 품질 개선을 위해 분석 쿠키를 사용할 수 있습니다.</p>
      <button type="button">확인</button>
    </div>
  );
}
.cookieBanner {
  position: fixed;
  inset-inline: 1rem;
  bottom: 1rem;
  z-index: 50;
  max-width: 32rem;
  padding: 1rem 1.1rem;
  border-radius: 1rem;
  background: rgba(17, 24, 39, 0.94);
  color: white;
}

이 패턴은 본문을 밀어내지 않기 때문에 CLS에 훨씬 유리합니다. 반대로 꼭 상단 배너가 필요하다면, header 아래에 그 영역의 최소 높이를 미리 잡아두고 그 안에서 문구만 바뀌게 만드는 편이 좋습니다.

5. 스켈레톤은 “대충 비슷한 회색 박스”가 아니라 실제 컴포넌트와 같은 외곽을 가져야 한다

스켈레톤 UI를 넣었다고 해서 자동으로 CLS가 해결되지는 않습니다. 오히려 스켈레톤이 실제 카드보다 낮거나 좁으면, 데이터가 들어오는 순간 다시 레이아웃이 밀립니다. 그래서 스켈레톤은 “뭐가 들어올지 느낌만 주는 장식”이 아니라 “최종 레이아웃의 외곽선을 먼저 예약하는 도구”로 보는 편이 맞습니다.

제가 바꾼 습관은 단순합니다.

  • 실제 카드와 같은 min-height를 쓴다
  • 이미지 자리 비율을 미리 맞춘다
  • 제목 두 줄, 메타 한 줄처럼 최종 구조를 흉내 낸다

예를 들면 이런 식입니다.

export function PostCardSkeleton() {
  return (
    <article className="postCardSkeleton">
      <div className="postCardSkeleton__thumb" />
      <div className="postCardSkeleton__line postCardSkeleton__line--title" />
      <div className="postCardSkeleton__line" />
      <div className="postCardSkeleton__line postCardSkeleton__line--meta" />
    </article>
  );
}
.postCardSkeleton {
  min-height: 320px;
  padding: 1.25rem;
  border-radius: 1.5rem;
  background: #fff;
}

.postCardSkeleton__thumb {
  aspect-ratio: 16 / 9;
  border-radius: 1rem;
  background: #ece7df;
}

여기서 중요한 건 실제 카드가 렌더된 뒤에도 외곽 높이가 거의 같아야 한다는 점입니다. “비슷하게 보인다”보다 “바뀌지 않는다”가 더 중요합니다.

6. 버튼과 상태 문구는 비동기 전후 폭이 너무 달라지지 않게 만든다

CLS는 큰 미디어만의 문제가 아닙니다. 버튼 하나도 레이아웃을 흔들 수 있습니다. 예를 들어 저장 버튼이 로딩 중 저장 중입니다...로 길어지면서 옆 요소를 밀거나, 성공 후 완료됨으로 바뀌며 폭이 달라지는 경우가 있습니다. 이런 변화는 작은 폼이나 카드 그리드에서 누적되면 의외로 눈에 띕니다.

그래서 저는 비동기 버튼에 최소 너비를 먼저 둡니다.

.submitButton {
  min-inline-size: 8rem;
  justify-content: center;
}

그리고 상태 문구는 레이아웃 바깥으로 분리하는 편을 선호합니다.

<div className="formActions">
  <button className="submitButton" type="submit">
    {isPending ? "전송 중..." : "문의 보내기"}
  </button>
  <p className="formStatus" aria-live="polite">
    {message}
  </p>
</div>

이렇게 하면 버튼 폭이 바뀌며 주변 컴포넌트를 밀어내는 일을 줄일 수 있습니다.

7. 웹폰트는 “예쁜 서체”보다 먼저 레이아웃 안정성 관점으로 고른다

web.dev는 웹폰트를 CLS의 흔한 원인 중 하나로 직접 언급합니다. 폰트가 늦게 들어오면서 fallback 폰트에서 실제 폰트로 바뀌고, 그 과정에서 줄 수나 단어 폭이 달라지면 제목과 카드 높이가 흔들릴 수 있습니다.

Next.js를 쓴다면 next/font가 좋은 기본값입니다. 공식 문서도 이 모듈이 폰트를 자동으로 최적화하고 외부 요청을 제거하며, self-hosting을 포함해 layout shift 없이 로드할 수 있다고 설명합니다. 그래서 저는 새 프로젝트에서 폰트를 붙일 때 CDN 링크를 head에 직접 꽂기보다 next/font부터 검토합니다.

src/app/layout.tsx

import { Geist } from "next/font/google";

const geist = Geist({
  subsets: ["latin"],
});

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

물론 한국어 콘텐츠 사이트에서는 한글 폰트 선택이 더 까다롭습니다. 그래서 실제로는 아래 기준을 같이 봅니다.

  • title과 body의 fallback 폭 차이가 너무 크지 않은가
  • card title처럼 줄 바꿈 민감한 곳에서 line-height가 안정적인가
  • 꼭 필요한 weight만 쓰고 있는가
  • variable font를 쓸 수 있는가

폰트는 디자인 도구이기도 하지만, 레이아웃 안정성 도구이기도 합니다.

8. 비동기 섹션은 “데이터가 오면 생기는 영역”이 아니라 “비어 있어도 존재하는 영역”으로 그린다

댓글, 추천 글, 관련 문서, 통계 카드처럼 비동기 데이터가 들어오는 섹션은 자주 페이지 하단을 밀어냅니다. 특히 본문 아래 추천 글이 늦게 들어오면, 사용자가 막 다음 문단을 읽는 순간 계속 위치가 바뀔 수 있습니다.

그래서 저는 비동기 섹션을 만들 때 래퍼 높이부터 생각합니다.

export function RelatedPostsSection({
  children,
}: {
  children: React.ReactNode;
}) {
  return <section className="relatedPostsShell">{children}</section>;
}
.relatedPostsShell {
  min-height: 28rem;
}

이 숫자는 마법 같은 값이 아니라, 실제 카드 3개가 들어왔을 때 차지하는 높이와 비슷하면 됩니다. 목적은 완벽한 픽셀 고정이 아니라 “데이터가 오기 전과 후의 외곽 차이를 줄이는 것”입니다.

9. 애니메이션도 레이아웃을 흔들지 않는 속성을 우선한다

레이아웃 점프는 로딩 중에만 생기지 않습니다. 개발자가 의도적으로 준 애니메이션도 레이아웃을 흔들 수 있습니다. 특히 height, top, left, margin을 직접 움직이는 방식은 주변 요소 위치를 바꾸기 쉽습니다. 저는 시각적인 등장 효과가 필요할 때 가능하면 transformopacity부터 고려합니다.

예를 들어 카드 hover나 배너 등장 같은 효과는 아래처럼 처리합니다.

.panel {
  transform: translateY(8px);
  opacity: 0;
}

.panel[data-ready="true"] {
  transform: translateY(0);
  opacity: 1;
  transition:
    transform 180ms ease-out,
    opacity 180ms ease-out;
}

이 방식의 장점은 주변 레이아웃을 다시 계산하게 만들지 않는다는 점입니다. 시각적으로는 부드럽게 보이지만, 문단과 카드가 실제 위치를 바꾸지는 않습니다.

10. 측정도 “로드 직후 한 번”으로 끝내지 않는다

CLS는 로드 직후만 보면 놓치는 경우가 많습니다. web.dev 문서도 실험실 도구는 대개 초기 로드 CLS만 잡고, 실제 사용자 데이터에서는 페이지 전체 수명 동안의 CLS가 반영된다고 설명합니다. 그래서 Lighthouse와 CrUX 또는 RUM 숫자가 다를 수 있습니다.

이 차이를 이해하면 왜 “내 컴퓨터에서는 멀쩡한데 실사용 CLS가 높지?”라는 일이 생기는지도 보입니다.

  • 광고 네트워크가 실제 운영에서만 늦게 반응함
  • 개인화 위젯이 프로덕션에서만 렌더됨
  • 스크롤 중 lazy-loaded 섹션이 뒤늦게 높이를 바꿈
  • SPA 전환이나 긴 상호작용이 500ms를 넘기며 shift를 만듦

그래서 저는 CLS를 볼 때 아래 두 가지를 같이 봅니다.

  1. Lighthouse / DevTools로 초기 로드 shift 확인
  2. 필드 데이터나 실사용 로그로 post-load shift 확인

간단히 콘솔에서 layout shift를 찍어보는 것도 도움이 됩니다.

new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    console.log("Layout shift:", entry);
  }
}).observe({ type: "layout-shift", buffered: true });

그리고 실제 수집에는 web-vitalsonCLS 같은 도구를 쓰는 편이 안전합니다.

import { onCLS } from "web-vitals";

onCLS(console.log);

이런 식으로 보면 “무슨 요소가 흔들렸는지”뿐 아니라 “언제, 어떤 흐름에서 흔들렸는지”가 보여서 우선순위를 정하기 쉬워집니다.

제가 기본값처럼 유지하는 체크리스트

  • 이미지, 비디오, 썸네일에 비율 정보나 width / height를 넣었는가
  • iframe, embed, 광고 슬롯은 먼저 자리부터 만들어뒀는가
  • 상단 배너가 본문을 밀어내지 않는가
  • 스켈레톤 높이와 실제 컴포넌트 높이가 거의 같은가
  • 버튼 로딩 상태가 주변 요소를 밀지 않는가
  • 비동기 섹션은 래퍼 차원에서 최소 높이를 확보했는가
  • 웹폰트 도입 시 fallback 폭 차이를 점검했는가
  • 등장 효과를 transform, opacity 중심으로 처리했는가
  • 초기 로드 CLS와 post-load CLS를 따로 확인했는가

마무리

CLS를 줄이는 일은 특별한 최적화 트릭보다, 컴포넌트를 설계하는 기본 습관에 더 가깝습니다. 늦게 나타날 것은 먼저 자리를 만들고, 비율이 있는 것은 비율부터 고정하고, 상태가 바뀌는 버튼과 배너는 주변을 밀지 않게 만들고, 스켈레톤은 최종 레이아웃의 외곽을 미리 닮게 만드는 식입니다.

콘텐츠 사이트는 요소 하나하나가 크지 않아 보여도, 이런 작은 이동이 누적되면 읽는 경험이 크게 흔들립니다. 애드센스나 embed를 넣을 계획이 있다면 더더욱 레이아웃 안정성을 먼저 설계하는 편이 좋습니다. 이미지 최적화 관점까지 함께 보고 싶다면 LCP 이미지 최적화 글도 이어서 보면 흐름이 잘 이어집니다.

참고 자료: