- Published on
Chrome LCP 느림? Render‑Blocking 리소스 추적법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 응답(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 요소 확인
- Chrome에서 페이지 열기
F12→ Performance 탭Record후 새로고침(또는 재현 동작)- 타임라인 상단의 LCP 마커 클릭
- 오른쪽/하단 패널에서 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가지입니다.
- CSS가 늦게 도착/파싱되어 렌더 트리가 확정되지 않음
- 동기(synchronous) JS가 메인 스레드를 점유해 스타일 계산/레이아웃/페인트가 지연
- 웹폰트 로딩 때문에 텍스트가 늦게 나타남(FOIT/FOUT)
- 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 경합 줄이기
렌더링을 막는 리소스는 ‘크기’만 문제가 아닙니다. 초기 렌더에 필요 없는 코드가 먼저 내려와 경합을 일으키는 것이 더 흔합니다.
- DevTools
Cmd/Ctrl + Shift + P Show Coverage실행- 새로고침
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 추적 플로우
- Performance에서 LCP 마커 클릭 → LCP element 확정
- LCP 직전 구간 확대 → Main Long Task / Rendering 지연 확인
- Network에서 CSS/JS/Font/Image Waterfall 확인
- Coverage로 불필요 리소스 비율 확인
- 처방 적용
- CSS: critical 분리, @import 제거, noncritical 지연
- JS: defer/async, 3rd-party 지연, 코드 스플리팅
- Image: preload/fetchpriority, lazy 제거, 크기/포맷 최적화
- Font: preload + font-display
- 동일 조건으로 재측정(로컬) + RUM으로 실사용자 검증
LCP 최적화는 “파일을 줄이자”가 아니라, LCP 요소가 그려지기까지의 길을 막는 것들을 증거 기반으로 제거하는 작업입니다. DevTools에서 LCP element를 먼저 고정하고, 그 직전 타임라인에서 CSS/JS/폰트/이미지 중 무엇이 실제로 렌더링을 막는지 찾아내면, 개선은 의외로 직선적으로 진행됩니다.