NextJS는 어떻게 이미지를 최적화할까?

이 글은 NextJS 14.2.23 버전을 기준으로 작성되었습니다.

웹에서 이미지는 페이지 로딩 속도사용자 경험에 큰 영향을 미친다. 최적화되지 않은 이미지는 페이지 로딩을 느리게 하고 사용자 이탈률을 높이며, SEO에도 부정적인 영향을 미칠 수 있다.

이를 해결하기 위해 NextJS는 next/image 컴포넌트를 제공해 자동으로 최적화를 수행한다.
공식 문서와 소스 코드를 기반으로 next/image의 이미지 최적화 원리를 알아보자.

무엇을 최적화할까?

NextJS 공식 문서에 따르면 next/image 컴포넌트는 다음과 같이 이미지를 최적화한다.

  • 이미지 크기 최적화: 브라우저에 따라 적절한 크기의 이미지를 제공
  • 최신 포맷으로 변환: WebP, AVIF 등 브라우저가 지원하는 최적의 포맷으로 변환
  • Lazy Loading 기본 지원: 필요할 때만 이미지를 로드하여 성능 최적화 (뷰포트에 진입할 때 이미지 로드)
  • Placeholder Blur 지원: 이미지가 로드되기 전에 블러 효과 제공
  • CDN 최적화: Vercel 배포 시 자동으로 CDN을 활용한 최적화 수행

어떤 포맷으로 변환할까?

next/src/server/image-optimizer.ts
function getSupportedMimeType(options: string[], accept = ''): string {
  const mimeType = mediaType(accept, options)
  return accept.includes(mimeType) ? mimeType : ''
}

const mimeType = getSupportedMimeType(formats || [], req.headers['accept'])ts

next/image의 이미지 최적화를 담당하는 image-optimizer.ts 코드를 살펴보면 브라우저의 Accept 헤더 값과 config의 formats로 변환할 포맷을 결정한다.

브라우저의 Accept 헤더는 클라이언트가 처리할 수 있는 MIME 타입을 서버에 알리는 역할을 한다. 쉽게 설명하자면 Accept 헤더에 브라우저가 지원하는 포맷 정보가 담겨있다.

getSupportedMimeType 함수를 실행해 formats 배열 값 중 클라이언트가 지원하는 최적의 포맷으로 mimeType을 결정하는데, 만약 formats 배열의 값을 모두 지원하지 않는다면 원본 포맷을 유지한다.

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'], 
  },
}

export default nextConfigjs

options는 nextConfig에서 설정할 수 있으며 기본값['image/webp']이다.

최적화되지 않는 이미지

next/src/server/image-optimizer.ts
// ANIMATABLE_TYPES = [WEBP, PNG, GIF]
if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)) {
  Log.warnOnce(
    `The requested resource "${href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the <Image>.`
  )
  return {
    buffer: upstreamBuffer,
    contentType: upstreamType,
    maxAge,
  }
}ts

GIF 등 Animatable 이미지는 최적화가 이루어지지 않는다. 이미지 최적화 과정을 건너뛰기 위해 unoptimized={true}를 권장한다.

next/src/server/image-optimizer.ts
if (
  upstreamType.startsWith('image/svg') &&
  !nextConfig.images.dangerouslyAllowSVG
) {
  Log.error(
    `The requested resource "${href}" has type "${upstreamType}" but dangerouslyAllowSVG is disabled`
  )
  throw new ImageError(
    400,
    '"url" parameter is valid but image type is not allowed'
  )
}

// VECTOR_TYPES = [SVG]
if (VECTOR_TYPES.includes(upstreamType)) {
  return {
    buffer: upstreamBuffer,
    contentType: upstreamType,
    maxAge,
  }
}ts

벡터 이미지인 SVG도 마찬가지인데, next/image에서 SVG를 사용하려면 unoptimized={true}반드시 적용해야 한다.

next.config.mjs
const nextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    dangerouslyAllowSVG: true, 
    contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", 
  },
}js

프로젝트 전체에 적용하고 싶다면 위처럼 dangerouslyAllowSVG 설정이 필요하다.
SVG는 JavaScript를 포함할 수 있어 XSS 공격 등 보안상의 위험 때문에 ‘dangerous’라는 이름이 붙었으며, contentSecurityPolicy 설정을 통해 보안 정책을 설정할 수 있다.

최적화는 언제 이루어질까?

next/image의 최적화는 빌드 시점이 아닌, 브라우저에서 이미지를 요청할 때 이루어진다.
최적화 과정은 다음과 같다.

  1. HTML에 이미지 태그 렌더링

  2. 브라우저가 서버에 이미지 요청 (이때 Accept 헤더에 지원하는 이미지 포맷 정보 전송)

    • 예) Accept: image/avif,image/webp,image/*,*/*;q=0.8
  3. 서버가 이미지 최적화 수행

  4. 최적화된 이미지 응답

다음 예시를 통해 살펴보자.

import Image from 'next/image'
import testImage from '../public/test.png'

export default function Page() {
  return (
    <>
      <img src={testImage.src} alt="테스트 이미지" />
      <Image src={testImage} alt="테스트 이미지" />
    </>
  )
}tsx

위 코드는 다음과 같이 렌더링 된다.

<!-- img 태그: 원본 이미지 경로 -->
<img src="/_next/static/media/test.5a33257e.png" alt="테스트 이미지" />
<!-- Image 컴포넌트: 최적화를 위한 URL로 변환 -->
<img
  alt="테스트 이미지"
  loading="lazy"
  width="1048"
  height="743"
  decoding="async"
  data-nimg="1"
  style="color:transparent"
  srcset="
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.5a33257e.png&amp;w=1080&amp;q=75 1x,
    /_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.5a33257e.png&amp;w=3840&amp;q=75 2x
  "
  src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.5a33257e.png&amp;w=3840&amp;q=75"
/>html

img 태그와 달리 next/image 컴포넌트는 이미지 URL을 /_next/image?url=... 형태로 변환한다. 이 URL로 요청이 들어오면 Next.js의 이미지 최적화 미들웨어가 동작하여 다음과 같은 결과를 반환한다.

slow 4g 환경에서 테스트한 결과
slow 4g 환경에서 테스트한 결과

네트워크 탭을 살펴보면 기본 img 태그의 요청은 원본 PNG 이미지(794 KB)를 그대로 전송하고 next/image 컴포넌트의 요청은 WebP 형식(45.7 KB)으로 최적화되어 전송되는 것을 확인할 수 있다.
next/image의 두 번째 요청부터는 캐시에서 즉시 응답한다.

이처럼 요청 시점(런타임)에 최적화하기 때문에 디바이스나 브라우저 등 클라이언트의 상황에 맞춰 최적화가 가능하다.
최적화된 이미지는 캐시에 저장되어 같은 요청이 왔을 때 최적화 과정 없이 바로 응답한다.

캐시 정책

next/image의 이미지 캐시는 다음과 같은 정책으로 운영된다.

  • 캐시 저장: 최적화된 이미지는 <distDir>/cache/images 디렉토리에 저장됨
  • 캐시 유효 기간
    • minimumCacheTTL 설정값과 원본 이미지의 Cache-Control 헤더 중 더 큰 값을 사용
    • 기본적으로 60초 동안 캐시 되며, next.config.js에서 minimumCacheTTL 설정으로 조절 가능
    • 별도의 캐시 무효화 메커니즘이 없기 때문에 minimumCacheTTL을 너무 길게 설정하지 않는 것을 권장
      • 만약 강제로 갱신하려면 src를 변경하거나 캐시 파일을 직접 삭제해야 함
next.config.mjs
const nextConfig = {
  images: {
    minimumCacheTTL: 60, // 캐시 유효 기간을 60초로 설정 
  },
}js

캐시의 유효 기간이 만료된 이미지를 요청할 경우 만료된(stale) 이미지를 즉시 제공하고 백그라운드에서 새로운 이미지를 가져와 캐시를 업데이트한다.
캐시의 상태에 따라 x-next-cache 응답 헤더의 값이 MISS, STALE, HIT로 결정된다.

  • MISS: 캐시에 이미지가 없는 경우 (최초 요청 시)
  • STALE: 캐시는 있지만 유효 기간이 만료된 경우
  • HIT: 유효한 캐시가 있는 경우

최적화 과정을 다시 한번 요약해 보자면 다음과 같다.

캐시 상태에 따른 최적화 과정
캐시 상태에 따른 최적화 과정

빌드 타임 최적화

런타임에 이미지 최적화가 진행되지만, 빌드 시점(또는 개발 환경의 컴파일 시점)에 미리 준비할 수도 있다.

import testImage from '../public/test.png'

export default function Page() {
  return (
    <Image
      src={testImage}
      placeholder="blur"
      alt="테스트 이미지"
    />
  )
}jsx

이렇게 정적으로 이미지를 불러오면 NextJS는 다음과 같은 메타데이터 객체를 생성한다.

{
  src: '/_next/static/media/test.5a33257e.png',
  height: 743,
  width: 1048,
  blurDataURL: '/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.5a33257e.png&w=8&q=70',
  blurWidth: 8,
  blurHeight: 6
}js

빌드 시점에 이미지 메타데이터와 blur 이미지가 생성되기 때문에 런타임에 추가 계산이 필요 없고, 미리 생성된 메타데이터를 바탕으로 최적화를 수행하므로 widthheight 속성을 별도로 전달할 필요가 없다.

<Image
  src="/images/test.png"
  width={500}
  height={300}
  placeholder="blur"
  alt="테스트 이미지"
/>jsx

만약 위처럼 문자열로 직접 경로를 지정하면 Layout Shift를 방지하기 위해 widthheight반드시 지정해야 한다.
또한 미리 blurDataURL 데이터를 가지고 있는 정적 import 방식과 달리 placeholder="blur" 옵션을 사용하면 첫 요청 시점blurDataURL이 생성된다.

Sharp VS Squoosh

next/image 컴포넌트는 Sharp 또는 Squoosh 라이브러리를 사용해 이미지를 최적화한다.

next/src/server/image-optimizer.ts
let showSharpMissingWarning = process.env.NODE_ENV === 'production'

if (sharp) {
  // sharp 패키지가 있으면 sharp로 최적화
  const transformer = sharp(buffer, {
    sequentialRead: true,
  })
  // ...
} else {
  // production 모드이고 output: "standalone" 이면 Sharp 패키지 필수
  if (showSharpMissingWarning && nextConfigOutput === 'standalone') {
    Log.error(
      `Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly. Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production`
    )
    throw new ImageError(500, 'Internal Server Error')
  }

  // production 모드에서 Sharp 패키지 권장
  if (showSharpMissingWarning) {
    Log.warnOnce(
      `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended. Run 'npm i sharp', and Next.js will use it automatically for Image Optimization.\n` +
        'Read more: https://nextjs.org/docs/messages/sharp-missing-in-production'
    )
    showSharpMissingWarning = false
  }

  // Squoosh 변환 로직
  const orientation = await getOrientation(buffer)
  const { processBuffer } =
    require('./lib/squoosh/main') as typeof import('./lib/squoosh/main')
  // ...
}tsx

Sharp가 설치되어 있지 않다면 Squoosh를 사용해 최적화를 진행한다. 단, 개발 모드에서는 Sharp 없이도 경고가 발생하지 않지만 production 모드에서는 Sharp 설치를 권장한다.
특히 output: “standalone” 설정이 되어 있다면 Sharp가 필수이며, 없을 경우 에러가 발생한다.

Next15부터 달라진 점

NextJS 15부터는 기본적으로 sharp를 사용하기 때문에 sharp를 수동 설치할 필요 없다.
소스 코드를 살펴보면 이전과 달리 조건문 없이 Sharp를 사용해 최적화를 진행하는 것을 확인할 수 있다.

Sharp를 권장하는 이유

Sharp는 C++ 라이브러리인 libvips 이미지를 최적화해 속도가 빠르다. 반면 Squoosh는 웹 브라우저에서 실행되는 이미지 최적화 도구로 시작했고 WebAssembly 기반으로 만들어져 상대적으로 느리다.

next/image는 런타임에 최적화를 진행하는 만큼 속도가 빠른 Sharp를 권장한다.

Sharp (권장)Squoosh (대체 옵션)
구현네이티브 C++ 기반WebAssembly(WASM) 기반
성능빠름상대적으로 느림
리소스CPU 사용량과 메모리 효율이 좋음CPU 부하가 큼

요약

  • 런타임 최적화: 요청 시점에 클라이언트의 상황(브라우저, 디바이스)에 맞춰 이미지를 최적화
  • 포맷 변환: 브라우저의 Accept 헤더를 확인해 지원하는 최신 포맷(AVIF, WebP 등)으로 자동 변환
  • 성능 최적화: Sharp를 사용한 빠른 이미지 처리와 효율적인 캐싱 전략으로 성능 향상
  • 특수케이스 처리: SVG나 애니메이션 이미지(GIF 등)는 최적화 대상에서 제외되며, unoptimized 옵션 사용을 권장
  • 빌드 타임 최적화: 정적 import를 통해 이미지 메타데이터를 미리 생성하고, 이를 바탕으로 최적화 수행 가능

맺으면서

지금까지 NextJS의 이미지 최적화 기능을 살펴봤다. next/image를 사용하면 다양한 최적화 전략을 자동으로 적용해 웹 성능을 향상시킬 수 있다.

이번 포스팅을 작성하며 공식 문서와 소스 코드를 살펴보고 이미지 최적화 원리에 대해 깊이 있게 학습할 수 있었다. 단순히 컴포넌트를 사용하는 것을 넘어 내부 동작 원리를 이해할 수 있는 좋은 기회였다.

next/image 컴포넌트 사용법은 공식 문서에서 더 자세하게 살펴볼 수 있다.