테마 전환

Next.js Core Web Vitals 최적화 실전: LCP/FCP/CLS 완벽 가이드

Easton editorial illustration: state-management shelf

지난달 한 이커머스 프로젝트의 성능 최적화를 맡았습니다. Lighthouse 점수는 줄곧 65점 안팎이었고 LCP(최대 콘텐츠 렌더링)는 3.5초 전후를 오갔습니다. 사장님은 사용자 이탈률이 매우 높고 전환율도 좀처럼 오르지 않는다고 했습니다.

2주 동안 Core Web Vitals를 체계적으로 최적화한 결과, Lighthouse 점수는 95점까지 올랐고 LCP는 1.2초로 낮아졌습니다. 더 중요한 것은 전환율이 28% 향상됐다는 점입니다. 성능 최적화는 단순한 기술 지표가 아니라 실제 비즈니스 가치로 이어집니다.

2025년 최신 데이터에 따르면 Google의 Core Web Vitals 기준을 충족한 웹사이트는 47%에 불과합니다. 성능 저하는 매출, 순위, 전환율에서 8~35%의 손실을 초래할 수 있습니다. 과장이 아니라 실제 비즈니스 비용입니다.

이 글에서는 Next.js 성능을 최적화하며 쌓은 실전 경험을 공유합니다. 다음 내용을 배울 수 있습니다.

  • 세 가지 핵심 지표(LCP, INP, CLS)의 구체적인 최적화 방법
  • 바로 복사해 쓸 수 있는 10개 이상의 코드 예제
  • 가장 흔히 빠지는 성능 함정 5가지
95점
Lighthouse 최적화 후
65점에서 향상
1.2초
LCP 최적화 후
3.5초에서 단축
28%
전환율 향상
성능 최적화가 만든 비즈니스 가치
47%
기준 충족 사이트 비율
2025년 Core Web Vitals 데이터
Source: 실전 데이터

2025년 Core Web Vitals 최신 변화

최적화를 시작하기 전에 현재의 평가 기준부터 정확히 알아야 합니다. 아직도 많은 글이 FID(최초 입력 지연)를 설명하지만, 이 지표는 사실 2024년 3월에 폐기됐습니다.

현재의 세 가지 핵심 지표

현재 Google은 다음 세 지표에 중점을 둡니다.

  1. LCP (Largest Contentful Paint) - 최대 콘텐츠 렌더링

    • 목표: ≤ 2.5초
    • 로딩 성능 측정
    • Lighthouse 점수에서 25% 차지
  2. INP (Interaction to Next Paint) - 상호작용 후 다음 페인트

    • 목표: ≤ 200ms
    • 상호작용 응답 속도 측정
    • 2024년 3월에 FID를 대체한 새 지표
  3. CLS (Cumulative Layout Shift) - 누적 레이아웃 이동

    • 목표: < 0.1
    • 시각적 안정성 측정

FCP(최초 콘텐츠 렌더링)는 더 이상 핵심 지표가 아니지만 사용자의 첫인상에 영향을 주기 때문에 여전히 중요합니다.

왜 이 지표들에 주목해야 할까요?

웹사이트를 열고 버튼을 누르려는 순간 갑자기 페이지가 움직여 엉뚱한 곳을 클릭한 적이 있나요? 이것이 CLS가 나쁠 때 나타나는 현상입니다.

또는 사이트에 들어가 몇 초 동안 흰 화면만 바라보며 ‘인터넷이 끊겼나?’라고 생각한 적도 있을 것입니다. 이것은 LCP가 너무 느린 경우입니다.

Chrome 팀의 데이터에 따르면 최상위 웹사이트의 평균 LCP는 약 1,220ms입니다. 사이트의 LCP가 2.5초를 넘는다면 이미 많은 경쟁사보다 뒤처진 것입니다.

LCP 최적화 - 가장 큰 콘텐츠 요소를 빠르게 표시하기

LCP는 제가 최적화하면서 가장 많은 시간을 들인 지표이자 개선 효과가 가장 뚜렷했던 지표입니다. 지금부터 단계별 최적화 방법을 살펴보겠습니다.

1단계: LCP 요소 찾기

최적화에 앞서 어떤 요소가 LCP인지 알아야 합니다. 일반적으로 LCP 요소는 다음과 같습니다.

  • 첫 화면의 hero 이미지
  • 동영상 썸네일
  • 큰 제목 또는 텍스트 블록

빠르게 확인하는 방법:

  1. Chrome DevTools(F12)를 엽니다.
  2. Ctrl+Shift+P를 누릅니다(Mac에서는 Cmd+Shift+P).
  3. “Show Rendering”을 입력합니다.
  4. “Core Web Vitals”를 선택합니다.
  5. 페이지를 새로고침하면 오른쪽 위에 LCP 요소가 표시됩니다.

