이미지는 페이지 체감 속도를 가장 크게 흔드는 요소 중 하나입니다. 특히 블로그 홈, 랜딩 페이지, 글 상세 상단처럼 큰 대표 이미지가 있는 화면에서는 이미지가 그대로 Largest Contentful Paint, 즉 LCP 후보가 되는 경우가 많습니다. 그런데 실무에서는 “이미지 압축만 좀 더 하면 되겠지”라고 생각했다가, 실제로는 발견 시점이 늦거나, 잘못 lazy loading 되어 있거나, 반응형 sizes가 비어 있어서 불필요하게 큰 파일을 받는 문제가 더 크게 작동하는 경우가 많습니다.

Google의 web.dev 문서도 LCP를 하나의 숫자로만 보지 말고, TTFB, resource load delay, resource load duration, element render delay 네 부분으로 나눠서 봐야 한다고 설명합니다. 즉 이미지 파일 크기만 줄였다고 해서 항상 LCP가 좋아지는 건 아닙니다. 브라우저가 그 이미지를 너무 늦게 발견하거나, 이미지가 다 받아진 뒤에도 렌더가 늦게 일어나면 개선이 생각보다 적을 수 있습니다.

Next.js는 이런 문제를 해결하는 데 유리한 도구를 많이 제공합니다. next/image, sizes, preload, 정적 이미지 import, useReportWebVitals 같은 기능들이 대표적입니다. 다만 여기서 중요한 전제가 하나 있습니다. 지금 이 레포처럼 output: "export"를 쓰는 정적 사이트라면, next/image의 기본 최적화 API는 그대로 사용할 수 없습니다. 즉 서버형 배포와 정적 export는 같은 전략으로 접근하면 안 됩니다.

이 글에서는 “어떤 이미지가 LCP인지 찾는 법”부터 시작해서, Next.js에서 이미지 때문에 느려진 LCP를 실제로 줄이는 순서를 정리해보겠습니다. 코드 예시는 App Router 기준으로 설명하고, 정적 export 환경에서 무엇을 다르게 봐야 하는지도 함께 적겠습니다.

경험/운영성능 개선

Next.js 이미지 최적화로 LCP를 줄이는 실제 방법

LCP를 악화시키는 이미지 문제를 어떻게 찾고, Next.js에서 어떤 순서로 개선해야 하는지 정리합니다. `next/image`, `sizes`, `preload`, 정적 export 한계, 측정 방법까지 실제 운영 기준으로 설명합니다.

핵심 1

먼저 기준부터: LCP는 2.5초 이하를 목표로 보되, 숫자 하나보다 “어디서 늦어졌는지”를 본다

핵심 2

제일 먼저 LCP 요소를 특정한다

핵심 3

LCP 이미지라면 절대 lazy loading부터 걸지 않는다

성능 개선경험/운영image optimization lcp
LCP 측정부터 이미지 발견 시점, 다운로드 크기, 렌더링 지연, 배포 방식별 대응까지 Next.js 이미지 최적화 흐름

먼저 기준부터: LCP는 2.5초 이하를 목표로 보되, 숫자 하나보다 “어디서 늦어졌는지”를 본다

web.dev의 LCP 문서는 좋은 사용자 경험을 위해 페이지 방문의 최소 75%에서 LCP가 2.5초 이하가 되도록 목표를 잡으라고 설명합니다. 하지만 실전에서는 2.5초라는 숫자만 보면 개선 순서가 잘 안 보입니다. 그래서 저는 먼저 아래 질문을 합니다.

  • LCP 요소가 정말 이미지인가
  • 그 이미지 요청이 HTML 직후 바로 시작되는가
  • 파일 크기가 지금 화면에 비해 과도하지 않은가
  • 이미지가 다 내려온 뒤 렌더를 막는 다른 일이 있는가

이 순서가 중요한 이유는, 같은 “LCP가 느리다”라는 결과라도 원인이 꽤 다를 수 있기 때문입니다.

  • 이미지 발견이 늦은 경우: preload, fetchPriority, HTML 구조가 중요
  • 이미지가 너무 큰 경우: 포맷, 압축, 실제 크기, sizes가 중요
  • 이미지 다 받은 뒤 늦게 나타나는 경우: 클라이언트 렌더링, 숨김 처리, JS 의존이 중요

즉 이미지를 줄이는 작업은 “파일 다이어트”만이 아니라 “언제 발견되는지, 어떤 우선순위로 내려받는지, 바로 그릴 수 있는지”를 함께 보는 작업입니다.

