Next.js Image 어떻게 최적화 할까?

2025-04-23
NextImage

toc

Next.js Image 최적화

(2025.4 Canary 버전 기준)


왜 사용하는가?

  • 크기 최적화: 웹 친화적인 이미지 포맷으로 변환 (AVIF, WEBP 등)

이미지 포맷 비교 표

특성AVIFJPG/JPEGPNGWebPMozJPEG
개발자Alliance for Open MediaJoint Photographic Experts GroupPNG Development GroupGoogleMozilla
압축 방식손실/무손실손실무손실손실/무손실손실
파일 크기매우 작음중간작음중간-작음
이미지 품질매우 높음중간-높음매우 높음높음높음
투명도 지원지원미지원지원지원미지원
애니메이션지원미지원미지원(APNG 제외)지원미지원
브라우저 호환성제한적매우 높음매우 높음높음매우 높음
적합한 용도웹 이미지, 사진사진, 웹 이미지로고, 아이콘, 스크린샷웹 이미지, 애니메이션최적화된 웹 사진
색상 깊이8-12비트8비트8-16비트8비트8비트
HDR 지원지원미지원미지원제한적미지원
  • 웹사이트 성능 최적화: AVIF나 WebP
  • 호환성: JPG나 PNG

참고) MIME Type vs Content-Type

항목MIME 타입Content-Type
정의파일 형식 식별을 위한 표준HTTP 응답의 리소스 타입 명시
사용 범위이메일, 웹, 프로그램 등 광범위HTTP 통신에서 주로 사용
포함 정보타입/서브타입 (예: text/html)MIME 타입 + 선택 파라미터
예시image/jpegContent-Type: image/jpeg; charset=UTF-8
독립성다양한 프로토콜에서 사용HTTP 헤더의 일부로 한정

Next.js Image 요청 처리 흐름

  1. 브라우저가 /_next/image/... 경로로 요청
  2. handleNextImageRequest에서 요청 라우팅
  3. 파라미터 검증 및 캐시 확인
  4. 캐시가 있다면 반환
  5. 없다면 imageOptimizer 호출하여 최적화 수행 후 캐시에 저장

주요 파일

  • next-server.ts (server)
  • image-optimizer.ts (server)
  • image-external.ts (client)
  • image-component.ts(client)

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"]);
  • header에서 accept를 추출하여 브라우저에서 지원하는 값을 추출하고, 이 options 는
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    formats: ["image/avif", "image/webp"],
  },
};

export default nextConfig;
  • 여기 작성한 formats 옵션이 들어간다. 기본값은 [image/webp]이다. 지원하지 않으면 원본 포맷을 유지한다.

이미지 최적화 주요 처리

Next.js가 관리하는 이미지 속성값들

const AVIF = "image/avif";
const WEBP = "image/webp";
const PNG = "image/png";
const JPEG = "image/jpeg";
const GIF = "image/gif";
const SVG = "image/svg+xml";
const ICO = "image/x-icon";
const ICNS = "image/x-icns";
const TIFF = "image/tiff";
const BMP = "image/bmp";
const ANIMATABLE_TYPES = [WEBP, PNG, GIF]; // 움직일만한 타입(PNG는 APNG라는 포맷이 있다.)
const BYPASS_TYPES = [SVG, ICO, ICNS, BMP]; // 최적화 안해주는 타입들

지원되지 않는 이미지

if (upstreamType.startsWith("image/svg") && !nextConfig.images.dangerouslyAllowSVG)
  • SVG는 dangerouslyAllowSVG 설정이 꺼져 있으면 에러 반환
if (ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer))
  • 애니메이션 이미지(WEBP, PNG(APNG), GIF)는 최적화하지 않고 원본 반환
if (BYPASS_TYPES.includes(upstreamType))
  • 최적화 대상에서 제외 (SVG, ICO, ICNS, BMP 등)

contentType 결정

let contentType = mimeType ?? upstreamType ?? JPEG;
  • MIME 타입 > upstreamType > 기본값 JPEG 순서로 지정

이미지 최적화

let optimizedBuffer = await optimizeImage({
  buffer: upstreamBuffer,
  contentType,
  quality,
  width,
  concurrency: nextConfig.experimental.imgOptConcurrency,
  limitInputPixels: nextConfig.experimental.imgOptMaxInputPixels,
  sequentialRead: nextConfig.experimental.imgOptSequentialRead,
  timeoutInSeconds: nextConfig.experimental.imgOptTimeoutInSeconds,
});

return {
  buffer: optimizedBuffer,
  contentType,
  maxAge,
  etag: getImageEtag(optimizedBuffer),
  upstreamEtag,
};
  • optimizedBuffer를 받게된다.
export async function optimizeImage(...) { ... }

주요 처리:

  • sharp 라이브러리 사용(squoosh는 제외됨)
  • 사이즈 조절: resize(width, height)
  • 포맷별 설정:
    • AVIF: quality - 20, effort: 3
    • WEBP, PNG, JPEG: 각 포맷에 맞는 옵션 적용
  • 최종 버퍼 반환

실험적 옵션(sharp에서 지원)

옵션명설명
concurrency동시 최적화 제한
limitInputPixels입력 이미지 픽셀 제한
sequentialRead순차 읽기 활성화 여부
timeoutInSeconds최적화 타임아웃 설정

캐싱 처리

캐시 저장 방식

async function writeToCacheDir(
...
) {
  const filename = join(
    dir,
    `${maxAge}.${expireAt}.${etag}.${upstreamEtag}.${extension}`
  )
... await promises.writeFile(filename, buffer)
}

  • 이미지 캐시 파일명은 정보 기반으로 구성

next-server.ts

handleNextImageRequest(이미지 요청 핸들러)

	 // URL 경로가 없거나 '/_next/image'로 시작하지 않으면 처리하지 않음
    if (!parsedUrl.pathname || !parsedUrl.pathname.startsWith('/_next/image')) {
      return false
    }
    // 미들웨어 요청이면 처리하지 않음
    if (getRequestMeta(req, 'middlewareInvoke')) {
      return false
    }
     // 최소 모드이거나 정적 내보내기 모드일 경우 이미지 최적화를 처리하지 않고 400 오류 반환
    if (
      this.minimalMode ||
      this.nextConfig.output === 'export' ||
      process.env.NEXT_MINIMAL
    )

이미지 최적화 캐시 클래스

const imageOptimizerCache = new ImageOptimizerCache({
  distDir: this.distDir,
  nextConfig: this.nextConfig,
});
  • 최적화된 이미지가 저장되는 디렉토리를 관리하는 클래스
  • 이미지 최적화 캐시 인스턴스 생성
const paramsResult = ImageOptimizerCache.validateParams();
const cacheKey = ImageOptimizerCache.getCacheKey(paramsResult);
  • 이미지 최적화 캐시 클래스에서 이미지 검증과 캐시 키를 가져옴
   const cacheEntry = await this.imageResponseCache.get(
          cacheKey,
          async ({ previousCacheEntry }) => {
            const { buffer, contentType, maxAge, upstreamEtag, etag } =
              await this.imageOptimizer(
              ...
              )
         ...},
          {
            routeKind: RouteKind.IMAGE,
            incrementalCache: imageOptimizerCache,
            isFallback: false,
          }
        )

if (!key) {
  return responseGenerator({ hasResolved: false, previousCacheEntry: null });
}
  • 이런식으로 imageOtimizer를 this.imageResponseCache.get에서 활용하고 있다.

imageOptimizer

protected async imageOptimizer(...) {
				...
				// 절대 루트인지 확인해서 외부 이미지, 내부이미지 구분
			const imageUpstream = isAbsolute
        ? await fetchExternalImage(href)
        : await fetchInternalImage(
            href,
            req.originalRequest,
            res.originalResponse,
            handleInternalReq
          )
				//image-optimizer.ts에 있는 함수
      return imageOptimizer(imageUpstream, paramsResult, this.nextConfig, {
        isDev: this.renderOpts.dev,
        previousCacheEntry,
      })}
  • 외부 URL → fetchExternalImage(href)
  • 내부 URL → fetchInternalImage(...)
  • 이후 최적화 수행 및 캐시 저장

Client

image-external.ts


export function getImageProps(imgProps: ImageProps) {
  ...
}

export default Image

export type { ImageProps, ImageLoaderProps, ImageLoader, StaticImageData }
  • 기본적으로 Image 컴포넌트를 내보내고, 타입과 getImageProps 함수를 내보낸다.

defaultLoader

  • next image의 src path 리턴
import type { ImageLoaderPropsWithConfig } from "./image-config";

const DEFAULT_Q = 75;

function defaultLoader({
  config,
  src,
  width,
  quality,
}: ImageLoaderPropsWithConfig): string {
  // 이미지 유효성 확인 로직
  const q =
    quality ||
    // image 퀄리티를 제한하려면 next.config에 구성을 추가할 수 있습니다.
    config.qualities?.reduce((prev, cur) =>
      Math.abs(cur - DEFAULT_Q) < Math.abs(prev - DEFAULT_Q) ? cur : prev
    ) ||
    DEFAULT_Q;

  return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${q}${
    src.startsWith("/_next/static/media/") && process.env.NEXT_DEPLOYMENT_ID
      ? `&dpl=${process.env.NEXT_DEPLOYMENT_ID}`
      : ""
  }`;
}

// We use this to determine if the import is the default loader
// or a custom loader defined by the user in next.config.js
defaultLoader.__next_img_default = true;

export default defaultLoader;
  • 사용자가 직접 quality를 지정했다면 그걸 쓰고, 지정 안 했다면 config.qualities 목록에서 75와 가장 가까운 걸 골라서 쓰고, 그것도 없으면 기본값인 75를 사용한다.
const DEFAULT_Q = 75;

const qualities = [60, 70, 80, 90];

const closestQuality = qualities.reduce((prev, cur) => {
  const prevDiff = Math.abs(prev - DEFAULT_Q);
  const curDiff = Math.abs(cur - DEFAULT_Q);

  return Math.abs(cur - DEFAULT_Q) < Math.abs(prev - DEFAULT_Q) ? cur : prev;
});

console.log(closestQuality);
  • 확인해보기

getImgProps

export function getImgProps(
({ ...ImageProps }, _state) => {
  return {
    props: 실제 <img> 태그에 들어갈 최종 속성들,
    meta: {
      unoptimized: boolean // 최적화 비활성화 여부
      priority: boolean // 우선 로딩 여부
      placeholder: 'blur' | 'empty' // 플레이스홀더 타입
      fill: boolean // 부모 요소에 맞게 꽉 채울지 여부
    }
  }
}
// 예외 처리 및 이미지 스타일, 백그라운드 이미지, 사이즈 속성이 설정된 후
const imgAttributes = generateImgAttrs({
  config,
  src,
  unoptimized,
  width: widthInt,
  quality: qualityInt,
  sizes,
  loader,
});
  • Image의 src, sizes, srcSet을 결정하는 generateImgAttrs를 통과한다.

generateAttrs

function generateImgAttrs({
  config,
  src,
  unoptimized,
  width,
  quality,
  sizes,
  loader,
}: GenImgAttrsData): GenImgAttrsResult {
  if (unoptimized) {
    return { src, srcSet: undefined, sizes: undefined };
  }
  // size가 있는 경우 sizes 또는 width 값을 기준으로 적절한 width 목록을 계산
  // width가 있는 경우, kind:x (배율계산)
  // size, width가 없다면 deviceSize를 씀
  const { widths, kind } = getWidths(config, width, sizes);

  const last = widths.length - 1;
  // kind는 w(width), x(배율)
  return {
    sizes: !sizes && kind === "w" ? "100vw" : sizes,
    srcSet: widths
      .map(
        (w, i) =>
          `${loader({ config, src, quality, width: w })} ${
            kind === "w" ? w : i + 1
          }${kind}`
      )
      .join(", "),
    src: loader({ config, src, quality, width: widths[last] }),
  };
}

return

const props: ImgProps = {
  ...rest,
  loading: isLazy ? "lazy" : loading,
  fetchPriority,
  width: widthInt,
  height: heightInt,
  decoding,
  className,
  style: { ...imgStyle, ...placeholderStyle },
  sizes: imgAttributes.sizes,
  srcSet: imgAttributes.srcSet,
  src: overrideSrc || imgAttributes.src,
};
const meta = { unoptimized, priority, placeholder, fill };
return { props, meta };
  • 이로써 최종 Image 컴포넌트로의 속성들이 결정된다.

<Image/>

export const Image = forwardRef<HTMLImageElement | null, ImageProps>(
  (props, forwardedRef) => {

    const isAppRouter = !pagesRouter

    const configContext = useContext(ImageConfigContext)
    // config를 가져옴
    const config = useMemo(() => {
      const c = configEnv || configContext || imageConfigDefault
      const allSizes = [...c.deviceSizes, ...c.imageSizes].sort((a, b) => a - b)
      const deviceSizes = c.deviceSizes.sort((a, b) => a - b)
      const qualities = c.qualities?.sort((a, b) => a - b)
      return { ...c, allSizes, deviceSizes, qualities }
    }, [configContext])

...
    const { props: imgAttributes, meta: imgMeta } = getImgProps(props, {
      defaultLoader,
      imgConf: config,
      blurComplete,
      showAltText,
    })

   return ...
  }
)

return (
  <>
    {
      <ImageElement
        {...imgAttributes}
        unoptimized={imgMeta.unoptimized}
        placeholder={imgMeta.placeholder}
        fill={imgMeta.fill}
        onLoadRef={onLoadRef}
        onLoadingCompleteRef={onLoadingCompleteRef}
        setBlurComplete={setBlurComplete}
        setShowAltText={setShowAltText}
        sizesInput={props.sizes}
        ref={forwardedRef}
      />
    }
    // 미리 로드
    {imgMeta.priority ? (
      <ImagePreload isAppRouter={isAppRouter} imgAttributes={imgAttributes} />
    ) : null}
  </>
);
  • imgMeta.priority 속성을 활용해 Preload 판단을 하고, preload를 한다
    • App router라면 ReactDom.Preload를 활용해 preload를 한다
    • 아니라면, Head 컴포넌트를 사용하여 preload

번외

Image 캐싱 정책

  • 이미지 캐싱 위치
    • 이미지는 요청 시 실시간으로 최적화되며, 결과는 /.next/cache/images 폴더에 저장된다.
    • 저장된 이미지는 만료될 때까지 반복해서 사용된다.
  • 만료되었을 때
    • 만료된 이미지 요청 시, 바로 이전 이미지(STALE)를 먼저 응답하고, 백그라운드에서 새로 최적화해서 캐시에 다시 저장한다 → revalidation
  • 응답 헤더로 캐시 상태 확인 가능
    • x-nextjs-cache 헤더 값으로 확인 가능:
      • MISS: 캐시에 없음 (첫 요청 시)
      • STALE: 만료된 이미지지만 즉시 응답하고 백그라운드 재검증 중
      • HIT: 유효한 캐시 이미지로 응답
  • 캐시 만료 기준
    • minimumCacheTTL과 원본 이미지의 Cache-Control 헤더의 max-age더 큰 값이 사용된다.
    • s-maxage가(CDN, proxy) 있으면 max-age보다 우선된다.
    • 이 값은 CDN, 브라우저 등 downstream 클라이언트에게도 전달된다.
  • minimumCacheTTL 설정
    • 기본값: 60초
    • 설정 예:
      module.exports = {
        images: {
          minimumCacheTTL: 2678400, // 31일
        },
      };
      
    • TTL(Time To Live - 캐시의 수명)이 낮으면 캐시 재검증이 자주 일어나지만 변경 사항 반영이 빠르다.
    • TTL이 높으면 비용 절감에는 유리하지만 캐시 무효화가 어렵다.
  • 주의 사항
    • 개별 이미지의 캐시를 무효화하려면 src를 변경하거나 .next/cache/images 폴더를 직접 삭제
    • /_next/image가 아닌 실제 원본 이미지(/some-asset.jpg)의 Cache-Control 헤더를 설정하여 캐시 전략을 개별적으로 제어할 수 있음.

Vercel 배포 시 주의점(2025.4월 기준)

항목hobby 포함pro 포함Pro/Enterprise 추가 요금
이미지 변환5천 달러/월1만 달러/월1K당 $0.05 - $0.0812
이미지 캐시 읽기30만/월60만/월1M당 $0.40 - $0.64
이미지 캐시 쓰기10만/월20만/월1M당 $4.00 - $6.40

Vercel에서 최적화된 모든 이미지에는 다음과 같은 제한이 적용된다.

  • 변환된 이미지의 최대 크기는 캐시 가능한 응답 제한 에 명시된 대로 10MB
  • 각 소스 이미지의 최대 너비와 높이는 8192픽셀이다.
  • 최적화하려면 원본 이미지가 다음 형식 중 하나여야 한다. image/jpeg, image/png, image/webp, image/avif