Published on

Next.js LCP 4초→1초 - 이미지·폰트 최적화

Authors

서버가 빠른데도 LCP가 4초대에서 내려오지 않는 경우, 범인은 대체로 LCP 후보(히어로 이미지/타이틀 영역)다운로드 지연 또는 렌더링 차단 리소스(폰트/CSS) 입니다. Next.js는 next/image, next/font 같은 도구를 제공하지만, 기본값만으로는 “가장 중요한 한 장”과 “가장 중요한 폰트”를 제대로 우선순위로 올리지 못하는 경우가 많습니다.

이 글은 LCP를 4초에서 1초대로 줄일 때 실제로 효과가 큰 이미지·폰트 최적화를 중심으로, 측정 방법부터 코드 적용, 운영 팁까지 한 번에 정리합니다.

참고: CLS가 같이 튀는 경우가 많습니다. 폰트/이미지로 레이아웃이 흔들린다면 Chrome CLS 급증 - 폰트·이미지·광고 시프트 해결도 함께 보세요.

1) 먼저 “진짜 LCP 요소”부터 확정하기

LCP 최적화는 감으로 하면 실패합니다. 먼저 크롬에서 LCP가 무엇인지 확정하세요.

Chrome DevTools로 LCP 후보 확인

  1. DevTools Performance 탭에서 기록
  2. Timings 또는 Experience 섹션에서 Largest Contentful Paint 클릭
  3. 우측 패널에서 LCP element가 어떤 노드인지 확인

대부분 아래 둘 중 하나입니다.

  • 히어로 이미지: img 또는 next/image가 렌더한 img
  • 히어로 텍스트: 큰 h1/p가 웹폰트 로딩 때문에 늦게 그려짐

Web Vitals로 실사용 LCP 수집

실험실(로컬)에서 좋아도 실사용이 나쁘면 의미가 없습니다. Next.js App Router 기준으로 web-vitals를 연결해 LCP를 수집하세요.

// app/_web-vitals.ts (예시) 또는 instrumentation/analytics 레이어
import type { Metric } from 'web-vitals';

export function reportWebVitals(metric: Metric) {
  // metric.name: 'LCP', 'CLS', 'INP'...
  // metric.value: ms
  if (metric.name === 'LCP') {
    // 예: analytics endpoint로 전송
    navigator.sendBeacon(
      '/api/vitals',
      JSON.stringify({ name: metric.name, value: metric.value, id: metric.id })
    );
  }
}

이제부터는 “LCP 요소가 이미지인지 폰트인지”에 따라 처방이 갈립니다.

2) 이미지 최적화: LCP의 80%는 히어로 이미지에서 나온다

히어로 이미지가 LCP인 경우, 목표는 단순합니다.

  • LCP 이미지 요청을 최대한 빨리 시작한다
  • 전송 바이트를 줄인다
  • 디코드/렌더 비용을 줄인다

2-1) next/image에서 LCP 이미지는 반드시 priority

next/image는 기본적으로 lazy 로딩을 사용합니다. 히어로 이미지를 lazy로 두면 LCP가 늦어지는 건 당연합니다.

// app/(home)/page.tsx
import Image from 'next/image';

export default function Home() {
  return (
    <main>
      <section>
        <Image
          src="/images/hero.webp"
          alt="서비스 소개"
          width={1200}
          height={630}
          priority
          sizes="(max-width: 768px) 100vw, 1200px"
        />
        <h1>가장 중요한 메시지</h1>
      </section>
    </main>
  );
}

핵심 포인트:

  • priority: LCP 후보라면 거의 무조건
  • width/height: CLS 방지 + 레이아웃 확정으로 렌더링 안정화
  • sizes: 모바일에서 과도한 이미지 다운로드 방지

2-2) fill을 쓸 때는 컨테이너 크기 확정

fill은 편하지만, 컨테이너가 늦게 확정되면 레이아웃 계산이 흔들리고 이미지 로딩 타이밍도 꼬일 수 있습니다.

import Image from 'next/image';

export function Hero() {
  return (
    <div style={{ position: 'relative', width: '100%', height: '420px' }}>
      <Image
        src="/images/hero.webp"
        alt="히어로"
        fill
        priority
        sizes="100vw"
        style={{ objectFit: 'cover' }}
      />
    </div>
  );
}

컨테이너에 height 또는 aspect-ratio초기부터 크기를 확정하는 게 중요합니다.

2-3) 포맷: AVIF 또는 WebP 우선, JPEG/PNG는 최소화

LCP 이미지가 JPEG/PNG면 전송 바이트가 커서 RTT가 조금만 늘어도 바로 4초대로 갑니다.

  • 가능하면 AVIF (지원 브라우저 확대 중)
  • 그 다음 WebP
  • 투명 필요 없으면 PNG 지양

Next.js 기본 이미지 최적화 파이프라인이 포맷 변환을 도와주지만, 원본이 너무 크면 변환해도 손해가 남습니다. 원본 리사이즈/크롭을 먼저 해두면 효과가 큽니다.

2-4) sizes 미설정은 “모바일 LCP 악화” 지름길

sizes가 없으면 브라우저는 큰 후보를 선택하기 쉬워져 모바일에서 과한 다운로드가 발생합니다.

<Image
  src="/images/hero.webp"
  alt="히어로"
  width={1600}
  height={900}
  priority
  sizes="(max-width: 480px) 100vw, (max-width: 1024px) 90vw, 1200px"
/>

현장에서 자주 보는 패턴:

  • 데스크톱은 괜찮은데 모바일 LCP만 나쁨
  • 원인: sizes 누락으로 모바일이 1600px 이미지를 받아버림

2-5) 원격 이미지라면 next.config와 캐시 전략 점검

원격 이미지(https://...)를 next/image로 최적화할 때는 도메인 허용과 캐시가 중요합니다.

// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
        pathname: '/**'
      }
    ],
    formats: ['image/avif', 'image/webp']
  }
};

추가 팁:

  • 이미지가 자주 바뀌지 않으면 CDN에서 Cache-Control을 길게
  • 사용자별로 달라지는 이미지(예: 개인화 배너)는 LCP 후보에서 제외하거나 크기를 과감히 줄이기

2-6) preload는 최후의 수단, 하지만 “정확히” 쓰면 강력

priority로도 부족하고, LCP 이미지가 초기 HTML 파싱 시점에 확실히 필요하다면 <link rel="preload">를 고려할 수 있습니다. 다만 잘못 쓰면 다른 리소스를 밀어내 역효과가 납니다.

App Router에서는 next/imagepriority가 일반적으로 충분합니다. 정말 필요할 때만 제한적으로 적용하세요.

3) 폰트 최적화: 텍스트가 LCP면 next/font가 정답에 가깝다

LCP 요소가 큰 헤딩 텍스트인데, 웹폰트 로딩 때문에 늦게 페인트되면 LCP가 크게 늘어납니다. 이때 핵심은 아래 3가지입니다.

  • 폰트를 렌더링 차단 리소스로 만들지 않기
  • 필요한 글리프만 내려받기(서브셋)
  • 폰트 로딩 중 레이아웃 변화를 최소화

3-1) next/font로 로컬/구글 폰트 최적화

next/font는 폰트를 빌드 타임에 최적화하고, 필요한 CSS를 자동으로 주입하며, font-display 전략도 포함합니다.

// app/layout.tsx
import './globals.css';
import { Noto_Sans_KR } from 'next/font/google';

const noto = Noto_Sans_KR({
  subsets: ['latin'],
  weight: ['400', '700'],
  display: 'swap',
  preload: true
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body className={noto.className}>{children}</body>
    </html>
  );
}

주의할 점:

  • 한글 폰트는 용량이 큽니다. 가능한 경우 subsets를 현실적으로 설계하거나, 아예 로컬 서브셋 폰트를 만드는 편이 더 큽니다.
  • weight를 여러 개 열면 그만큼 요청 수와 용량이 늘어납니다. 실제로 쓰는 굵기만 남기세요.

3-2) 한글 폰트는 “서브셋 제작”이 LCP를 바꾼다

한글 폰트 전체를 내려받으면 수백 KB에서 수 MB까지도 갑니다. 히어로 텍스트가 웹폰트를 기다리면 LCP는 쉽게 4초가 됩니다.

실무에서는 다음 중 하나를 선택합니다.

  • 첫 화면에는 시스템 폰트 스택을 쓰고, 본문에서만 웹폰트 적용
  • 히어로에 필요한 글자만 서브셋 폰트로 제공

예: fonttools로 서브셋(개념 예시)

# 예시: 필요한 글자만 포함한 woff2 생성
pyftsubset "NotoSansKR-Regular.otf" \
  --text="서비스 소개 지금 시작" \
  --flavor=woff2 \
  --output-file="NotoSansKR-subset.woff2"

그리고 next/font/local로 로드합니다.

import localFont from 'next/font/local';

const heroFont = localFont({
  src: './fonts/NotoSansKR-subset.woff2',
  display: 'swap',
  preload: true
});

export function HeroTitle() {
  return <h1 className={heroFont.className}>서비스 소개 지금 시작</h1>;
}

서브셋의 장점:

  • 전송 바이트 급감
  • 폰트 로딩 시간이 짧아져 텍스트 LCP가 크게 개선

3-3) font-display: swap만으로는 부족할 때: 메트릭 오버라이드

swap은 FOIT를 줄이지만, 폴백 폰트와 웹폰트의 폭/높이가 달라 CLS가 생길 수 있습니다. next/font는 폰트 메트릭 오버라이드로 이를 완화할 수 있습니다.

  • 결과적으로 LCP뿐 아니라 CLS도 안정화

CLS까지 같이 튜닝하려면 위에서 언급한 내부 글을 함께 참고하세요.

4) “이미지 + 폰트” 조합에서 LCP를 1초대로 만드는 체크리스트

4-1) 히어로 섹션은 최대한 단순하게

  • 히어로 영역에 대형 이미지 1장 + 텍스트 정도로 제한
  • 초기 렌더에 불필요한 슬라이더/비디오/애니메이션 제거
  • 3rd-party 스크립트(채팅/태그)는 가능한 지연 로드

4-2) CSS가 LCP를 막고 있지 않은지 확인

  • 거대한 CSS 번들이 초기 렌더를 지연시키기도 합니다.
  • App Router에서 컴포넌트 단위 CSS를 쓰더라도, 전역 CSS에 모든 것을 몰아넣으면 역효과가 납니다.

4-3) 서버/캐시가 느리면 이미지·폰트 최적화가 반쪽

LCP는 네트워크 왕복에 민감합니다.

  • 정적 자산은 CDN 캐시
  • Cache-Control 설정 점검
  • TTFB가 나쁘면 LCP도 같이 나빠짐

운영 환경에서 인증/리다이렉트가 얽혀 초기 HTML이 늦어지는 경우도 있습니다. 인증 콜백이나 상태 검증 문제로 불필요한 왕복이 생기면 성능에도 악영향이 나니, 관련 이슈가 있다면 Next.js 14 OAuth 콜백 400 invalid_state 해결법처럼 “초기 진입 플로우”도 함께 점검하세요.

5) 실전 예시: LCP 4초에서 1초대로 떨어지는 전형적인 변경

아래는 현장에서 자주 나오는 “전/후”입니다.

변경 전(문제 패턴)

  • 히어로 이미지가 img 태그로 삽입, lazy 로딩
  • 모바일에서도 큰 이미지 다운로드
  • 한글 웹폰트 전체를 첫 화면에서 로드
export function HeroBad() {
  return (
    <section>
      <img src="/images/hero.jpg" alt="hero" />
      <h1 style={{ fontFamily: 'MyKoreanWebFont' }}>서비스 소개</h1>
    </section>
  );
}

변경 후(개선 패턴)

  • next/image + priority + sizes
  • webp 또는 avif
  • 히어로 텍스트는 서브셋 폰트 또는 시스템 폰트 우선
import Image from 'next/image';
import localFont from 'next/font/local';

const heroFont = localFont({
  src: './fonts/HeroSubset.woff2',
  display: 'swap',
  preload: true
});

export function HeroGood() {
  return (
    <section>
      <Image
        src="/images/hero.webp"
        alt="hero"
        width={1200}
        height={630}
        priority
        sizes="(max-width: 768px) 100vw, 1200px"
      />
      <h1 className={heroFont.className}>서비스 소개</h1>
    </section>
  );
}

이 조합은 특히 모바일에서 효과가 큽니다. LCP가 4초대였던 페이지가, 이미지 전송량 감소 + 요청 우선순위 상승 + 폰트 대기 감소로 1초대까지 내려오는 케이스가 많습니다.

6) 검증: “좋아진 것처럼 보이는” 함정을 피하는 법

최적화 후에는 다음을 꼭 확인하세요.

  • DevTools Network에서 LCP 이미지가 초기에 요청되는지(워터폴 상 앞쪽)
  • LCP 이미지 응답 헤더에 캐시가 제대로 설정됐는지
  • 모바일 시뮬레이션(느린 4G)에서 LCP가 유지되는지
  • 실사용 데이터(필드 데이터)에서 LCP 분포가 개선되는지

특히 priority를 남발하면 다른 리소스가 밀려 전체 UX가 나빠질 수 있습니다. LCP 후보 1개(필요하면 2개) 정도만 엄격히 우선순위를 주는 게 안전합니다.

마무리

Next.js에서 LCP를 4초에서 1초대로 줄이는 가장 확실한 지름길은 “LCP 요소를 빨리 시작하고, 가볍게 만들고, 기다리지 않게 만드는 것”입니다.

  • 히어로 이미지라면 next/imageprioritysizes, 그리고 포맷/원본 용량 최적화
  • 텍스트라면 next/font 기반 폰트 로딩 전략과 한글 서브셋

이 두 축만 제대로 잡아도 LCP는 체감될 정도로 내려가고, 부수적으로 CLS까지 안정화되는 경우가 많습니다.