Published on

Chrome Rendering 탭으로 CLS 급증 원인 추적법

Authors

서버 성능이 괜찮고 TTFB도 안정적인데, 사용자 체감이 계속 불편하다면 범인은 종종 CLS 입니다. 특히 배너, 이미지, 폰트, 스켈레톤, 광고 슬롯 같은 요소가 "나중에" 크기를 확정하면서 화면이 튀는 순간이 누적되면, Lighthouse 점수는 물론 실제 전환율에도 영향을 줍니다.

이 글에서는 Chrome DevTools의 Rendering 탭을 중심으로, Performance 타임라인과 함께 CLS 급증 구간을 재현하고 “무엇이 언제 움직였는지”를 증거 기반으로 추적하는 방법을 설명합니다. 단순히 width/height 넣으세요 수준이 아니라, 어느 프레임에서 어떤 노드가 이동했는지까지 좁혀가는 흐름으로 구성했습니다.

참고로 CLS는 서버 응답이 느려서 발생하는 문제가 아니라, 렌더링 이후 레이아웃이 바뀌는 문제인 경우가 많습니다. 다만 느린 데이터 페치가 레이아웃 변화를 늦게 발생시키며 CLS를 악화시키기도 하므로, 네트워크/서버 병목이 의심되면 Next.js 14 RSC 느림? TTFB 급증 7가지 해결 같은 관점도 함께 점검하는 것이 좋습니다.

CLS를 빠르게 이해하기: "이동"이 아니라 "예상 불가"가 문제

CLS는 단순히 요소가 움직였다는 사실만으로 점수가 오르지 않습니다. 핵심은 다음 두 가지입니다.

  • 예상하지 못한 레이아웃 이동인지
  • 그 이동이 뷰포트 내에서 얼마나 큰 면적을 차지했는지

예를 들어, 사용자가 버튼을 눌러 아코디언을 펼친 경우는 "사용자 입력에 의한 변화"로 간주되어 CLS에 포함되지 않는 경우가 많습니다. 반대로 페이지 로드 중 이미지가 늦게 로드되며 텍스트를 밀어내는 것은 대표적인 CLS 원인입니다.

따라서 추적의 목표는 명확합니다.

  • CLS가 튄 시점의 프레임을 찾고
  • 그 프레임에서 레이아웃을 바꾼 트리거를 찾고
  • 트리거를 만든 리소스나 코드까지 연결한다

준비: DevTools에서 Rendering 패널 열기

  1. Chrome에서 문제 페이지를 연 뒤 DevTools를 엽니다.
  2. Command Menu를 열고 Rendering을 검색합니다.
    • macOS: Cmd + Shift + P
    • Windows: Ctrl + Shift + P
  3. Show Rendering을 선택해 하단에 Rendering 패널을 띄웁니다.

여기서 오늘 핵심적으로 쓸 기능은 다음입니다.

  • Layout Shift Regions (레이아웃 이동 영역 표시)
  • Paint Flashing (페인트 발생 영역 표시)
  • Scrolling performance issues는 부가적으로 참고

1단계: Layout Shift Regions로 "움직인 영역"을 먼저 고정

Rendering 패널에서 Layout Shift Regions를 체크한 뒤 페이지를 새로고침합니다.

  • 페이지 로드 중 화면에서 반짝이는 사각형이 보이면, 그 영역이 실제로 레이아웃 이동이 발생한 부분입니다.
  • 이 단계의 목적은 "CLS가 있다"가 아니라, 어떤 영역이 문제인지 시각적으로 확보하는 것입니다.

자주 보이는 패턴

  • 상단 히어로 이미지가 늦게 자리 잡으며 아래 텍스트가 밀림
  • 웹폰트 적용 시 텍스트 줄바꿈이 바뀌며 콘텐츠가 위아래로 이동
  • 광고/추천 영역이 비동기로 삽입되며 본문이 밀림
  • 스켈레톤이 실제 콘텐츠로 바뀌며 높이가 달라짐

이제 "어디가 움직였는지"는 잡았습니다. 다음은 "언제"와 "무엇 때문에"를 찾아야 합니다.

2단계: Performance 녹화로 CLS 이벤트를 타임라인에 박제

  1. DevTools의 Performance 탭으로 이동합니다.
  2. 상단 설정에서 Screenshots를 켭니다.
  3. Record를 누르고, 문제 상황을 재현합니다.
    • 로드 직후 튐이면 Hard reload로 재현
    • 스크롤 중 튐이면 해당 구간까지 스크롤
  4. Stop을 눌러 녹화를 종료합니다.

타임라인에서 Experience 또는 Layout Shift 관련 항목을 찾을 수 있습니다. Chrome 버전에 따라 표시가 조금 다르지만, 보통은 다음 흐름으로 접근합니다.

  • 타임라인에서 Layout Shift 이벤트를 클릭
  • 우측 상세 패널에서 Score 혹은 Impacted nodes를 확인
  • 스크린샷 스트립에서 해당 순간의 프레임을 확인

여기서 중요한 점은, CLS는 누적 지표이므로 "한 번의 큰 이동"일 수도 있고 "여러 번의 작은 이동"일 수도 있다는 것입니다. 따라서 점수가 큰 이벤트부터 잡는 것이 효율적입니다.

3단계: Impacted nodes로 "범인 DOM"을 특정

Layout Shift 이벤트 상세에서 Impacted nodes 또는 유사한 필드를 펼치면, 레이아웃 이동에 관여한 DOM 노드가 나옵니다.

  • 해당 노드를 클릭하면 Elements 패널에서 하이라이트되는 경우가 많습니다.
  • 이때 확인할 것은 "움직인 노드" 자체가 아니라, 그 노드의 위치/크기를 바꾼 원인 노드입니다.

예시로, 본문이 밀렸다면 본문이 범인이 아니라 본문 위에 삽입된 배너가 범인일 가능성이 큽니다.

4단계: 원인 분류 체크리스트 (가장 많이 터지는 6가지)

이제부터는 원인을 빠르게 분류하고, 각각을 어떻게 증명할지 정리합니다.

1) 이미지/비디오의 고정 크기 미지정

가장 흔한 케이스입니다. imgvideo에 고정된 가로세로 비율이 없으면, 로드 전에는 높이가 0에 가깝다가 로드 후 확정되며 아래 콘텐츠를 밀어냅니다.

해결

  • imgwidthheight를 지정하거나
  • CSS aspect-ratio로 비율을 고정합니다.
<img
  src="/hero.jpg"
  width="1200"
  height="630"
  alt="hero"
  loading="eager"
/>
.hero-media {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

증명 방법

  • Layout Shift Regions가 이미지 아래 영역에서 강하게 뜸
  • Performance의 Layout Shift 이벤트 시점이 이미지 리소스 로드 직후와 일치

2) 웹폰트 로딩으로 인한 줄바꿈 변화 (FOIT/FOUT)

웹폰트가 늦게 적용되면 글자 폭이 달라져 줄바꿈이 바뀌고, 그 결과 레이아웃이 이동합니다.

해결

  • font-display: swap 또는 optional 고려
  • 가능한 경우 메트릭 호환 폰트 사용
  • 핵심 폰트는 preload로 당깁니다.
@font-face {
  font-family: "MyFont";
  src: url("/fonts/myfont.woff2") format("woff2");
  font-display: swap;
}
<link
  rel="preload"
  href="/fonts/myfont.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

증명 방법

  • Layout Shift 이벤트 시점에 Font 리소스 다운로드/적용이 겹침
  • 이동 영역이 텍스트 블록 주변에 집중

3) 상단 고정 영역에 늦게 삽입되는 배너/공지/동의 UI

쿠키 동의, 공지 배너, 앱 설치 유도 바 등이 늦게 DOM에 삽입되면 상단이 밀리면서 전체가 흔들립니다.

해결

  • 처음부터 공간을 예약합니다.
  • position: fixed로 오버레이 처리하되, 콘텐츠를 밀지 않게 합니다.
.top-banner-slot {
  height: 56px; /* 미리 공간 예약 */
}

.top-banner {
  height: 56px;
}

또는 콘텐츠를 밀지 않는 방식:

.top-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 56px;
  transform: translateY(-100%);
  transition: transform 200ms ease;
}

.top-banner.is-open {
  transform: translateY(0);
}

body {
  padding-top: 56px; /* 항상 동일 */
}

증명 방법

  • Layout Shift Regions가 페이지 전체에 걸쳐 나타남
  • Layout Shift 이벤트의 impacted nodes에 배너 컨테이너가 포함

4) 스켈레톤과 실제 콘텐츠의 높이 불일치

스켈레톤이 예쁘게 보이지만, 실제 데이터가 들어오면서 카드 높이가 달라지면 그 순간 CLS가 발생합니다.

해결

  • 스켈레톤을 실제 레이아웃과 동일한 박스 모델로 만듭니다.
  • 리스트/그리드라면 각 아이템의 최소 높이를 고정합니다.
.card {
  min-height: 180px;
}

.card__title {
  line-clamp: 2;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

증명 방법

  • 데이터 응답 완료 직후 Layout Shift 발생
  • 스켈레톤 제거와 실제 DOM 삽입이 같은 프레임에 일어남

5) 동적 스타일 주입과 늦은 CSS 로딩

초기에는 기본 스타일로 렌더링되었다가, 나중에 CSS가 적용되며 레이아웃이 재정렬되는 경우입니다. CSS-in-JS 런타임 주입, 조건부 CSS 로딩, 늦은 @import 등이 원인이 됩니다.

해결

  • 핵심 CSS는 초기 렌더링 이전에 준비
  • @import 대신 빌드 타임 번들링
  • 조건부 로딩이 필요하면 레이아웃에 영향 없는 속성부터 적용

증명 방법

  • Paint Flashing을 켜면 특정 구간에서 대규모 페인트가 발생
  • Network에서 CSS가 늦게 도착하는 타이밍과 Layout Shift가 일치

6) 무한 스크롤/추천 모듈의 중간 삽입

스크롤 중 특정 위치에 추천 모듈이 끼어들어오면, 사용자가 읽던 위치가 갑자기 변합니다.

해결

  • 삽입 위치에 슬롯을 미리 두고 높이를 예약
  • 또는 "더보기" 버튼으로 사용자 입력 기반으로 변경
<div class="recommend-slot" aria-hidden="true"></div>
.recommend-slot {
  min-height: 320px;
}

5단계: Rendering 탭의 Paint Flashing으로 "리플로우 유발 코드" 감지

Layout Shift Regions는 "이동"을 보여주고, Paint Flashing은 "다시 그려진 영역"을 보여줍니다. 둘을 함께 켜면 다음이 쉬워집니다.

  • 특정 컴포넌트가 계속 깜빡인다
  • 그 직후 레이아웃이 이동한다
  • 해당 컴포넌트의 상태 업데이트나 스타일 변경이 원인일 가능성이 크다

특히 다음 패턴을 의심하세요.

  • 스크롤 이벤트에서 레이아웃을 읽고 즉시 쓰는 코드
  • offsetHeight 같은 레이아웃 읽기가 잦은 코드
  • 애니메이션을 top/left/height로 주는 코드

가능하면 애니메이션은 transformopacity 중심으로 바꾸는 것이 안전합니다.

6단계: 실전 디버깅 스니펫으로 "CLS 순간" 로그 남기기

DevTools에서 원인을 좁히기 어려울 때는, 브라우저에서 Layout Shift를 직접 관측해 콘솔에 남기는 방식이 강력합니다.

아래 코드는 layout-shift 엔트리를 수집하고, 사용자 입력에 의해 발생한 이동은 제외한 뒤, 점수가 큰 순서대로 로그를 남깁니다.

// DevTools Console에 붙여넣어 실행
(() => {
  const shifts = [];

  const po = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // 사용자 입력에 의한 이동은 제외
      if (entry.hadRecentInput) continue;
      shifts.push({
        value: entry.value,
        startTime: entry.startTime,
        sources: entry.sources?.map((s) => ({
          node: s.node,
          previousRect: s.previousRect,
          currentRect: s.currentRect,
        })),
      });
    }
  });

  po.observe({ type: "layout-shift", buffered: true });

  window.__dumpCLS = () => {
    const sorted = shifts.slice().sort((a, b) => b.value - a.value);
    console.table(
      sorted.map((s) => ({
        value: s.value,
        startTime: Math.round(s.startTime),
        sourcesCount: s.sources?.length ?? 0,
      }))
    );
    return sorted;
  };

  console.log("CLS observer attached. Run window.__dumpCLS() after reproducing.");
})();

재현 후 window.__dumpCLS()를 실행하고, 상위 엔트리의 sources.node를 펼치면 어떤 노드가 이동에 관여했는지 더 직접적으로 확인할 수 있습니다.

7단계: 수정 후 검증 루틴 (재발 방지)

CLS는 "고쳤다"고 생각해도 다른 화면 크기, 다른 데이터, 느린 네트워크에서 다시 터지는 경우가 많습니다. 아래 루틴으로 검증하는 것이 안전합니다.

  • DevTools Network에서 Slow 3G 또는 Fast 3G로 테스트
  • CPU 쓰로틀링을 걸고 초기 렌더링 지연을 시뮬레이션
  • 뷰포트를 모바일/데스크톱으로 바꿔가며 확인
  • Layout Shift Regions를 켠 상태로 하드 리로드

또한 서버 측 병목으로 데이터가 늦게 도착해 레이아웃 변화가 뒤로 밀리는 경우, 결국 CLS가 더 눈에 띄게 됩니다. 그런 경우엔 렌더링/데이터 전략도 함께 점검하세요. 예를 들어 Next.js를 쓰고 있다면 Next.js 14 RSC 느림? TTFB 급증 7가지 해결에서 다룬 것처럼 스트리밍, 캐시, RSC 경계 설정이 체감에 영향을 줄 수 있습니다.

자주 묻는 함정: "원인 요소"가 아니라 "트리거"를 고쳐야 한다

추적을 하다 보면 impacted node는 본문 텍스트인데, 실제 트리거는 다음 중 하나인 경우가 많습니다.

  • 이미지 슬롯의 크기 미예약
  • 폰트 적용 타이밍
  • 상단 배너의 늦은 삽입
  • 조건부 렌더링으로 DOM이 통째로 교체

따라서 해결책도 본문에 margin을 주는 식의 임시방편이 아니라, 공간 예약초기 레이아웃 안정화에 집중해야 합니다.

결론: Rendering 탭은 "증거 수집" 도구다

CLS를 줄이는 작업은 감으로 하면 끝이 없습니다. Chrome DevTools의 Rendering 탭에서 Layout Shift Regions로 이동 영역을 잡고, Performance에서 Layout Shift 이벤트와 impacted nodes를 확인한 뒤, 이미지/폰트/배너/스켈레톤/늦은 CSS 같은 전형적인 트리거로 분류해 수정하면 재현성과 속도가 크게 올라갑니다.

마지막으로, 수정 후에는 반드시 느린 네트워크와 낮은 CPU 환경에서 다시 녹화해 "정말로 더 이상 이동이 없는지"를 확인하세요. CLS는 사용자 환경에서 더 자주, 더 크게 보이는 지표입니다.