제가 맡았던 이커머스 프로젝트에서는 홈페이지의 메인 비주얼 이미지가 LCP 요소였습니다. 2MB짜리 JPG 한 장이 문제의 원인이었습니다.

이미지 최적화: next/image를 올바르게 사용하는 방법

Next.js의 Image 컴포넌트만 쓰면 모든 문제가 해결된다고 생각하는 사람이 많지만 그렇지 않습니다. 저도 처음에는 그렇게 생각했지만 LCP는 여전히 느렸습니다.

우선순위 설정이 핵심입니다

LCP 이미지에는 ‘이 이미지는 중요하니 먼저 로드하라’고 브라우저에 명확히 알려야 합니다. Next.js는 두 가지 방법을 제공합니다.

Next.js 13~15(주요 버전):

import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero-image.jpg"
      width={1200}
      height={630}
      priority  // 핵심! 브라우저에 우선 로드를 지시
      fetchPriority="high"  // 이중 안전장치
      alt="제품 메인 비주얼"
    />
  );
}

Next.js 16+(최신 버전):

Next.js 16에서는 priority 속성이 폐기되었으며 다음과 같이 작성합니다.

<Image
  src="/hero-image.jpg"
  width={1200}
  height={630}
  loading="eager"  // 지연 로드 없이 즉시 로드
  fetchPriority="high"  // 높은 우선순위
  alt="제품 메인 비주얼"
/>

왜 속성을 두 개나 설정해야 하는지 궁금할 수 있습니다. priority는 preload 태그를 자동으로 추가하고, fetchPriority는 브라우저에 리소스의 중요도를 알려 줍니다. 둘을 함께 사용하면 효과가 가장 좋습니다.

잘못된 작성법과 비교해 보겠습니다.

// ❌ 잘못된 예: 지연 로드되어 LCP가 매우 느림
<Image
  src="/hero-image.jpg"
  width={1200}
  height={630}
  alt="제품 메인 비주얼"
/>

// ❌ 잘못된 예: 크기 정보 누락
<Image
  src="/hero-image.jpg"
  priority
  alt="제품 메인 비주얼"
/>

크기 선언의 중요성

Next.js의 Image 컴포넌트는 widthheight를 지정하도록 요구합니다. 번거롭게 하려는 것이 아니라 CLS(레이아웃 이동)를 막기 위해서입니다.

반응형 레이아웃에서는 다음처럼 작성할 수 있습니다.

// fill 속성으로 반응형 구현
<div style={{ position: 'relative', width: '100%', aspectRatio: '16/9' }}>
  <Image
    src="/hero-image.jpg"
    fill
    priority
    style={{ objectFit: 'cover' }}
    alt="제품 메인 비주얼"
  />
</div>

부모 컨테이너에는 명확한 크기나 aspect-ratio가 있어야 합니다. 그렇지 않으면 브라우저가 이미지의 렌더링 크기를 알 수 없습니다.

글꼴 최적화: next/font 자동 인라인

글꼴 로딩이 느려도 LCP가 나빠집니다. 특히 중국어 글꼴은 수 MB에 달하기도 합니다. Next.js 13에 도입된 next/font는 글꼴 로딩을 자동으로 최적화합니다.

Google Fonts 사용:

// app/layout.jsx
import { Inter, Noto_Sans_SC } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',  // 글꼴 깜빡임 방지
});

const notoSansSC = Noto_Sans_SC({
  subsets: ['chinese-simplified'],
  weight: ['400', '700'],
  display: 'swap',
});

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN" className={`${inter.className} ${notoSansSC.className}`}>
      <body>{children}</body>
    </html>
  );
}

사용자 정의 글꼴 사용:

import localFont from 'next/font/local';

const myFont = localFont({
  src: './my-font.woff2',
  display: 'swap',
});

export default function Layout({ children }) {
  return <div className={myFont.className}>{children}</div>;
}

next/font는 다음 최적화를 자동으로 수행합니다.

  • 글꼴 CSS를 자동 인라인하여 네트워크 요청 축소
  • 자동 서브세팅으로 필요한 문자만 로드
  • 글꼴 파일 자동 사전 로드
  • 레이아웃 이동 제거

프로젝트의 글꼴을 next/font로 전환한 뒤 LCP가 0.3초 더 줄었습니다.

서버 응답 시간 단축

이미지와 글꼴을 모두 최적화했는데도 LCP가 느리다면 서버 응답 시간(TTFB)이 원인일 수 있습니다.

정적 생성을 우선 사용

Next.js는 여러 렌더링 방식을 제공합니다. 성능이 좋은 순서대로 나열하면 다음과 같습니다.

  1. SSG (Static Site Generation) - 빌드 시 HTML 생성, 가장 빠름
  2. ISR (Incremental Static Regeneration) - 필요할 때 정적 페이지 재생성
  3. SSR (Server-Side Rendering) - 요청마다 HTML 생성, 비교적 느림
  4. CSR (Client-Side Rendering) - 클라이언트 렌더링, LCP가 가장 느림

SSG를 쓸 수 있다면 우선 사용하세요.

// app/blog/[slug]/page.jsx
export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  return <article>{/* ... */}</article>;
}

데이터를 실시간으로 갱신해야 한다면 ISR을 사용합니다.

// app/products/[id]/page.jsx
export const revalidate = 3600; // 한 시간마다 재생성

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  return <div>{/* ... */}</div>;
}

CDN과 Edge Network 사용

Vercel에 배포하면 정적 리소스가 전 세계 Edge Network에 자동으로 배포되어 TTFB가 크게 줄어듭니다.

다른 플랫폼을 사용한다면 Cloudflare, AWS CloudFront 같은 CDN을 함께 사용할 수 있습니다.

서버에서 복잡한 연산 피하기

저도 이 함정에 빠진 적이 있습니다. 한 번은 서버에서 복잡한 데이터 처리를 수행했는데, 렌더링할 때마다 500ms가 걸려 LCP가 크게 나빠졌습니다.

잘못된 예:

// ❌ 잘못된 예: 요청할 때마다 계산
export default async function Page() {
  const data = await fetchData();
  const processed = heavyProcessing(data); // 500ms 소요
  return <div>{processed}</div>;
}

올바른 방법:

계산 결과를 캐시하거나 빌드할 때 미리 계산합니다.

// ✅ 올바른 예: 빌드 시 계산
export async function generateStaticParams() {
  const data = await fetchData();
  const processed = heavyProcessing(data);
  // 결과를 데이터베이스나 파일에 저장
  await saveProcessedData(processed);
}

export default async function Page() {
  const processed = await getProcessedData(); // 바로 읽기
  return <div>{processed}</div>;
}

CLS 최적화 - 레이아웃 흔들림 제거

CLS(누적 레이아웃 이동)는 가장 골치 아픈 지표입니다. 페이지가 갑자기 움직이면 사용자 경험이 크게 나빠집니다. 솔직히 이 문제 때문에 한동안 꽤 애를 먹었습니다.

이미지와 미디어 요소의 공간 미리 확보하기

CLS의 가장 흔한 원인은 이미지가 로드된 뒤 레이아웃이 바뀌는 것입니다. 해결 방법은 간단합니다. 브라우저에 이미지 크기를 미리 알려 주세요.

Next.js Image 컴포넌트는 자동으로 처리합니다.

// ✅ 올바른 예: 너비와 높이를 지정하면 CLS가 발생하지 않음
<Image
  src="/product.jpg"
  width={400}
  height={300}
  alt="제품 이미지"
/>

동적으로 로드하는 이미지라면 어떻게 할까요?

CMS에서 이미지를 가져와 크기를 알 수 없다고 가정해 보겠습니다.

// ✅ 올바른 예: aspect-ratio로 공간 미리 확보
<div style={{ position: 'relative', width: '100%', aspectRatio: '4/3' }}>
  <Image
    src={dynamicImageUrl}
    fill
    style={{ objectFit: 'cover' }}
    alt="동적 이미지"
  />
</div>

동영상도 마찬가지입니다.

<video
  width="1280"
  height="720"
  poster="/video-poster.jpg"
  controls
>
  <source src="/video.mp4" type="video/mp4" />
</video>

동적 콘텐츠를 위한 공간 확보

광고, 알림 배너, 임베드 콘텐츠(예: Twitter 카드)는 모두 CLS를 일으킬 수 있습니다. 이런 요소를 위한 공간을 미리 확보해야 합니다.

광고 컨테이너:

// ✅ 올바른 예: 광고 높이 미리 확보
<div
  style={{
    minHeight: '250px',  // Google AdSense 표준 광고 높이
    backgroundColor: '#f0f0f0'  // 자리 표시자 배경색
  }}
>
  {/* 광고 스크립트 */}
  <ins className="adsbygoogle" />
</div>

알림 배너:

사이트 상단에 알림 배너가 있다면 갑자기 나타나게 만들지 마세요.

// ❌ 잘못된 예: 갑자기 나타나 페이지를 아래로 밀어냄
{showBanner && <NotificationBanner />}

// ✅ 올바른 예: 공간을 미리 확보
<div style={{ minHeight: '60px' }}>
  {showBanner ? <NotificationBanner /> : <div style={{ height: '60px' }} />}
</div>

스켈레톤(Skeleton):

동적으로 로드하는 콘텐츠에는 스켈레톤 화면을 자리 표시자로 사용합니다.

