Next.js Image 어떻게 최적화 할까?
2025-04-23
NextImage
toc
Next.js Image 최적화
(2025.4 Canary 버전 기준)
왜 사용하는가?
- 크기 최적화: 웹 친화적인 이미지 포맷으로 변환 (AVIF, WEBP 등)
이미지 포맷 비교 표
| 특성 | AVIF | JPG/JPEG | PNG | WebP | MozJPEG |
|---|---|---|---|---|---|
| 개발자 | Alliance for Open Media | Joint Photographic Experts Group | PNG Development Group | Mozilla | |
| 압축 방식 | 손실/무손실 | 손실 | 무손실 | 손실/무손실 | 손실 |
| 파일 크기 | 매우 작음 | 중간 | 큼 | 작음 | 중간-작음 |
| 이미지 품질 | 매우 높음 | 중간-높음 | 매우 높음 | 높음 | 높음 |
| 투명도 지원 | 지원 | 미지원 | 지원 | 지원 | 미지원 |
| 애니메이션 | 지원 | 미지원 | 미지원(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/jpeg | Content-Type: image/jpeg; charset=UTF-8 |
| 독립성 | 다양한 프로토콜에서 사용 | HTTP 헤더의 일부로 한정 |
Next.js Image 요청 처리 흐름
- 브라우저가
/_next/image/...경로로 요청 handleNextImageRequest에서 요청 라우팅- 파라미터 검증 및 캐시 확인
- 캐시가 있다면 반환
- 없다면
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: 각 포맷에 맞는 옵션 적용
- AVIF:
- 최종 버퍼 반환
실험적 옵션(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
- 만료된 이미지 요청 시, 바로 이전 이미지(STALE)를 먼저 응답하고, 백그라운드에서 새로 최적화해서 캐시에 다시 저장한다 →
- 응답 헤더로 캐시 상태 확인 가능
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