Published on

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

Authors

서빙은 빠른데 사용자 체감은 왜 느릴까요? Next.js 앱에서 가장 흔한 함정은 네트워크가 아니라 LCP(Largest Contentful Paint)를 결정하는 “가장 큰 콘텐츠”가 늦게 준비되는 구조입니다. 특히 App Router 환경에서는 RSC(React Server Components)와 스트리밍, 이미지 최적화, 폰트 로딩이 서로 얽혀 LCP를 쉽게 3~5초대로 끌어올립니다.

이 글은 LCP 4초대에서 1초대로 내릴 때 실제로 효과가 컸던 순서대로, 무엇을 측정하고 어떤 코드를 바꿔야 하는지 정리합니다. (환경은 Next.js 14+ App Router 기준)

관련해서 이미지·폰트가 LCP를 늦추는 더 근본 원인은 아래 글도 함께 보면 좋습니다.

1) 먼저 LCP 후보를 특정하고, “무엇이 막는지” 쪼개기

LCP는 보통 히어로 이미지, H1 텍스트 블록, 첫 카드 리스트 같은 큰 요소가 후보가 됩니다. 개선을 시작하기 전에 해야 할 일은 단 하나입니다.

  • LCP 요소가 무엇인지 확정
  • 그 요소가 늦는 이유가 서버(TTFB)인지, 렌더 차단(CSS/폰트)인지, 리소스 우선순위(이미지 preload)인지 분리

Chrome DevTools에서 확인할 포인트

  1. Performance 패널에서 LCP 마커 클릭
  2. 해당 요소(이미지 또는 텍스트)의 “요청 시작 시점”과 “페인트 시점” 사이에 무엇이 끼어있는지 확인
  3. Network에서 해당 리소스의 Priority, Initiator, Waterfall 확인

LCP를 4초에서 1초로 줄인 케이스에서 가장 흔한 병목은 아래 3가지 조합이었습니다.

  • 서버 컴포넌트에서 느린 데이터 fetch로 LCP 요소가 늦게 생성됨
  • LCP 이미지가 priority가 아니어서 늦게 다운로드됨
  • 폰트가 늦게 적용되면서 텍스트 LCP가 늦거나 레이아웃이 흔들림

2) RSC: LCP를 만드는 경로를 “가볍게” 만들기

App Router에서는 기본이 서버 컴포넌트입니다. 즉, 페이지의 첫 HTML이 만들어지는 경로에서 데이터 fetch가 늘어날수록 LCP가 밀립니다. 핵심은 “LCP를 구성하는 최소 UI만 빠르게” 그리고 “나머지는 스트리밍/지연”입니다.

2-1) LCP 영역을 먼저 렌더링하고, 나머지는 Suspense로 뒤로 보내기

예를 들어 홈에서 히어로(이미지+타이틀)가 LCP인데, 아래 섹션(추천 리스트, 리뷰, 통계)을 같은 서버 컴포넌트 트리에서 기다리면 LCP가 같이 밀립니다.

app/page.tsx

import { Suspense } from "react";
import Hero from "./_components/Hero";
import BelowFold from "./_components/BelowFold";

export default async function Page() {
  // Hero는 가능한 한 데이터 의존성을 최소화
  // 아래 폴드는 Suspense로 스트리밍
  return (
    <main>
      <Hero />
      <Suspense fallback={<div style={{ height: 400 }} />}> 
        <BelowFold />
      </Suspense>
    </main>
  );
}

포인트는 Hero가 LCP를 담당한다면 Hero는 “즉시 렌더링 가능한 정보”만 사용하게 만들고, 무거운 fetch는 BelowFold로 보내는 것입니다.

2-2) 서버 fetch가 캐시 때문에 느리거나, 반대로 갱신이 안 되는 문제도 같이 점검

RSC의 fetch는 캐시 전략에 따라 체감이 크게 달라집니다.

  • 매 요청마다 느린 외부 API를 호출하면 TTFB가 늘고 LCP가 같이 늘어남
  • 반대로 캐시가 과하게 걸리면 데이터가 안 바뀌어 UX 문제가 생김

캐시로 인해 “데이터가 안 갱신되는” 이슈를 겪고 있다면 아래 글을 참고해, revalidate 또는 cache: "no-store"를 의도적으로 설계하세요.

app/_lib/api.ts

export async function getHeroData() {
  // LCP에 영향이 큰 데이터라면
  // 1) 캐시가 가능한 형태로 만들거나
  // 2) 정말 실시간이 필요하면 no-store로 명시
  const res = await fetch("https://api.example.com/hero", {
    // cache: "no-store",
    next: { revalidate: 60 },
  });

  if (!res.ok) throw new Error("Failed to fetch hero");
  return res.json();
}

2-3) 클라이언트 컴포넌트 경계를 LCP 위에 올리지 않기

"use client"는 나쁜 게 아니라 “위치”가 문제입니다. LCP 요소(히어로 이미지, H1 텍스트)가 클라이언트 컴포넌트 안에 있으면, 브라우저가 JS를 다운로드하고 실행한 뒤에야 제대로 표시되는 경우가 생깁니다.

권장 패턴:

  • LCP 영역은 서버 컴포넌트로 유지
  • 상호작용(슬라이더, 탭, 위시리스트 버튼)만 작은 클라이언트 컴포넌트로 분리

app/_components/Hero.tsx (서버 컴포넌트)

import Image from "next/image";
import HeroCta from "./HeroCta";

export default function Hero() {
  return (
    <section>
      <h1>가장 중요한 메시지는 서버에서 바로</h1>
      <Image
        src="/images/hero.webp"
        alt="Hero"
        width={1200}
        height={630}
        priority
        sizes="(max-width: 768px) 100vw, 1200px"
      />
      <HeroCta />
    </section>
  );
}

app/_components/HeroCta.tsx (클라이언트 컴포넌트)

"use client";

export default function HeroCta() {
  return (
    <button onClick={() => console.log("click")}>시작하기</button>
  );
}

3) 이미지: LCP 이미지에 priority와 올바른 sizes를 주기

Next.js에서 LCP가 이미지인 경우, next/image 설정이 체감 성능을 좌우합니다. 실무에서 가장 큰 차이를 만든 체크리스트는 아래 4개였습니다.

3-1) LCP 이미지에는 priority를 명시

priority는 사실상 preload 힌트입니다. 히어로 이미지가 LCP라면 거의 무조건 필요합니다.

<Image
  src="/images/hero.webp"
  alt="Hero"
  width={1200}
  height={630}
  priority
/>

주의: priority를 남발하면 오히려 다른 리소스가 밀립니다. “딱 1장” 수준으로 제한하세요.

3-2) sizes가 없으면 모바일에서 과한 해상도를 받기 쉽다

sizes를 주지 않으면 브라우저가 큰 이미지를 선택해 다운로드하는 일이 잦습니다. 특히 히어로가 fill을 쓰는 경우 더 중요합니다.

<Image
  src="/images/hero.webp"
  alt="Hero"
  fill
  priority
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 90vw, 1200px"
  style={{ objectFit: "cover" }}
/>

3-3) LCP 이미지는 가능하면 정적 경로 또는 CDN으로

  • 로컬 정적 파일: 배포 환경에서 캐시가 잘 먹고 안정적
  • 외부 이미지: remotePatterns 설정 + 이미지 서버 응답 헤더(캐시)까지 같이 최적화 필요

next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "cdn.example.com",
        pathname: "/assets/**",
      },
    ],
  },
};

module.exports = nextConfig;

3-4) blur placeholder는 “LCP 이미지”에는 신중

placeholder="blur"는 UX에는 좋지만, 경우에 따라 LCP 판정을 흐리거나(블러가 먼저 LCP로 잡히고 원본이 다시 잡히는 등) 디코딩 비용이 늘 수 있습니다. 히어로처럼 큰 이미지라면 A/B로 확인하고 적용하세요.

4) 폰트: 텍스트 LCP를 늦추는 가장 조용한 원인

텍스트가 LCP인 페이지에서는 폰트 로딩 정책이 곧 LCP입니다. Next.js에서는 next/font를 쓰면 많은 문제가 자동으로 해결되지만, 설정을 잘못하면 여전히 느릴 수 있습니다.

4-1) next/font로 로컬 폰트 + 서브셋 + display: swap

app/layout.tsx

import localFont from "next/font/local";

const pretendard = localFont({
  src: [
    {
      path: "../public/fonts/Pretendard-Regular.woff2",
      weight: "400",
      style: "normal",
    },
    {
      path: "../public/fonts/Pretendard-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>
  );
}

핵심:

  • 필요한 weight만 포함(예: 400, 700만)
  • woff2 우선
  • display: "swap"으로 FOIT 방지

4-2) 폰트 개수를 줄이는 게 가장 큰 최적화

실무에서 흔한 구성:

  • 본문 폰트 1개
  • 제목 폰트 1개
  • 아이콘 폰트 1개

이 중 아이콘 폰트가 남아있다면 SVG 스프라이트나 개별 SVG로 전환하는 것만으로도 초기 폰트 로딩 부담이 크게 줄어듭니다.

4-3) 폰트로 인한 CLS도 같이 잡아야 LCP가 안정된다

폰트가 늦게 적용되면 레이아웃이 바뀌면서 LCP가 흔들릴 수 있습니다. font-size-adjust나 비슷한 메트릭의 대체 폰트를 쓰는 것도 방법이지만, 가장 현실적인 방법은:

  • 대체 폰트 스택을 실제와 비슷하게
  • 굵기 다양성을 줄여 첫 렌더에서의 변화를 최소화

5) 최적화 적용 순서: “큰 것부터”

LCP 4초대에서 1초대로 줄일 때, 보통 아래 순서가 가장 효율적이었습니다.

  1. LCP 요소가 무엇인지 확정(이미지인지 텍스트인지)
  2. LCP 영역을 서버 컴포넌트로 유지하고, 무거운 섹션을 Suspense로 분리
  3. LCP 이미지에 priority + 올바른 sizes
  4. 폰트는 next/font로 정리하고 weight/종류를 줄이기
  5. 캐시 전략을 재설계해 TTFB와 데이터 신선도를 균형 있게

6) 체크리스트: 배포 전 마지막 점검

  • LCP 이미지가 next/image이고 priority가 켜져 있는가
  • sizes가 실제 레이아웃과 일치하는가
  • LCP 영역이 "use client" 컴포넌트 안에 들어가 있지 않은가
  • 초기 렌더에 필요 없는 데이터 fetch가 상단 트리에서 기다리고 있지 않은가
  • 폰트 weight가 과도하지 않은가(특히 300, 500, 600까지 모두 쓰는 경우)
  • 외부 폰트/CDN이 있다면 캐시 헤더와 지역(리전) 지연이 없는가

마무리

Next.js에서 LCP는 단일 설정으로 해결되기보다, RSC 렌더 경로(무엇을 먼저 그릴지), 이미지 우선순위(무엇을 먼저 받을지), 폰트 정책(텍스트를 언제 확정할지)이 함께 맞물려 결정됩니다.

가장 효과적인 접근은 “LCP를 만드는 최소 경로를 먼저 빠르게 만들고(서버 컴포넌트 + 스트리밍), LCP 리소스를 최우선으로 내려받게 하며(이미지 priority), 텍스트 렌더를 막지 않게(폰트 swap + 경량화)” 순서대로 적용하는 것입니다. 이 3가지만 제대로 해도 4초에서 1초대로 내려오는 경우가 많습니다.