function ProductList() {
  const { data, isLoading } = useQuery('products', fetchProducts);

  if (isLoading) {
    return (
      <div className="grid grid-cols-3 gap-4">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="skeleton" style={{ height: '300px' }} />
        ))}
      </div>
    );
  }

  return (
    <div className="grid grid-cols-3 gap-4">
      {data.map(product => <ProductCard key={product.id} {...product} />)}
    </div>
  );
}

글꼴 로딩 최적화

글꼴 로딩도 ‘텍스트 깜빡임’이나 ‘글꼴 전환’ 형태의 CLS를 일으킬 수 있습니다.

next/font가 이 문제를 자동으로 처리하지만 수동으로 최적화할 수도 있습니다.

const inter = Inter({
  subsets: ['latin'],
  display: 'optional',  // 글꼴 로드가 끝나지 않으면 기다리지 않고 시스템 글꼴 사용
  adjustFontFallback: true,  // 대체 글꼴 크기를 자동 조정해 이동 축소
});

display 속성의 선택지:

  • swap: 먼저 대체 글꼴을 표시하고 로드가 끝나면 전환(CLS가 발생할 수 있음)
  • optional: 글꼴이 제때 로드되지 않으면 대체 글꼴 사용(권장)
  • block: 글꼴 로드가 끝날 때까지 텍스트를 잠시 숨김(권장하지 않음)
  • fallback: swap과 optional의 중간 방식

사용자 정의 글꼴이 표시되지 않을 수 있더라도 사용자 경험이 더 나은 optional을 권합니다.

JavaScript 기반 반응형 레이아웃 피하기

이것은 2025년에 가장 흔한 CLS 함정입니다! 많은 Next.js 프로젝트에서 같은 실수가 반복됩니다.

전형적인 잘못된 코드:

// ❌ 잘못된 예: 심각한 CLS 발생
import { useMediaQuery } from '@/hooks/useMediaQuery';

export default function ResponsiveLayout() {
  const isMobile = useMediaQuery('(max-width: 768px)');

  return (
    <div>
      {isMobile ? (
        <MobileNav />
      ) : (
        <DesktopNav />
      )}
    </div>
  );
}

CLS가 발생하는 이유는 다음과 같습니다.

  1. 페이지가 처음 렌더링될 때 JavaScript가 아직 실행되지 않아 isMobileundefined이거나 기본값입니다.
  2. JavaScript가 실행되면 useMediaQuery가 실제 값을 반환합니다.
  3. 컴포넌트가 다시 렌더링되면서 레이아웃이 갑자기 바뀝니다.

서버는 클라이언트의 화면 너비를 알 수 없기 때문에 이 문제는 서버 사이드 렌더링(SSR)에서 더 심각합니다.

올바른 방법: CSS 미디어 쿼리 사용

// ✅ 올바른 예: CSS로 표시/숨김을 제어하면 CLS가 발생하지 않음
export default function ResponsiveLayout() {
  return (
    <>
      <nav className="mobile-nav md:hidden">
        <MobileNav />
      </nav>
      <nav className="desktop-nav hidden md:block">
        <DesktopNav />
      </nav>
    </>
  );
}

CSS-in-JS를 사용할 수도 있습니다.

export default function ResponsiveLayout() {
  return (
    <div className="responsive-container">
      <MobileNav />
      <DesktopNav />
      <style jsx>{`
        .responsive-container > :global(.mobile-nav) {
          display: block;
        }
        .responsive-container > :global(.desktop-nav) {
          display: none;
        }
        @media (min-width: 768px) {
          .responsive-container > :global(.mobile-nav) {
            display: none;
          }
          .responsive-container > :global(.desktop-nav) {
            display: block;
          }
        }
      `}</style>
    </div>
  );
}

기기별로 다른 데이터를 요청해야 하는 경우처럼 JavaScript 판별이 꼭 필요하다면 서버에서 User-Agent를 가져올 수 있습니다.

// app/page.jsx
import { headers } from 'next/headers';

export default async function Page() {
  const headersList = headers();
  const userAgent = headersList.get('user-agent') || '';
  const isMobile = /mobile/i.test(userAgent);

  return (
    <div>
      {isMobile ? <MobileView /> : <DesktopView />}
    </div>
  );
}

이렇게 하면 서버와 클라이언트의 렌더링 결과가 일치하므로 CLS가 발생하지 않습니다.

FCP 최적화 - 최초 콘텐츠 렌더링 속도 높이기

FCP(최초 콘텐츠 렌더링)는 핵심 지표는 아니지만 사용자의 첫인상에 영향을 줍니다. 흰 화면이 너무 오래 보이면 사용자는 페이지를 바로 닫을 수 있습니다.

핵심 리소스 최적화

FCP가 느린 원인은 대개 렌더링을 차단하는 리소스입니다. Chrome DevTools의 Coverage 패널을 열어 사용되지 않는 CSS와 JavaScript를 확인하세요.

핵심 CSS 인라인:

