Published on

Chrome LCP 느림? Render‑Blocking 리소스 추적법

Authors

서버 응답(TTFB)은 괜찮은데도 LCP(Largest Contentful Paint)만 유독 느린 경우가 있습니다. 이때 범인은 대개 render-blocking 리소스(CSS, 동기 JS, 폰트, 메인 스레드 점유) 입니다. 문제는 “어떤 파일이, 어떤 이유로, 얼마만큼” LCP를 밀어내는지 정확히 찍어내지 못하면 최적화가 감(感)으로 흘러간다는 점입니다.

이 글은 Chrome DevTools만으로 LCP 후보 요소를 특정 → 해당 요소가 그려지기까지 막는 리소스를 추적 → 해결책을 적용 → 재측정 하는 흐름을 실전 단계로 정리합니다. (Next.js/SPA든 정적 페이지든 동일한 원리로 적용됩니다.)

> 참고: LCP가 느리다고 해서 항상 프론트만 문제는 아닙니다. RSC/캐시/재검증 때문에 초기 HTML이 늦어지면 LCP도 같이 밀립니다. 서버/프레임워크 관점의 TTFB 최적화는 Next.js App Router 렌더링 폭주, RSC 캐시·revalidate로 TTFB 낮추기도 함께 확인해두면 좋습니다.

1) LCP부터 ‘정확히’ 찍고 시작하기

LCP 최적화는 “가장 큰 요소가 언제 그려졌는가”를 줄이는 게임입니다. 먼저 LCP 대상이 이미지인지 텍스트 블록인지부터 확정해야 합니다.

DevTools에서 LCP 요소 확인

  1. Chrome에서 페이지 열기
  2. F12Performance
  3. Record 후 새로고침(또는 재현 동작)
  4. 타임라인 상단의 LCP 마커 클릭
  5. 오른쪽/하단 패널에서 LCP element 확인

여기서 확인해야 할 핵심:

  • LCP가 이미지인가? (hero 이미지, 썸네일, 배너)
  • LCP가 텍스트인가? (큰 H1/카피 영역)
  • LCP element가 DOM에 언제 삽입되는가? (SSR/CSR, hydration 이후 등장 등)

Console로 LCP 로그 찍기(재현이 어려울 때)

아래 스니펫은 로딩 중 LCP 후보가 바뀌는 과정까지 콘솔에 남겨줍니다.

// DevTools Console에서 실행
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('[LCP]', {
      startTime: entry.startTime,
      renderTime: entry.renderTime,
      loadTime: entry.loadTime,
      size: entry.size,
      element: entry.element,
      url: entry.url,
    });
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

이제 “LCP가 누구인지”가 확정됐습니다. 다음은 “그 LCP가 왜 늦게 그려지는지”를 render-blocking 관점으로 파고듭니다.

2) Render‑Blocking 리소스의 정체: 무엇이 LCP를 막나?

렌더링 파이프라인에서 LCP를 늦추는 대표 원인은 다음 4가지입니다.

  1. CSS가 늦게 도착/파싱되어 렌더 트리가 확정되지 않음
  2. 동기(synchronous) JS가 메인 스레드를 점유해 스타일 계산/레이아웃/페인트가 지연
  3. 웹폰트 로딩 때문에 텍스트가 늦게 나타남(FOIT/FOUT)
  4. LCP가 이미지인데 우선순위가 낮아 늦게 다운로드(preload 미적용, lazy 오용, 네트워크 경합)

중요한 포인트는 “어떤 리소스가 render-blocking인가”가 아니라, LCP 타임라인에서 실제로 ‘막고 있는’ 리소스가 무엇인지를 증거로 찾는 것입니다.

3) Performance 탭에서 ‘막힌 구간’을 증거로 찾기

(1) LCP 직전의 긴 공백을 확대

Performance 타임라인에서 LCP 마커 직전 구간을 확대해 보면 보통 다음 중 하나가 보입니다.

  • Main 트랙에 긴 노란색(스크립트) 작업
  • Rendering 관련 작업(Style/Layout/Paint)이 뒤로 밀림
  • Network에서 CSS/JS/폰트가 늦게 끝남

(2) Main 스레드 Long Task부터 잡기

Main 트랙에서 50ms 이상 작업(Long Task)을 클릭하면 오른쪽에 Call Tree / Bottom-Up이 나옵니다.

  • 어떤 번들이 메인 스레드를 오래 잡는지
  • 그 작업이 LCP 이전에 발생하는지
  • parse/evaluate/script execution인지

여기서 흔히 나오는 패턴:

  • 번들 초기화(analytics, AB test, tag manager)
  • 큰 라이브러리 파싱(차트, 에디터 등)
  • hydration 시점의 대량 이벤트 바인딩

SPA/Next.js에서 hydration 타이밍이 문제라면, 관련 경고/원인 추적은 Next.js 14 Hydration failed 경고 10분 해결법도 같이 보면 진단 속도가 빨라집니다.

4) Network 탭으로 render‑blocking 리소스 ‘리스트업’하기

이제 네트워크에서 “렌더링을 막는 후보”를 정리합니다.

(1) CSS 필터 + 우선순위(Priority) 확인

Network 탭에서 CSS 필터를 걸고 다음을 봅니다.

  • Waterfall에서 CSS가 언제 다운로드 끝나는지
  • Priority가 High인지(대개 CSS는 High)
  • CSS가 여러 개라면 어떤 것이 늦게 끝나는지

CSS는 기본적으로 렌더링을 막습니다. 특히 다음이 치명적입니다.

  • 큰 CSS 한 방(수백 KB) + 압축/캐시 미흡
  • @import 체인(추가 요청을 연쇄적으로 발생)

(2) JS가 “Parser Blocking”인지 확인

Network에서 JS 항목을 클릭 → Initiator 또는 Timing을 확인합니다.

  • <script>defer/async 없이 head에 있으면 파서 블로킹 가능
  • 번들이 LCP 이전에 evaluate되며 Long Task를 만들면 실질적 블로킹

(3) 폰트(Fonts)와 preload 여부

LCP가 텍스트라면 폰트가 병목인 경우가 많습니다.

  • Network에서 Font 필터
  • 해당 폰트 요청이 늦게 시작되는지(대개 CSS 파싱 이후 시작)
  • preload로 앞당길 여지가 있는지

5) Coverage로 “불필요 CSS/JS”를 찾아 LCP 경합 줄이기

렌더링을 막는 리소스는 ‘크기’만 문제가 아닙니다. 초기 렌더에 필요 없는 코드가 먼저 내려와 경합을 일으키는 것이 더 흔합니다.

  1. DevTools Cmd/Ctrl + Shift + P
  2. Show Coverage 실행
  3. 새로고침

Coverage에서 확인할 것:

  • 초기 화면에서 사용되지 않는 CSS 비율이 큰 파일
  • 사용되지 않는 JS가 많은 번들(특히 vendor)

이 결과를 기반으로 “critical path에 필요한 최소만 남기기”가 다음 단계입니다.

6) 해결책 1: Critical CSS 분리 + 나머지 지연 로딩

가장 강력한 LCP 개선은 초기 렌더에 필요한 CSS만 즉시 적용하고, 나머지는 나중에 불러오는 것입니다.

(1) 핵심 스타일만 inline(주의: 과도하면 역효과)

<style>
  /* Above-the-fold에 필요한 최소 스타일만 */
  .hero { display: grid; gap: 16px; }
  .hero__title { font-size: 40px; line-height: 1.1; }
</style>
<link rel="stylesheet" href="/assets/app.css" />

(2) 비핵심 CSS를 non-blocking으로 로드

<link rel="preload" href="/assets/noncritical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/assets/noncritical.css"></noscript>

추적 포인트:

  • 적용 후 Performance에서 LCP 직전 공백이 줄었는지
  • Network에서 noncritical.css가 병목 구간에서 빠졌는지

7) 해결책 2: JS는 “LCP 이후”로 미루고, 동기를 제거하기

(1) 기본 원칙: head의 동기 스크립트 금지

<!-- 나쁨: 파서 블로킹 + 실행 타이밍이 LCP를 침범 -->
<script src="/assets/bundle.js"></script>

<!-- 좋음: HTML 파싱 후 실행(대부분의 앱 번들에 권장) -->
<script defer src="/assets/bundle.js"></script>

(2) 3rd-party 스크립트는 더 엄격하게

  • analytics, tag manager, A/B 테스트는 LCP와 무관한 경우가 대부분
  • defer 또는 사용자 상호작용 이후 로드
<script>
  // 유저가 첫 상호작용을 했을 때 로드(예시)
  const loadAnalytics = () => {
    const s = document.createElement('script');
    s.src = 'https://example.com/analytics.js';
    s.async = true;
    document.head.appendChild(s);
  };
  window.addEventListener('pointerdown', loadAnalytics, { once: true });
</script>

8) 해결책 3: LCP 이미지라면 ‘우선순위’부터 바로잡기

LCP가 hero 이미지인데 늦다면, 십중팔구 요청 시작이 늦거나(priority 낮음), 다른 리소스에 밀리는 문제입니다.

(1) lazy 로딩 오용 제거

LCP 이미지에 loading="lazy"를 걸면 오히려 늦어질 수 있습니다.

<!-- LCP 후보라면 lazy를 피하고 즉시 로드 -->
<img src="/img/hero.webp" width="1200" height="630" alt="..." loading="eager" decoding="async">

(2) preload로 요청을 앞당기기

<link rel="preload" as="image" href="/img/hero.webp" imagesrcset="/img/hero.webp 1x" imagesizes="100vw">

(3) fetchpriority 힌트(지원 브라우저에서 효과)

<img src="/img/hero.webp" fetchpriority="high" width="1200" height="630" alt="...">

적용 후 확인:

  • Network Waterfall에서 이미지 요청이 더 일찍 시작하는지
  • LCP 마커가 당겨졌는지

9) 해결책 4: 폰트가 LCP를 막으면 preload + font-display

텍스트가 LCP인데 웹폰트가 늦으면, 텍스트 페인트가 지연됩니다.

(1) font-display: swap 적용

@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: swap;
}

(2) 핵심 폰트 preload

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

주의:

  • preload 남발은 오히려 경합을 만들 수 있습니다. LCP에 직접 영향 있는 1~2개만.

10) 재측정 체크리스트: “개선”을 숫자로 증명하기

최적화는 적용보다 검증이 더 중요합니다.

로컬에서 빠르게 검증

  • DevTools Performance 재측정
  • LCP 마커 시간 비교
  • Main Long Task 감소 여부
  • Network에서 CSS/폰트/이미지 요청 시작 시점 변화

실사용자(RUM)로 검증(권장)

실제 사용자의 네트워크/디바이스는 다양합니다. 가능하면 Web Vitals를 RUM으로 수집해 “일부 환경에서만 느린” 문제를 잡아야 합니다.

// web-vitals 사용 예시
import { onLCP } from 'web-vitals';

onLCP((metric) => {
  // metric.value: ms
  // metric.id: 세션 단위 식별자
  // metric.attribution: 원인 추적에 유용
  console.log('LCP', metric);
});

11) 흔한 함정: LCP가 느린데 render-blocking이 ‘안 보일’ 때

다음 케이스는 겉으로는 render-blocking처럼 보이지만 원인이 다릅니다.

  • TTFB가 느림: HTML 자체가 늦게 오면 LCP도 늦음
  • 클라이언트에서 LCP 요소가 늦게 생성: CSR로 hero가 나중에 렌더됨
  • 레이아웃 시프트/재계산 반복: LCP 후보가 계속 바뀌며 최종 확정이 늦음
  • 메인 스레드가 프레임 드랍: 애니메이션/무거운 계산이 페인트를 계속 밀어냄

이럴 때는 “LCP 직전”이 아니라 처음부터 끝까지 타임라인을 보고, LCP element가 DOM에 등장하는 순간과 네트워크/스크립트 이벤트를 맞춰봐야 합니다.

12) 한 번에 정리: LCP 느림 → render-blocking 추적 플로우

  1. Performance에서 LCP 마커 클릭 → LCP element 확정
  2. LCP 직전 구간 확대 → Main Long Task / Rendering 지연 확인
  3. Network에서 CSS/JS/Font/Image Waterfall 확인
  4. Coverage로 불필요 리소스 비율 확인
  5. 처방 적용
    • CSS: critical 분리, @import 제거, noncritical 지연
    • JS: defer/async, 3rd-party 지연, 코드 스플리팅
    • Image: preload/fetchpriority, lazy 제거, 크기/포맷 최적화
    • Font: preload + font-display
  6. 동일 조건으로 재측정(로컬) + RUM으로 실사용자 검증

LCP 최적화는 “파일을 줄이자”가 아니라, LCP 요소가 그려지기까지의 길을 막는 것들을 증거 기반으로 제거하는 작업입니다. DevTools에서 LCP element를 먼저 고정하고, 그 직전 타임라인에서 CSS/JS/폰트/이미지 중 무엇이 실제로 렌더링을 막는지 찾아내면, 개선은 의외로 직선적으로 진행됩니다.