1. 제일 먼저 LCP 요소를 특정한다

생각보다 자주 생기는 실수가 있습니다. 느린 페이지를 보자마자 대표 이미지를 의심했는데, 실제 LCP는 제목 텍스트이거나 쿠키 배너 배경이거나, 다른 카드 이미지인 경우입니다. 그래서 최적화는 반드시 LCP 요소 확인부터 시작하는 편이 좋습니다.

가장 현실적인 확인 순서는 이렇습니다.

  1. PageSpeed Insights에서 해당 페이지의 LCP 요소를 확인
  2. Lighthouse 또는 Chrome DevTools Performance에서 요청 시작 시점과 렌더 시점을 확인
  3. 가능하면 실제 사용자 데이터도 함께 확인

web.dev의 최적화 가이드도 Lighthouse 실험실 데이터와 CrUX 실사용 데이터가 크게 다르면, 먼저 실사용 데이터를 기준으로 봐야 한다고 설명합니다. 특히 블로그처럼 글 페이지별 편차가 큰 사이트는 origin 전체 숫자보다 “문제 페이지 하나”를 따로 보는 편이 정확합니다.

Next.js에서는 실제 사용자 환경에서 Web Vitals를 수집할 수도 있습니다. App Router 기준으로는 작은 클라이언트 컴포넌트를 하나 두고 useReportWebVitals를 연결하면 됩니다.

src/components/web-vitals.tsx

'use client';

import { useReportWebVitals } from 'next/web-vitals';

type ReportWebVitalsCallback = Parameters<typeof useReportWebVitals>[0];

const handleWebVitals: ReportWebVitalsCallback = (metric) => {
  if (metric.name === 'LCP') {
    console.log('LCP', metric.value, metric.rating, metric.id);
  }
};

export function WebVitals() {
  useReportWebVitals(handleWebVitals);
  return null;
}

src/app/layout.tsx

import { WebVitals } from '@/components/web-vitals';

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

Next.js 공식 문서도 useReportWebVitals는 별도 클라이언트 컴포넌트로 분리해 루트 레이아웃에서 불러오는 방식을 권장합니다. 이렇게 해두면 “어떤 페이지에서 LCP가 느린지”를 추적하기 쉬워집니다.

2. LCP 이미지라면 절대 lazy loading부터 걸지 않는다

이건 가장 자주 나오는 실수라서 먼저 강조할 가치가 있습니다. web.dev의 LCP 최적화 문서는 LCP 이미지를 lazy loading 하면 불필요한 resource load delay가 생기므로, LCP 이미지에는 lazy loading을 사용하지 말라고 분명히 설명합니다.

즉 아래 같은 패턴은 대표 이미지가 LCP 후보일 때 좋지 않습니다.

<Image
  src={heroImage}
  alt="메인 화면 예시"
  loading="lazy"
  sizes="100vw"
/>

이렇게 하면 브라우저는 “이 이미지를 지금 바로 우선해서 받아야 한다”는 신호를 충분히 받지 못합니다. 특히 레이아웃 계산 후 viewport 안에 있다고 판단되기 전까지 로딩이 늦어질 수 있습니다.

LCP 후보가 명확한 hero 이미지라면 아래 셋 중 하나를 선택하는 편이 좋습니다.

  • preload
  • fetchPriority="high"
  • loading="eager"

여기서 중요한 건 세 개를 한 번에 다 쓰지 않는 것입니다. Next.js 16 문서도 preload는 LCP나 hero 이미지에 쓸 수 있지만, loading이나 fetchPriority와 함께 쓰지는 말라고 안내합니다. 그리고 예전 priority prop은 Next.js 16부터 preload로 대체되었습니다. 예전 글을 참고하다 보면 아직 priority 예제가 많으니, 현재 버전 기준에서는 preload로 읽는 편이 맞습니다.

예를 들어 가장 단순한 hero 이미지라면 이렇게 둘 수 있습니다.

import Image from 'next/image';
import heroImage from '@/public/hero-dashboard.webp';

export function Hero() {
  return (
    <Image
      src={heroImage}
      alt="서비스 핵심 화면 미리보기"
      preload
      sizes="100vw"
      style={{ width: '100%', height: 'auto' }}
    />
  );
}

이 예시의 핵심은 이미지가 “빠르게 발견되고”, “현재 viewport에서 얼마나 큰지 알려주고”, “비율이 유지되도록 렌더링된다”는 점입니다.

3. sizes를 비워두면 반응형 화면에서 생각보다 큰 이미지를 받기 쉽다

Next.js Image 문서에서 개인적으로 가장 자주 실수하는 항목이 sizes입니다. 공식 문서에 따르면 이미지가 fill을 쓰거나 CSS로 반응형으로 늘어나는 경우, sizes를 제공하는 편이 좋습니다. 이 값이 없으면 브라우저는 이미지를 100vw처럼 더 크게 가정할 수 있고, 그 결과 불필요하게 큰 리소스를 내려받을 수 있습니다.

예를 들어 카드 그리드 안에서 실제로는 데스크톱에서 33vw 정도만 차지하는 이미지를, 브라우저가 100vw로 오해하면 필요 이상으로 큰 소스를 고르게 됩니다. LCP 후보 이미지라면 이 낭비가 그대로 resource load duration으로 이어질 수 있습니다.

아래처럼 화면 규칙을 구체적으로 적는 편이 좋습니다.

<Image
  src={heroImage}
  alt="블로그 대표 이미지"
  fill
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 960px"
  style={{ objectFit: 'cover' }}
/>

이때 부모 요소에는 position: relative가 필요합니다. fill을 쓸 때 Next.js 문서도 부모가 위치 기준을 가져야 하고, 그렇지 않으면 기대와 다른 배치가 생길 수 있다고 설명합니다.

4. widthheight 또는 fill은 다운로드 크기뿐 아니라 안정적인 렌더링에도 중요하다

LCP는 엄밀히 말하면 CLS와 다른 지표입니다. 하지만 같은 대표 이미지가 화면 상단 큰 영역을 차지한다면, 비율이 늦게 확정되는 문제는 렌더링 안정성과 지각 속도 모두에 좋지 않습니다. Next.js 공식 문서도 widthheight는 intrinsic size를 알려주어 브라우저가 올바른 비율의 공간을 예약하고 layout shift를 피하는 데 사용된다고 설명합니다.

정적 import를 사용하면 Next.js가 이 값을 자동으로 알 수 있어서 편합니다.

import Image from 'next/image';
import articleCover from '@/public/article-cover.webp';

export function ArticleHero() {
  return (
    <Image
      src={articleCover}
      alt="글 대표 이미지"
      sizes="100vw"
      style={{ width: '100%', height: 'auto' }}
    />
  );
}

반대로 원격 이미지를 쓸 때는 width, height를 직접 넣어주는 편이 좋습니다.

<Image
  src="https://images.example.com/hero/post-01.webp"
  alt="글 대표 이미지"
  width={1600}
  height={900}
  sizes="100vw"
  style={{ width: '100%', height: 'auto' }}
/>

이 숫자는 화면에 실제로 그려지는 크기와 똑같을 필요는 없습니다. 중요한 건 비율 정보를 미리 제공하는 것입니다.

5. 파일 크기는 “현재 그려질 크기”를 기준으로 줄인다

이미지 최적화 얘기를 하면 종종 포맷 이야기부터 시작하지만, 실무에서는 먼저 “이 이미지가 실제로 어느 크기로 보이는가”를 보는 편이 더 효율적입니다. 데스크톱 hero에서 최대 960px로만 보이는 이미지를 2400px 너비 JPEG 그대로 두면, 포맷을 조금 바꾸는 것보다 크기 자체를 줄이는 쪽이 더 큰 효과를 내는 경우가 많습니다.

여기서 제가 먼저 보는 항목은 아래와 같습니다.

  • 원본 이미지의 실제 픽셀 수
  • 현재 화면에서 최대 몇 px까지 그려지는지
  • 고해상도 기기까지 고려해도 필요한 상한선이 어느 정도인지
  • WebP나 AVIF로 변환했을 때 화질 손실이 허용되는지

web.dev의 LCP 가이드도 resource load duration을 줄이기 위한 한 방법으로 더 나은 포맷, 예를 들면 WebP나 AVIF를 언급합니다. 다만 포맷만 바꾸고 발견 시점이나 렌더 지연을 안 건드리면 전체 LCP 개선이 제한될 수 있다고도 설명합니다. 이 점이 중요합니다. 이미지 최적화는 “바이트만 줄이면 끝”이 아닙니다.

또 하나 자주 헷갈리는 게 placeholder="blur"입니다. blur placeholder는 체감상 덜 거슬리게 만들 수는 있지만, 실제 LCP 자체를 강하게 줄여주는 도구는 아닙니다. 오히려 큰 blurDataURL을 넣으면 성능에 불리할 수 있다고 Next.js 문서가 설명합니다. 그래서 blur는 보조 수단으로 보고, 먼저 실제 이미지 바이트와 발견 시점을 줄이는 편이 맞습니다.

6. CSS 배경 이미지가 LCP 후보라면 “보이는데 늦게 발견되는” 문제가 생기기 쉽다

hero 이미지를 <Image><img>가 아니라 CSS background-image로 두는 경우가 있습니다. 이 방식이 꼭 나쁜 건 아니지만, web.dev 문서는 CSS 배경 이미지는 HTML의 preload scanner가 즉시 발견하기 어렵기 때문에 preload가 필요할 수 있다고 설명합니다. 즉 화면에서 제일 크게 보이는 이미지를 CSS에 숨겨두면, 브라우저가 늦게 인식해서 resource load delay가 커질 수 있습니다.

배경 이미지가 꼭 필요하다면 아래처럼 이미지 preload 힌트를 검토할 수 있습니다.

<link
  rel="preload"
  as="image"
  href="/hero-background.webp"
>

실제 Next.js 프로젝트에서는 사용하는 head 구성 방식에 맞게 이 힌트를 넣으면 됩니다. 여기서 중요한 건 문법보다도 “브라우저가 이 이미지를 HTML 단계에서 더 빨리 발견하게 만들 수 있는가”입니다.

실무에서는 보통 다음 중 하나로 정리하는 편이 낫습니다.

  • 가능하면 hero 이미지를 실제 <Image> 요소로 바꾼다
  • 꼭 배경 이미지여야 한다면 preload를 검토한다
  • 단순한 그래디언트나 장식이라면 굳이 이미지로 만들지 않고 CSS로 대체한다

즉 LCP 후보가 될 만큼 큰 화면 자산은, 브라우저가 빨리 발견할 수 있게 만드는 편이 유리합니다.

7. 정적 export에서는 next/image가 자동 최적화를 해주지 않는다

이 부분은 지금 이 레포 같은 환경에서 특히 중요합니다. Next.js 공식 오류 문서는 output: "export" 상태에서 next/image의 기본 loader를 그대로 쓰면 Image Optimization API가 없어서 문제가 생긴다고 설명합니다. 이유는 간단합니다. 기본 이미지 최적화는 요청 시점(on-demand)에 수행되는데, 정적 export 앱에는 그 서버 기능이 없기 때문입니다.

즉 아래 둘은 같은 전략으로 보면 안 됩니다.

  • Vercel이나 next start 기반 서버형 배포
  • Cloudflare Pages 같은 정적 export 배포

정적 export에서는 보통 아래 셋 중 하나를 고릅니다.

  1. 이미지 최적화를 지원하는 서버/CDN으로 옮긴다
  2. loaderFile 또는 loader로 외부 이미지 최적화 서비스를 쓴다
  3. images.unoptimized = true로 두고, 원본 파일을 미리 최적화해 배포한다

예를 들어 외부 CDN을 붙일 계획이 있다면 이런 식의 설정을 둘 수 있습니다.

next.config.ts

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'export',
  images: {
    loader: 'custom',
    loaderFile: './src/lib/image-loader.ts',
  },
};

export default nextConfig;

src/lib/image-loader.ts

'use client';

export default function imageLoader({
  src,
  width,
  quality,
}: {
  src: string;
  width: number;
  quality?: number;
}) {
  return `https://cdn.example.com${src}?w=${width}&q=${quality ?? 75}`;
}

반대로 서버 최적화가 없는 단순 정적 블로그라면, 아예 이미지 파일을 미리 줄여서 public/에 넣고 unoptimized 전략으로 관리하는 편이 더 솔직할 수 있습니다.

next.config.ts

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'export',
  images: {
    unoptimized: true,
  },
};

export default nextConfig;

이 방식의 핵심은 “Next.js가 나 대신 최적화해주겠지”라는 기대를 버리는 것입니다. 정적 export에서는 이미지 크기, 포맷, 실제 렌더 폭을 운영자가 더 직접 관리해야 합니다.

8. 원격 이미지는 remotePatterns를 좁게 잡고, width/height를 명시한다

원격 이미지를 많이 쓰는 프로젝트라면 보안과 성능 둘 다 신경 써야 합니다. Next.js 문서는 remotePatterns로 어떤 호스트와 경로를 허용할지 제한할 수 있다고 설명합니다. 이걸 넓게 열어두면 원하지 않는 이미지까지 최적화 경로로 흘러들 수 있고, 관리도 어려워집니다.

예시는 아래처럼 둘 수 있습니다.

next.config.ts

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.example.com',
        pathname: '/blog/**',
        search: '',
      },
    ],
  },
};

export default nextConfig;

그리고 원격 이미지는 build 시점에 파일 정보를 알 수 없기 때문에 width, height를 직접 넣고, 필요하면 작은 blurDataURL도 함께 준비하는 편이 좋습니다. 특히 LCP 후보라면 비율 정보가 빠지는 순간 첫 화면 안정성이 바로 흔들립니다.

9. 실제로 LCP가 잘 줄었는지는 “개선 포인트별로” 다시 측정한다

이미지 바꾼 뒤 바로 “이제 빨라졌겠지”라고 넘어가면 의외로 헛수고가 생깁니다. web.dev 가이드가 강조하듯, 어떤 최적화는 한 subpart를 줄였지만 전체 LCP를 크게 바꾸지 못할 수 있습니다. 예를 들어 파일 용량은 줄었는데 여전히 JS가 끝나기 전까지 hero가 안 보이면, resource load duration만 줄고 element render delay는 남아 있게 됩니다.

그래서 저는 개선 뒤에 아래 순서로 다시 봅니다.

  • LCP 요소가 그대로 같은 요소인지
  • LCP 이미지 요청이 더 빨리 시작됐는지
  • 전송 바이트와 다운로드 시간이 줄었는지
  • 이미지 로드 이후 렌더 지연이 줄었는지

이렇게 보면 “무엇이 실제 효과를 냈는지”가 보입니다. 그냥 Lighthouse 점수 한 번 보는 것보다 훨씬 안정적입니다.

10. 제가 먼저 의심하는 흔한 실수들

실무에서 자주 보는 실수들을 정리하면 아래와 같습니다.

  • hero 이미지에 loading="lazy"를 넣음
  • fill을 쓰면서 sizes를 비워둠
  • 원본 이미지를 화면보다 지나치게 크게 유지함
  • 캐러셀 첫 슬라이드 이미지를 JS가 실행된 뒤에야 노출함
  • CSS background-image를 LCP로 쓰면서 preload를 안 함
  • 정적 export인데 next/image가 알아서 압축해줄 거라고 기대함
  • remote image인데 width/height를 빼서 비율 정보를 늦게 알게 만듦
  • blur placeholder만 넣고 실제 파일 크기와 발견 시점은 손대지 않음

이 목록은 단순하지만 효과가 큽니다. 특히 작은 블로그나 콘텐츠 사이트에서는 한두 군데만 고쳐도 LCP가 눈에 띄게 달라지는 경우가 많습니다.

실무 체크리스트

  • 이 페이지의 실제 LCP 요소를 PageSpeed Insights나 DevTools에서 확인했는가
  • LCP 후보 이미지에 lazy loading을 걸지 않았는가
  • preload, fetchPriority, loading="eager" 중 하나만 의도적으로 선택했는가
  • 반응형 이미지에 sizes를 정확히 넣었는가
  • 정적 import 또는 width/height로 비율 정보를 제공했는가
  • 현재 그려질 크기 기준으로 원본 이미지 크기를 줄였는가
  • 필요하면 WebP/AVIF로 변환했는가
  • CSS 배경 이미지가 LCP라면 preload 또는 구조 변경을 검토했는가
  • output: "export" 환경에서 이미지 전략을 별도로 정했는가
  • 변경 전후 LCP 요청 시작 시점과 렌더 지연을 다시 비교했는가

마무리

Next.js에서 이미지 때문에 느린 LCP를 줄이는 가장 좋은 방법은 “이미지 최적화”를 파일 압축 작업으로만 보지 않는 것입니다. 실제로는 어떤 이미지가 LCP인지 찾고, 브라우저가 그 이미지를 얼마나 빨리 발견하는지 보고, 화면에 필요한 만큼만 내려받게 만들고, 렌더를 늦추는 자바스크립트 의존을 줄이는 작업에 가깝습니다.

특히 서버형 배포와 정적 export는 전략이 다릅니다. 서버형 환경에서는 next/image의 장점을 적극적으로 살릴 수 있지만, 정적 export에서는 원본 파일 관리와 외부 loader 전략이 더 중요합니다. 코드 블록 자체의 가독성을 함께 다듬고 싶다면 이전에 정리한 코드 블록 서식 규칙 글도 이어서 보면 좋습니다.

참고 자료: