- Published on
Next.js LCP 개선 - 이미지·폰트·RSC 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트와 스트리밍이 기본이 된 Next.js(App Router)에서는 “그냥 잘 만들면 빨라지겠지”가 잘 통하지 않습니다. 특히 LCP(Largest Contentful Paint)는 사용자가 처음으로 의미 있는 큰 콘텐츠(대개 히어로 이미지/헤딩/카드) 를 보는 순간을 측정하기 때문에, 이미지/폰트/서버 렌더링 경로 중 하나만 삐끗해도 점수가 크게 흔들립니다.
이 글은 Next.js에서 LCP를 실제로 끌어내리는 3대 축(이미지, 폰트, RSC)을 중심으로, 어떤 증상이 왜 생기고, 어떤 설정/코드로 고칠 수 있는지를 App Router 기준으로 정리합니다.
참고로 렌더링/리렌더가 과도해 LCP가 늦어지는 케이스도 흔합니다. 클라이언트 컴포넌트가 불필요하게 커지거나 상태 변화가 초기 렌더를 막는다면, 아래 글의 프로파일링 접근도 함께 보면 좋습니다.
LCP를 먼저 “측정 가능”하게 만들기
최적화는 감으로 하면 실패합니다. LCP는 환경에 따라 변동이 크므로, 최소한 아래 3가지는 고정하고 비교하세요.
- 측정 도구 분리: Lighthouse(실험실) + CrUX/웹바이탈(실사용)
- 네트워크 조건 고정: DevTools에서
Fast 4G또는Slow 4G로 통일 - LCP 요소 확인: DevTools
Performance탭에서Largest Contentful Paint이벤트 클릭 후 대상 노드 확인
실전에서 가장 중요한 건 “LCP 후보가 무엇인지”입니다.
- 히어로 이미지가 LCP면: 이미지 최적화가 1순위
- 큰 H1 텍스트가 LCP면: 폰트 로딩/FOIT/FOUT가 1순위
- 카드 리스트/상품 그리드가 LCP면: RSC 데이터 패치/스트리밍 경로가 1순위
1) 이미지가 LCP인 경우: next/image를 “제대로” 쓰기
LCP가 이미지일 때의 목표는 단순합니다.
- 정확한 크기를 브라우저가 즉시 알게 해서 레이아웃 흔들림을 없애고
- 가장 먼저 다운로드되게 우선순위를 올리며
- 불필요한 바이트(과도한 해상도/포맷)를 줄입니다.
히어로 이미지는 priority + sizes + 명시적 레이아웃
fill을 쓰더라도 sizes를 안 주면 브라우저가 큰 이미지로 받아 LCP가 악화될 수 있습니다.
// app/page.tsx
import Image from 'next/image'
export default function Home() {
return (
<main>
<section style={{ position: 'relative', height: 420 }}>
<Image
src="/hero.jpg"
alt="제품 히어로"
fill
priority
sizes="(max-width: 768px) 100vw, 1200px"
style={{ objectFit: 'cover' }}
/>
</section>
<h1>가장 중요한 메시지</h1>
</main>
)
}
priority: LCP 후보는 거의 항상priority대상입니다.sizes: 반응형 레이아웃에서 실제 표시 크기를 알려 과다운로드를 막습니다.fill을 쓸 때도 부모의height가 명확해야 레이아웃 계산이 즉시 됩니다.
“보이지 않는 이미지”가 LCP를 잡아먹는 경우
아래 패턴은 LCP를 악화시키기 쉽습니다.
- 첫 화면에 안 보이는데
priority를 줌 - 슬라이더/캐러셀에서 여러 장을 동시에
priority처리 - CSS로 숨겨진 요소가 큰 이미지로 렌더 트리에 남아 LCP 후보가 됨
원칙: 첫 화면에서 실제로 보이는 1장만 priority.
원격 이미지 최적화: remotePatterns와 캐시
외부 CDN 이미지를 next/image로 최적화하려면 설정이 필요합니다.
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/**',
},
],
},
}
추가로, 원격 이미지의 응답 헤더에 Cache-Control이 짧으면 LCP가 환경에 따라 출렁입니다. 가능하다면 CDN에서 장기 캐시(예: public, max-age=31536000, immutable)를 주고, 파일명에 해시를 포함하세요.
이미지 포맷/품질: AVIF/WebP와 quality
- AVIF가 가장 작지만 인코딩 비용/호환성 고려 필요
- WebP는 범용적
next/image는 기본적으로 최적화를 해주지만, 원본이 너무 크면 변환을 해도 비용이 큽니다. 원본 자체를 적정 해상도로 준비하고, 필요하면 quality를 낮추는 게 체감이 큽니다.
2) 텍스트가 LCP인 경우: 폰트 로딩 전략이 승부처
LCP가 큰 헤딩 텍스트로 잡히는 사이트는 많습니다. 이때 LCP를 늦추는 주범은 보통 아래 중 하나입니다.
- FOIT(폰트가 로딩될 때까지 텍스트가 안 보임)
- 폰트 파일이 너무 큼(서브셋/가중치 과다)
- CSS가 렌더를 막음(외부 CSS,
@import, 느린 폰트 서버)
next/font로 로컬 폰트 + 서브셋 + display: swap
App Router에서는 next/font를 우선 고려하세요. 폰트 CSS 주입과 프리로드를 Next가 관리해주며, 실수 확률이 줄어듭니다.
// app/layout.tsx
import localFont from 'next/font/local'
const pretendard = localFont({
src: [
{
path: '../public/fonts/Pretendard-Subset.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/Pretendard-Subset-Bold.woff2',
weight: '700',
style: 'normal',
},
],
display: 'swap',
preload: true,
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" className={pretendard.className}>
<body>{children}</body>
</html>
)
}
핵심 포인트:
- 서브셋 폰트를 직접 만들면(한글은 특히) LCP가 눈에 띄게 개선됩니다.
- 필요한
weight만 포함하세요. 300/400/500/600/700을 다 넣으면 폰트만으로 초기 네트워크가 터집니다. display: swap은 LCP를 지키는 데 유리합니다(대신 FOUT 가능).
Google Fonts를 쓰더라도 “가중치 최소화”
next/font/google도 가능하지만, 여전히 “가중치/서브셋”이 핵심입니다.
import { Noto_Sans_KR } from 'next/font/google'
const noto = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
})
주의: 한글 서브셋을 광범위하게 가져오면 파일이 커질 수 있습니다. 실제 서비스에서는 로컬 서브셋을 더 선호합니다.
폰트가 LCP를 “가로채는” 상황 줄이기
- 첫 화면에 과한 타이포(초대형, 굵은 웨이트)를 쓰면 폰트 다운로드가 LCP에 직접 영향
- 가능하면 히어로의 핵심 문구는 시스템 폰트로 먼저 보여주고, 아래 영역부터 커스텀 폰트를 적용하는 전략도 있습니다
3) RSC/스트리밍이 LCP를 늦추는 경우: “상단 경로”를 얇게
App Router에서 서버 컴포넌트(RSC)는 기본적으로 빠르지만, 다음 조건이 겹치면 LCP가 오히려 느려질 수 있습니다.
- 상단(Above the fold)에서 느린 데이터 패치가 발생
Suspense경계가 잘못 잡혀 중요 콘텐츠가 fallback 뒤로 밀림dynamic/no-store남발로 캐시 이점을 버림- 클라이언트 컴포넌트가 커서 hydration이 병목
상단 UI는 “즉시 렌더 가능한 뼈대”로 분리
상단의 LCP 후보(히어로, H1, 가격/CTA)는 가능한 한 데이터 의존도를 낮추고 빠르게 렌더하세요.
// app/page.tsx (Server Component)
import { Suspense } from 'react'
import Hero from './ui/Hero'
import ProductGrid from './ui/ProductGrid'
import ProductGridSkeleton from './ui/ProductGridSkeleton'
export default async function Page() {
return (
<main>
<Hero />
{/* LCP와 덜 관련된 영역을 Suspense로 뒤로 보냄 */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
</main>
)
}
여기서 중요한 건 Hero가 느린 fetch를 하지 않도록 설계하는 것입니다. ProductGrid처럼 아래 영역에서 데이터가 필요하면 Suspense로 감싸 스트리밍 이점을 얻습니다.
서버 fetch 캐싱을 의도적으로 설계하기
Next.js의 fetch는 기본적으로 캐시/재검증 전략을 가질 수 있습니다. LCP가 걸린 상단 데이터는 “매 요청마다 느리게 가져오기”를 피해야 합니다.
// app/lib/api.ts
export async function getFeatured() {
const res = await fetch('https://api.example.com/featured', {
next: { revalidate: 60 },
})
if (!res.ok) throw new Error('failed')
return res.json()
}
revalidate: 60은 60초 동안 캐시된 응답을 활용해 TTFB 및 LCP 변동을 줄입니다.- 반대로
cache: 'no-store'는 정말 실시간이 필요한 곳에만 제한적으로 사용하세요.
클라이언트 컴포넌트 과다: LCP 이후를 갉아먹고 “체감”을 망친다
엄밀히 말하면 LCP는 페인트 지표지만, 실사용 체감은 hydration/인터랙션까지 포함됩니다. 상단에 거대한 클라이언트 컴포넌트를 두면 초기 JS가 커지고, 메인 스레드가 바빠져 LCP 타이밍도 간접적으로 악화될 수 있습니다.
원칙:
- 상단은 서버 컴포넌트 중심
- 상호작용이 필요한 최소 조각만
use client - 차트/에디터/지도 같은 무거운 위젯은 아래로 내리거나 동적 로딩
// app/ui/HeavyWidget.tsx
'use client'
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), {
ssr: false,
loading: () => <div style={{ height: 240 }}>Loading chart…</div>,
})
export default function HeavyWidget() {
return <Chart />
}
ssr: false는 남발하면 SEO/초기 렌더에 불리하지만, LCP와 무관한 하단 위젯에는 유효한 카드입니다.
Suspense 경계 실수: 중요한 블록을 fallback으로 가두지 말기
아래처럼 페이지 전체를 큰 Suspense로 감싸면, 느린 데이터 하나 때문에 상단 LCP 후보까지 fallback이 되어 LCP가 늦어집니다.
// 안 좋은 예시(개념 코드)
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<div>Loading…</div>}>
{/* 여기 안에 LCP 후보가 들어가면 위험 */}
{/* ... */}
</Suspense>
)
}
경계는 “느린 부분만” 최소 단위로 자르세요.
실전 체크리스트: LCP를 빠르게 줄이는 순서
1순위: LCP 후보가 이미지면
next/image사용- LCP 이미지에
priority sizes지정- 원본 해상도/품질 재점검
- CDN 캐시 헤더 점검
1순위: LCP 후보가 텍스트면
next/font로 전환- 서브셋/가중치 최소화
display: swap- 첫 화면 타이포 과다 여부 점검
1순위: LCP 후보가 “데이터 블록”이면
- 상단 데이터 fetch 제거/캐시(
revalidate) 적용 Suspense경계 재설계(중요 블록은 밖으로)- 클라이언트 컴포넌트 최소화, 무거운 위젯 분리
마무리: LCP는 “가장 큰 것”을 가장 먼저 보여주는 게임
Next.js에서 LCP 최적화는 결국 가장 큰 요소가 무엇인지 확정하고, 그 요소가 화면에 나타나는 경로(네트워크, 렌더링, 폰트, 이미지)를 가장 짧고 안정적으로 만드는 작업입니다.
- 이미지 LCP는
priority와sizes가 승부처 - 텍스트 LCP는 폰트 서브셋과
display: swap이 승부처 - RSC LCP는 상단 경로를 얇게, 느린 부분만
Suspense로 스트리밍하는 게 승부처
최적화 후에는 Lighthouse 점수만 보지 말고, DevTools에서 LCP 이벤트의 대상이 바뀌었는지(예: 이미지에서 텍스트로)도 확인하세요. LCP 후보가 바뀌면 다음 병목도 바뀌고, 그게 “다음 20점”의 출발점이 됩니다.