Next.js는 CSS 최적화를 자동으로 처리하지만 핵심 스타일을 직접 인라인할 수도 있습니다.

// app/layout.jsx
export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <style
          dangerouslySetInnerHTML={{
            __html: `
              /* 핵심 CSS: 첫 화면에 꼭 필요한 스타일 */
              body { margin: 0; font-family: system-ui; }
              .hero { height: 100vh; }
            `,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

핵심이 아닌 CSS 지연:

모달이나 접힌 영역처럼 첫 화면에 필요 없는 스타일은 지연 로드할 수 있습니다.

// 컴포넌트에서 CSS 동적 임포트
import dynamic from 'next/dynamic';

const Modal = dynamic(() => import('./Modal'), {
  loading: () => <p>Loading...</p>,
});

서드파티 스크립트 관리

Google Analytics, 광고, 소셜 미디어 플러그인 같은 서드파티 스크립트는 성능을 크게 떨어뜨립니다. 많은 사이트의 FCP가 느린 원인이 바로 이것입니다.

next/script 사용:

Next.js의 Script 컴포넌트를 사용하면 스크립트 로드 시점을 제어할 수 있습니다.

import Script from 'next/script';

export default function Layout({ children }) {
  return (
    <>
      {children}

      {/* Google Analytics: 페이지가 상호작용 가능해진 뒤 로드 */}
      <Script
        src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
        strategy="lazyOnload"
      />
      <Script id="ga-init" strategy="lazyOnload">
        {`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config', 'GA_MEASUREMENT_ID');
        `}
      </Script>

      {/* Facebook Pixel: 지연 로드 */}
      <Script
        src="https://connect.facebook.net/en_US/fbevents.js"
        strategy="lazyOnload"
      />
    </>
  );
}

strategy 속성 설명:

  • beforeInteractive: 페이지가 상호작용 가능해지기 전에 로드(핵심 스크립트용)
  • afterInteractive: 페이지가 상호작용 가능해진 뒤 로드(기본값)
  • lazyOnload: 유휴 시간에 로드(분석과 광고에 권장)
  • worker: Web Worker에서 실행(실험적 기능)

필수적이지 않은 서드파티 스크립트를 모두 lazyOnload로 바꾼 뒤 FCP가 0.8초 개선됐습니다.

코드 분할과 지연 로드

Next.js는 코드를 자동으로 분할하지만 때로는 수동 최적화가 필요합니다.

컴포넌트 지연 로드:

첫 화면에 필요 없는 컴포넌트는 동적으로 임포트할 수 있습니다.

import dynamic from 'next/dynamic';

// 댓글 컴포넌트 지연 로드
const Comments = dynamic(() => import('./Comments'), {
  loading: () => <div>댓글을 불러오는 중...</div>,
  ssr: false,  // 서버 사이드 렌더링 안 함
});

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
      {/* 여기까지 스크롤했을 때 댓글 로드 */}
      <Comments postId={post.id} />
    </article>
  );
}

서드파티 라이브러리 지연 로드:

차트 라이브러리나 리치 텍스트 편집기처럼 큰 라이브러리는 필요할 때만 로드해야 합니다.

사례: 차트 라이브러리를 지연 로드해 800KB 절약

예전에 맡았던 프로젝트는 모든 페이지에서 Chart.js를 임포트했지만 실제로는 대시보드에서만 사용했습니다. 그 결과 모든 페이지가 800KB의 코드를 로드하고 있었습니다.

최적화 전:

// ❌ 잘못된 예: 전역 임포트로 모든 페이지에서 로드
import { Chart } from 'chart.js';

export default function Dashboard() {
  return <canvas ref={chartRef} />;
}

최적화 후:

// ✅ 올바른 예: 필요할 때만 로드
import dynamic from 'next/dynamic';

const ChartComponent = dynamic(() => import('./ChartComponent'), {
  loading: () => <div>차트를 불러오는 중...</div>,
  ssr: false,
});

export default function Dashboard() {
  return <ChartComponent />;
}

// ChartComponent.jsx
import { Chart } from 'chart.js';

export default function ChartComponent() {
  return <canvas ref={chartRef} />;
}

이제 대시보드에 방문한 사용자만 Chart.js를 다운로드하고 다른 페이지에는 영향을 주지 않습니다.

이미지 지연 로드:

LCP 이미지를 제외한 다른 이미지는 지연 로드해야 합니다.

<Image
  src="/feature-image.jpg"
  width={600}
  height={400}
  loading="lazy"  // 기본값이므로 생략 가능
  alt="기능 소개"
/>

모니터링과 지속적인 최적화

한 번 최적화했다고 끝나는 것은 아닙니다. 성능 개선은 지속적인 과정이므로 정기적으로 확인하고 조정해야 합니다.

Lighthouse CI로 테스트 자동화

Lighthouse를 수동으로 실행하는 데는 시간이 많이 들고 잊어버리기도 쉽습니다. CI/CD 과정에 통합하는 편이 더 좋습니다.

Lighthouse CI 설치:

npm install -D @lhci/cli

설정 파일 lighthouserc.js:

module.exports = {
  ci: {
    collect: {
      url: ['http://localhost:3000/', 'http://localhost:3000/products'],
      numberOfRuns: 3,  // 3회 실행 후 평균값 사용
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],  // Performance ≥ 90
        'first-contentful-paint': ['error', { maxNumericValue: 2000 }],  // FCP ≤ 2s
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],  // LCP ≤ 2.5s
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],  // CLS < 0.1
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

GitHub Actions 설정:

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
      - run: npm install
      - run: npm run build
      - run: npm run start &
      - run: npx @lhci/cli autorun

이제 코드를 push할 때마다 Lighthouse 테스트가 자동으로 실행됩니다. 성능이 나빠지면 CI가 실패해 수정을 요청합니다.

Real User Monitoring(RUM)

Lighthouse는 실험실 환경에서 테스트하므로 실제 사용자의 경험과 다를 수 있습니다. 실제 사용자의 Core Web Vitals 데이터를 수집해야 합니다.

Vercel Analytics 사용:

Vercel에 배포했다면 Analytics를 간단히 활성화할 수 있습니다.

// app/layout.jsx
import { Analytics } from '@vercel/analytics/react';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

web-vitals 라이브러리 사용:

Vercel을 사용하지 않는다면 Google의 web-vitals 라이브러리를 사용할 수 있습니다.

npm install web-vitals
// app/layout.jsx
'use client';

import { useEffect } from 'react';
import { onLCP, onFID, onCLS, onINP } from 'web-vitals';

function sendToAnalytics(metric) {
  // 분석 서비스로 전송
  fetch('/api/analytics', {
    method: 'POST',
    body: JSON.stringify(metric),
  });
}

export default function Analytics() {
  useEffect(() => {
    onLCP(sendToAnalytics);
    onINP(sendToAnalytics);
    onCLS(sendToAnalytics);
  }, []);

  return null;
}

Google Search Console:

Google Search Console의 ‘Core Web Vitals’ 보고서는 실제 사용자에게서 측정한 사이트 성능을 보여 주고 개선이 필요한 페이지도 표시합니다.

흔한 함정과 해결 방법

마지막으로 가장 흔히 빠지는 함정 5가지를 정리했습니다.

함정 1: 클라이언트 JavaScript 과다 사용

많은 개발자가 모든 것을 React로 처리하는 데 익숙해져 페이지 전체가 JavaScript에 의존하게 됩니다. JS 로드가 실패하거나 너무 느리면 사용자는 흰 화면만 보게 됩니다.

해결 방법:

  • Server Components를 우선 사용합니다(Next.js 13+ 기본값).
  • 꼭 필요한 경우에만 ‘use client’를 사용합니다.
  • Progressive Enhancement 원칙을 적용해 기본 기능부터 보장하고 상호작용을 점진적으로 강화합니다.

함정 2: 모바일 성능 무시

데스크톱에서 빠르다고 모바일에서도 빠른 것은 아닙니다. 모바일 기기는 CPU가 약하고 네트워크가 느려 성능 문제가 더 뚜렷합니다.

해결 방법:

  • Lighthouse의 ‘모바일’ 모드로 테스트합니다.
  • 네트워크 속도를 제한합니다(Chrome DevTools → Network → Throttling).
  • CPU 성능을 제한합니다(Chrome DevTools → Performance → CPU).

함정 3: 서드파티 스크립트 미최적화

Google Analytics, Facebook Pixel, Intercom, Hotjar 등 모든 스크립트가 성능을 떨어뜨릴 수 있습니다.

해결 방법:

  • 모두 next/script로 감싸고 strategy="lazyOnload"를 설정합니다.
  • 어떤 스크립트가 꼭 필요한지, 줄일 수 있는지 정기적으로 검토합니다.
  • 클라이언트 추적 대신 서버 측 추적을 고려합니다.

함정 4: 부적절한 이미지 형식 선택

아직도 PNG와 JPG만 사용하나요? WebP와 AVIF를 사용하면 용량을 30~50% 줄일 수 있습니다.

해결 방법:

Next.js Image 컴포넌트가 형식을 자동 변환하지만 서버에서 이를 지원하는지 확인해야 합니다.

// next.config.js
module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],  // 최신 형식을 우선 사용
  },
};

함정 5: 서버 성능 무시

프론트엔드를 아무리 잘 최적화해도 서버 응답이 느리면 소용없습니다.

해결 방법:

  • 데이터베이스 쿼리를 최적화합니다(인덱스 추가, N+1 쿼리 축소).
  • 캐시를 사용합니다(Redis, CDN).
  • 서버 성능을 모니터링합니다(New Relic, Datadog 같은 APM 도구).

정리

지금까지 살펴본 내용을 정리하겠습니다.

LCP 최적화 핵심:

  • LCP 이미지에 priorityfetchPriority="high" 설정
  • next/font로 글꼴 로딩 최적화
  • SSG와 ISR을 우선 사용하고 서버 연산 축소
  • CDN으로 TTFB 단축

CLS 최적화 핵심:

  • 모든 이미지와 미디어 요소에 크기 지정
  • 동적 콘텐츠(광고, 알림 배너)를 위한 공간 확보
  • JavaScript hooks로 반응형 레이아웃을 구현하지 않기
  • next/font로 글꼴 전환에 따른 이동 축소

FCP 최적화 핵심:

  • 핵심이 아닌 리소스 지연 로드
  • 서드파티 스크립트를 next/script로 감싸고 lazyOnload 설정
  • 큰 라이브러리와 첫 화면 밖 컴포넌트 지연 로드

지속적인 최적화:

  • Lighthouse CI로 테스트 자동화
  • 실제 사용자 데이터(RUM) 수집
  • 불필요한 코드를 정기적으로 검토하고 제거

성능 최적화는 ‘측정 → 최적화 → 재측정’을 반복하는 과정입니다. 한 번에 끝내려 하지 말고 지속적으로 살펴보고 개선해야 합니다.

Lighthouse 점수가 마침내 90점을 넘었을 때의 성취감은 정말 컸습니다. 더 중요한 것은 전환율 향상으로 이런 노력이 가치 있었다는 점을 증명했다는 것입니다.

실행 체크리스트

이제 직접 시작해 볼 차례입니다. 다음 순서를 권합니다.

  1. 바로 실행:

    • Lighthouse로 Next.js 프로젝트 테스트
    • LCP 요소를 찾아 priority가 설정되어 있는지 확인
    • useMediaQuery처럼 CLS를 일으키는 코드가 있는지 확인
  2. 이번 주에 완료:

    • LCP 이미지 최적화(priority + fetchPriority)
    • 글꼴을 next/font로 전환
    • 동적 콘텐츠에 자리 표시자 추가
  3. 다음 주에 완료:

    • 서드파티 스크립트를 lazyOnload로 변경
    • 큰 라이브러리와 첫 화면 밖 컴포넌트 지연 로드
    • Lighthouse CI 자동 테스트 설정
  4. 지속적으로 수행:

    • 매월 Lighthouse 점수 확인
    • Google Search Console의 Core Web Vitals 보고서 확인
    • 성능 회귀 문제를 신속하게 수정

기억하세요. 성능 최적화는 일회성 작업이 아니라 지속적인 과정입니다. 최적화가 순조롭게 진행되길 바랍니다!

Next.js Core Web Vitals 최적화 전체 과정

LCP, INP, CLS 세 가지 핵심 지표를 최적화해 Lighthouse 점수를 90점 이상으로 높입니다.

⏱️ Estimated time: 8 hr

  1. 1

    Step 1: LCP(최대 콘텐츠 렌더링) 최적화

    목표: ≤2.5초

    최적화 방법:
    • next/image로 이미지 최적화(자동 WebP/AVIF 변환)
    • 첫 화면의 핵심 이미지에 priority 속성 추가
    • 핵심 리소스 사전 로드(<link rel="preload"> 사용)
    • 서버 응답 시간 최적화(CDN, Edge Functions 사용)
    • 렌더링 차단 리소스 축소(핵심이 아닌 CSS/JS 지연 로드)
    • React Server Components로 클라이언트 JS 축소

    검사 도구:
    • Lighthouse Performance 보고서
    • Chrome DevTools Performance 패널
    • WebPageTest 온라인 테스트
  2. 2

    Step 2: INP(상호작용 후 다음 페인트) 최적화

    목표: ≤200ms

    최적화 방법:
    • JavaScript 실행 시간 단축(코드 분할, 지연 로드)
    • React Server Components로 클라이언트 JS 축소
    • 이벤트 처리 최적화(디바운스, 스로틀, 이벤트 위임)
    • 긴 작업이 메인 스레드를 막지 않도록 조정(Web Workers 사용)
    • 서드파티 스크립트 최적화(지연 로드, async/defer 사용)
    • Suspense와 스트리밍 렌더링 사용

    검사 도구:
    • Chrome DevTools Performance 패널
    • Web Vitals Chrome 확장 프로그램
    • Real User Monitoring(RUM) 도구
  3. 3

    Step 3: CLS(누적 레이아웃 이동) 최적화

    목표: ≤0.1

    최적화 방법:
    • 이미지와 동영상의 너비·높이 지정(또는 aspect-ratio 사용)
    • 동적 콘텐츠 삽입 방지(공간 미리 확보)
    • font-display: swap으로 글꼴 로딩에 따른 이동 방지
    • 광고 영역 공간 미리 확보(광고가 로드된 뒤 콘텐츠를 밀어내지 않도록 함)
    • 절대 위치 지정 대신 CSS Grid/Flexbox 사용
    • 기존 콘텐츠 위에 새 콘텐츠를 삽입하지 않기

    검사 도구:
    • Lighthouse CLS 보고서
    • Chrome DevTools Performance 패널의 Layout Shift 기록
    • WebPageTest의 CLS 시각화
  4. 4

    Step 4: Next.js 성능 최적화 기능 사용

    Next.js 최적화 팁:
    • Image 컴포넌트로 이미지 자동 최적화
    • Font Optimization으로 글꼴 자동 최적화
    • React Server Components로 클라이언트 JS 축소
    • Streaming SSR로 첫 화면 속도 향상
    • Dynamic Imports로 코드 분할
    • next/dynamic으로 컴포넌트 지연 로드

    설정 확인:
    • next.config.js의 성능 관련 설정
    • 번들 크기 확인(npm run build)
  5. 5

    Step 5: 모니터링 및 지속적인 최적화

    모니터링 도구:
    • Google Search Console Core Web Vitals 보고서
    • Vercel Analytics(Vercel 사용 시)
    • Web Vitals Chrome 확장 프로그램
    • Real User Monitoring(RUM) 도구

    지속적인 최적화:
    • 매월 Lighthouse 점수 확인
    • Search Console의 Core Web Vitals 보고서 확인
    • 성능 회귀 문제를 신속하게 수정
    • A/B 테스트로 최적화 효과 검증

FAQ

Core Web Vitals의 세 가지 지표는 무엇인가요?
LCP(최대 콘텐츠 렌더링)≤2.5초는 로딩 성능을, INP(상호작용 후 다음 페인트)≤200ms는 상호작용 응답 속도를 측정하며 2024년에 FID를 대체했습니다. CLS(누적 레이아웃 이동)≤0.1은 레이아웃 안정성을 측정합니다. 이 세 지표는 Google 검색 순위에 직접 영향을 줍니다.
LCP는 어떻게 최적화하나요?
방법은 1) next/image로 이미지 최적화, 2) 첫 화면의 핵심 이미지에 priority 속성 추가, 3) 핵심 리소스 사전 로드, 4) 서버 응답 시간 최적화, 5) 렌더링 차단 리소스 축소, 6) React Server Components 사용입니다. 목표는 LCP≤2.5초입니다.
INP와 FID는 무엇이 다른가요?
INP는 2024년 3월에 FID를 대체한 새 지표입니다. FID는 최초 상호작용만 측정하지만 INP는 모든 상호작용의 응답 시간을 측정합니다. INP가 더 포괄적이며 실제 사용자 경험을 더 잘 반영합니다. 목표값은 ≤200ms입니다.
CLS(레이아웃 이동)는 어떻게 방지하나요?
방법은 1) 이미지와 동영상의 너비·높이 지정, 2) 동적 콘텐츠 삽입 방지, 3) font-display: swap 사용, 4) 광고 영역 공간 미리 확보, 5) CSS Grid/Flexbox 사용, 6) 기존 콘텐츠 위에 새 콘텐츠를 삽입하지 않기입니다. 목표는 CLS≤0.1입니다.
성능 최적화 효과는 언제 확인할 수 있나요?
기술 지표(Lighthouse 점수)는 즉시 확인할 수 있습니다. 하지만 비즈니스 지표(전환율, 검색 순위)는 일반적으로 1~3개월이 필요합니다. Google이 사이트를 다시 평가하고 사용자 행동 데이터가 쌓이는 데 시간이 걸리기 때문입니다. 지속적으로 모니터링하고 최적화하는 것이 좋습니다.
Next.js의 어떤 기능이 성능 최적화에 도움이 되나요?
React Server Components는 클라이언트 JS를 줄이고, Image 컴포넌트는 이미지를 자동 최적화하며, Font Optimization은 글꼴을 최적화합니다. Streaming SSR은 첫 화면 속도를 높이고, Dynamic Imports는 코드를 분할합니다. 자동 코드 분할과 tree-shaking도 지원합니다. 이런 기능을 충분히 활용하면 성능을 크게 높일 수 있습니다.
Core Web Vitals는 어떻게 모니터링하나요?
도구는 1) Google Search Console의 Core Web Vitals 보고서(실사용자 데이터), 2) Lighthouse(실험실 데이터), 3) Web Vitals Chrome 확장 프로그램, 4) Vercel Analytics(Vercel 사용 시), 5) Real User Monitoring 도구입니다. 실험실 데이터와 실사용자 데이터를 함께 사용하는 것이 좋습니다.

6분 읽기 · 게시일: 2025년 12월 19일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog