Published on

Chrome LCP 느림? 레이아웃 쉬프트 7원인

Authors

서버 응답이 빠른데도 Chrome에서 LCP가 계속 느리고, 화면이 로딩 중 ‘툭툭’ 밀리는 CLS(레이아웃 쉬프트)가 발생한다면 원인은 대개 브라우저가 “무엇을 먼저 그릴지” 결정하는 과정에 있습니다. 특히 LCP는 “가장 큰 콘텐츠 요소가 언제 그려졌는지”를 보며, CLS는 “예상치 못한 레이아웃 이동”의 누적 점수를 봅니다.

이 글은 Chrome 기준으로 LCP 지연과 레이아웃 쉬프트를 동시에 만드는 대표 7가지 원인을 정리하고, DevTools로 증거를 수집한 뒤 바로 적용 가능한 수정안을 코드로 제시합니다.

관련해서 프런트 성능 이슈가 “원인 7가지” 형태로 반복 패턴을 갖는다는 점은 다른 트러블슈팅 글과도 유사합니다. 예를 들어 운영에서 흔한 점검 프레임은 GCP Cloud Run 503·콜드스타트 지연 해결법 같은 글에서도 그대로 통합니다.

먼저: Chrome에서 LCP/CLS 원인 찾는 최소 절차

1) Web Vitals 오버레이/콘솔로 LCP 요소 확인

페이지에서 “LCP가 무엇인지”부터 확정해야 합니다. LCP 요소가 이미지인지, 텍스트 블록인지에 따라 해결책이 갈립니다.

// 개발 중 콘솔에서 LCP 후보를 기록 (Chromium 계열에서 동작)
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('LCP:', entry.startTime, entry.element);
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

2) DevTools Performance에서 “Layout Shift” 이벤트 확인

Performance 탭에서 기록 후, Timings/Experience 섹션의 Layout Shift를 클릭하면 어떤 노드가 이동했는지, 이동량이 어떤지 힌트를 얻습니다.

3) Network에서 “LCP 리소스” 우선순위/차단 여부 확인

LCP가 이미지라면 해당 이미지 요청이 Priority에서 낮게 잡히거나, CSS/JS 때문에 늦게 시작되는지 확인합니다.

원인 1) 이미지/비디오 크기 미지정(공간 예약 실패)

가장 흔한 CLS 원인입니다. 이미지가 로드되기 전에는 높이가 0으로 계산되었다가, 로드 후 높이가 생기면서 아래 콘텐츠를 밀어냅니다. 이때 LCP가 이미지라면, 늦게 로드되는 동안 LCP도 같이 느려집니다.

해결

  • imgwidth/height를 지정해 aspect ratio를 브라우저가 미리 알게 합니다.
  • 반응형이라면 CSS aspect-ratio로 공간을 예약합니다.
<!-- 권장: width/height 지정 -->
<img
  src="/hero.jpg"
  width="1200"
  height="630"
  alt="Hero"
  decoding="async"
/>
/* 카드 썸네일 등 반응형: 비율로 예약 */
.thumb {
  aspect-ratio: 16 / 9;
  width: 100%;
  background: #eee;
  overflow: hidden;
}
.thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

원인 2) 웹폰트 로딩으로 인한 FOIT/FOUT 및 재레이아웃

웹폰트가 늦게 로드되면 텍스트가 숨겨졌다가(FOIT) 나타나거나, 대체 폰트로 그려졌다가(FOUT) 바뀌면서 줄바꿈/행높이가 달라져 CLS가 발생합니다. 텍스트 블록이 LCP라면 LCP도 직접적으로 악화됩니다.

해결

  • font-display: swap 또는 optional 적용
  • 가능한 경우 preload로 핵심 폰트를 당겨오기
  • 폰트 메트릭 호환 폰트 스택으로 레이아웃 변화 최소화
@font-face {
  font-family: "MyFont";
  src: url("/fonts/myfont.woff2") format("woff2");
  font-display: swap;
}

body {
  font-family: "MyFont", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
<!-- 핵심 폰트를 미리 가져오기 (필요 최소만) -->
<link
  rel="preload"
  href="/fonts/myfont.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

원인 3) LCP 리소스(히어로 이미지) 우선순위가 낮음

Chrome은 많은 리소스를 병렬로 받지만, LCP 후보 리소스가 늦게 요청되거나 낮은 우선순위면 LCP가 밀립니다. 흔한 패턴은 다음과 같습니다.

  • 히어로 이미지가 HTML에 바로 없고, JS로 나중에 DOM에 삽입됨
  • CSS background-image로만 지정되어 우선순위가 떨어짐
  • 이미지가 lazy-load로 잘못 설정됨

해결

  • LCP 이미지는 loading="eager"
  • 가능하면 img 태그로 노출
  • fetchpriority="high"로 힌트 제공
  • preload로 확실히 당겨오기
<img
  src="/hero.webp"
  width="1200"
  height="630"
  alt="Hero"
  loading="eager"
  fetchpriority="high"
/>

<link rel="preload" as="image" href="/hero.webp" />

원인 4) 렌더 차단 CSS/JS로 초기 페인트가 지연됨

LCP는 “그릴 수 있는 상태”가 되어야 측정됩니다. 큰 번들 JS가 메인 스레드를 점유하거나, CSS가 늦게 도착하면 LCP가 늦어집니다. 그리고 JS가 늦게 실행되며 DOM을 삽입/치환하면 CLS까지 유발할 수 있습니다.

해결

  • 비핵심 스크립트는 defer/async
  • 초기 화면에 필요 없는 CSS/JS는 분리
  • 3rd-party 스크립트 지연 로딩
<!-- 렌더 차단을 줄이기 -->
<script src="/analytics.js" defer></script>
<script src="https://example.com/third-party.js" async></script>
// 사용자 상호작용 이후에만 로드
const loadChat = () => import('./chat-widget.js');
window.addEventListener('pointerdown', loadChat, { once: true });

React/Next.js 계열에서 hydration 타이밍 때문에 깜빡임/치환이 생기는 경우도 많습니다. 이 경우는 단순 CLS가 아니라 “서버 렌더와 클라이언트 렌더 불일치”로 이어질 수 있으니, Next.js 14 RSC로 생기는 Hydration Error 7가지도 함께 점검하는 편이 좋습니다.

원인 5) 스켈레톤/플레이스홀더가 실제 콘텐츠와 크기가 다름

스켈레톤 UI를 넣었는데도 CLS가 뜬다면, 스켈레톤의 높이/줄 수가 실제 콘텐츠와 달라서 로딩 완료 시점에 레이아웃이 바뀌기 때문입니다. 특히 카드 리스트, 댓글 영역, 추천 영역에서 흔합니다.

해결

  • 스켈레톤은 “최종 레이아웃과 동일한 박스 모델”로
  • 텍스트는 line-height와 줄 수를 맞추고, 이미지 영역은 aspect-ratio로 고정
.card {
  display: grid;
  grid-template-columns: 120px 1fr;
  gap: 12px;
  min-height: 120px; /* 최종 높이를 보장 */
}

.card__thumb {
  width: 120px;
  aspect-ratio: 1 / 1;
  background: #eee;
}

.skeleton-line {
  height: 14px;
  margin: 8px 0;
  background: #eee;
  border-radius: 6px;
}

원인 6) 동적 삽입(배너/광고/알림/쿠키바)로 상단이 밀림

페이지 상단에 쿠키 동의, 프로모션 배너, 로그인 유도 바 등을 JS로 “나중에” 삽입하면, 이미 렌더된 콘텐츠가 아래로 밀리며 큰 CLS가 발생합니다. LCP 요소가 상단 근처라면 LCP 측정 시점도 흔들릴 수 있습니다.

해결

  • 처음부터 공간을 예약하거나(placeholder)
  • 오버레이(absolute/fixed)로 띄우되 콘텐츠 흐름을 바꾸지 않기
  • transform 애니메이션으로 등장(레이아웃 영향 최소)
<!-- 공간 예약: 처음부터 배너 슬롯을 확보 -->
<div id="top-banner-slot" style="height: 56px"></div>
/* 오버레이 방식: 문서 흐름을 밀지 않음 */
.cookiebar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  transform: translateY(100%);
  transition: transform 200ms ease;
}
.cookiebar.is-open {
  transform: translateY(0);
}

원인 7) 애니메이션/스타일 변경이 layout 속성을 건드림

top, left, height, width 같은 속성을 애니메이션하면 매 프레임 레이아웃 계산이 발생해 jank가 생기고, 특정 상황에서는 CLS로도 관측됩니다. 반면 transformopacity는 비교적 안전합니다.

해결

  • 이동/확대는 transform으로
  • 필요한 경우 will-change: transform을 제한적으로 사용
/* 나쁜 예: 레이아웃을 건드림 */
.bad {
  position: relative;
  transition: top 200ms ease;
}
.bad:hover {
  top: -6px;
}

/* 좋은 예: transform 사용 */
.good {
  transition: transform 200ms ease, opacity 200ms ease;
}
.good:hover {
  transform: translateY(-6px);
}

체크리스트: “LCP 느림 + CLS 발생”을 빠르게 줄이는 우선순위

  1. LCP 요소가 이미지면 width/height, fetchpriority, preload, loading부터 정리
  2. 상단에 동적 삽입되는 UI(배너/쿠키바/광고) 공간 예약 또는 오버레이화
  3. 웹폰트 font-display: swap 적용 및 핵심 폰트만 preload
  4. 스켈레톤이 최종 레이아웃과 같은 박스 모델인지 검증
  5. 렌더 차단 JS/CSS를 줄이고, 3rd-party는 지연 로딩
  6. 애니메이션은 transform 중심으로 교체
  7. DevTools Performance에서 Layout Shift 이벤트가 “0”에 가까워졌는지 재측정

마무리: 숫자보다 “재현 가능한 원인”을 잡자

LCP와 CLS는 결과 지표라서, 한 번의 수정으로 드라마틱하게 좋아지기도 하지만, 대개는 우선순위(리소스), 공간 예약(레이아웃), 메인 스레드 점유(JS) 세 축을 순서대로 정리해야 안정적으로 개선됩니다.

특히 “원인 후보는 많은데 확신이 없다”면, 위 7가지를 하나씩 제거하면서 DevTools에서 Layout Shift와 LCP 후보 요소를 다시 확인하세요. 이 방식이 가장 빠르게 ‘감’이 아니라 ‘증거’로 성능을 고치는 루트입니다.

추가로 React에서 Suspense/전환 때문에 화면이 깜빡이며 레이아웃이 흔들린다면, React useTransition 무한 로딩·깜빡임 해결법도 함께 보면 원인 분리가 쉬워집니다.