최근에 링크드인에 Next 이미지 최적화에 대한 글이 올라온 것을 보고 문득 나도 궁금해져서 실제 코드를 살펴보면서 정리해보고 싶어 글을 쓰게 되었다.
같이 보면 좋은 글
아래 글은
Next.js v15
main 브랜치를 기준으로 작성하였습니다.
Next 공식문서에서 이미지 최적화에 대해서 아래와 같이 설명하고 있다.
Next.js Image Optimization의 기능
- Size Optimization: Automatically serve correctly sized images for each device, using modern image formats like WebP and AVIF.
- Visual Stability: Prevent layout shift automatically when images are loading.
- Faster Page Loads: Images are only loaded when they enter the viewport using native browser lazy loading, with optional blur-up placeholders.
- Asset Flexibility: On-demand image resizing, even for images stored on remote servers
- 사이즈 최적화:
WebP
/AVIF
와 같은 이미지 포맷을 사용해 각 디바이스에 정확한 사이즈를 자동으로 제공해준다.- 시각적 안정성: Layout Shift를 자동으로 방지해준다.
- 빠른 페이지 로드: blur-up placeholders(optional)과 lazy loading을 사용하여 뷰포트 내에 들어왔을 때에만 이미지를 불러온다.
- Asset 유연성: 원격 서버에 있는 이미지여도 이미지를 On-demand 이미지 리사이징을 한다.
이런 다양한 기능들을 어떤식으로 처리하고 있는지 정리해보려고 한다.
img
vs Next/Image
의 각각 살펴보기먼저 코드에 이렇게 각각의 태그를 사용하여 렌더링했을 때 어떤 차이가 있는지 살펴보려고 한다.
<img src="/test.jpg" alt="test img" />
<Image src="/test.jpg" alt="test img" width={952} height={1198} />
제한 없음
느린 4G
3G
네트환경이 좋지 않은 환경일 수록 차이가 더 심해지는 모습을 보인다.
image/jpeg
,image/webp
로 서로 다른 형태를 응답받게 된다./test.jpg
렌더링된다./_next/image?url=%2Ftest.jpg&w=1920&q=75
)Next/Image를 사용하게 되면 첫 요청 이후에는 .next/cache 폴더 하위에 이미지를 캐시하고 이를 계속 재사용하게 된다.
위에 사진에서 눈치를 챘는지 모르겠지만, Next/Image를 사용하면 X-Nextjs-Cache
라는 필드가 있다. 여기에 Cache 상태를 확인하여 이미지를 내려주게 된다.
실제로 어떻게 동작하는지 네트워크 요청을 살펴보면,
- .next/cache에 없으면 X-Nextjs-Cache
가 MISS 로 내려온다.
X-Nextjs-Cache
가 HIT 가 내려오게 된다.당연하게도 캐시가 된 이후에는 요청시간도 줄어든다.
여기서 신기한 점은 Next/image를 쓰면 첫 요청에는 최적화 과정이 들어가 img태그를 썼을 때보다 더 오래 걸린다. (물론 네트환경이 좋았을 때이다.)
전체적으로 기존 img태그와 어떻게 다르게 동작하는지 살펴보았으니, 실제 Next 코드에는 어떻게 되어있는지 보려고 한다. 각 코드마다 실제 레포의 링크도 달아두었으니 궁금하면 직접 가서 보는 것도 추천한다. 🤣
packages/src/server/image-optimizer.ts
에서 imageOptimizer
라는 함수를 통해 이미지 최적화를 진행한다.
// https://github.com/vercel/next.js/blob/main/packages/next/src/server/image-optimizer.ts#L570-L605
if (upstreamType) {
// svg 포맷이고 dangerouslyAllowSVG의 옵션을 사용하지 않을 경우 최적화하지 않도록 처리
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'
);
}
// 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 };
}
// 벡터(svg+xml)가 포함되면 최적화하지 않도록 처리
if (VECTOR_TYPES.includes(upstreamType)) {
// We don't warn here because we already know that "dangerouslyAllowSVG"
// was enabled above, therefore the user explicitly opted in.
// If we add more VECTOR_TYPES besides SVG, perhaps we could warn for those.
return { buffer: upstreamBuffer, contentType: upstreamType, maxAge };
}
// 정상적이지 않은 케이스는 에러 처리
if (!upstreamType.startsWith("image/") || upstreamType.includes(",")) {
Log.error(
"The requested resource isn't a valid image for",
href,
"received",
upstreamType
);
throw new ImageError(400, "The requested resource isn't a valid image.");
}
}
위 코드를 살펴보면 이미지 최적화를 처리하기 전에 방어 로직이 위에 차지하고 있다. 즉, 모든 이미지에 대해서 이미지 최적화 처리를 하는 것이 아니다.
바로 다음 코드이다.
// https://github.com/vercel/next.js/blob/main/packages/next/src/server/image-optimizer.ts#L607-L620
let contentType: string
// contentType을 지정하는 과정이다.
// 여기서 mimeType의 경우 지원하는 버전에만 처리하기 때문에, 지원하지 않으면 빈스트링으로 들어와 다른 조건문을 타게 된다.
if (mimeType) {
contentType = mimeType
} else if (
upstreamType?.startsWith('image/') &&
getExtension(upstreamType) &&
upstreamType !== WEBP &&
upstreamType !== AVIF
) {
contentType = upstreamType
} else {
contentType = JPEG
}
✋ 여기서 잠깐 다른 내용을 짚고 넘어가보려고 한다.
🧶 MIME Type과 Content Type의 차이란?
참고: (https://velog.io/@rookieand/MIME-type은-뭐고-Content-type은-뭔데)
MIME(Multipurpose Internet Mail Extensions)란?
오직 텍스트만 보낼 수 있었던 SMTP(Simple Mail Transfer Protocol)
을 보완하여 메세지 내부에 다른 파일을 전송할 수 있도록 하는 전자메일 프로토콜 이다.
현재는 전자메일에만 쓰이지 않고 웹 전반적으로 파일을 전송하는데 쓰인다.
MIME type이란
Content-type이란
웹 서버에서 HTTP 통신을 통해 받은 요청 헤더에 요청의 Body에 들어있는 데이터 타입에 대한 정보를 나타낸다.
⇒ 요청 Body에 들어가는 데이터타입을 명시하는 용도로 사용된다.
둘의 차이점?
쉽게 표현하면 요청 Body에 들어가는 데이터 타입을 MIME type으로 표기하면 그게 Content-Type인 것이다.
다시 위의 코드로 돌아와서 브라우저 별로 지원하는 이미지 포맷이 다르기 때문에 이를 확인하여 자동으로 지원하는 포맷으로 변환해주는 것이다.
브라우저 별로 지원하는 이미지 포멧이 다른 것을 볼 수 있다.
내부 코드는 다음과 같이 되어 있다.
// https://github.com/vercel/next.js/blob/main/packages/next/src/server/image-optimizer.ts#L86-L89
// 지원하는 MimeType만 가져온다.
function getSupportedMimeType(options: string[], accept = ''): string {
const mimeType = mediaType(accept, options)
return accept.includes(mimeType) ? mimeType : ''
}
export class ImageOptimizerCache {
// ...
static validateParams(
req: IncomingMessage,
query: UrlWithParsedQuery['query'],
nextConfig: NextConfigComplete,
isDev: boolean
): ImageParamsResult | { errorMessage: string } {
const imageData = nextConfig.images
const {
deviceSizes = [],
imageSizes = [],
domains = [],
minimumCacheTTL = 60,
formats = ['image/webp'],
} = imageData
// ...
// https://github.com/vercel/next.js/blob/main/packages/next/src/server/image-optimizer.ts#L277C11-L277C19
// 지원하는 mimeType을 체크하는 곳
// 요청 헤더의 accept를 통해 브라우저가 지원하는 포맷인지 확인하는 것
// 공식문서: https://nextjs.org/docs/app/api-reference/components/image#formats
const mimeType = getSupportedMimeType(formats || [], req.headers['accept'])
// ...
}
// Next/Image 컴포넌트 요청에 쓰이는 라우트 핸들러
protected handleNextImageRequest: NodeRouteHandler = () => {
//...
// https://github.com/vercel/next.js/blob/main/packages/next/src/server/next-server.ts#L852-L857
// paramsResult는 결국에 [imageOptimizer](https://github.com/vercel/next.js/blob/main/packages/next/src/server/image-optimizer.ts#L549)에 받는 params로 들어가게 된다.
const paramsResult = ImageOptimizerCache.validateParams(
req.originalRequest,
parsedUrl.query,
this.nextConfig,
!!this.renderOpts.dev
)
...
}
이제 실제로 이미지 최적화하는 함수가 보인다.
// https://github.com/vercel/next.js/blob/main/packages/next/src/server/image-optimizer.ts#L621-L666
try {
// 실제로 이미지 최적화하는 함수를 실행한다.
let optimizedBuffer = await optimizeImage({
buffer: upstreamBuffer,
contentType,
quality,
width,
})
// 최적화한 이미지를 return 한다.
if (optimizedBuffer) {
if (isDev && width <= BLUR_IMG_SIZE && quality === BLUR_QUALITY) {
// Dev모드일 때는 블러이미지를 웹팩으로 만들면 dev server를 띄우는 데에 시간이 오래 걸리기 떄문에, 별도 처리를 하는 과정이다.
// During `next dev`, we don't want to generate blur placeholders with webpack
// because it can delay starting the dev server. Instead, `next-image-loader.js`
// will inline a special url to lazily generate the blur placeholder at request time.
const meta = await getImageSize(optimizedBuffer)
const opts = {
blurWidth: meta.width,
blurHeight: meta.height,
blurDataURL: `data:${contentType};base64,${optimizedBuffer.toString(
'base64'
)}`,
}
optimizedBuffer = Buffer.from(unescape(getImageBlurSvg(opts)))
contentType = 'image/svg+xml'
}
return {
buffer: optimizedBuffer,
contentType,
maxAge: Math.max(maxAge, nextConfig.images.minimumCacheTTL),
}
} else {
throw new ImageError(500, 'Unable to optimize buffer')
}
} catch (error) {
if (upstreamBuffer && upstreamType) {
// If we fail to optimize, fallback to the original image
return {
buffer: upstreamBuffer,
contentType: upstreamType,
maxAge: nextConfig.images.minimumCacheTTL,
}
} else {
throw new ImageError(
400,
'Unable to optimize image and unable to fallback to upstream image'
)
}
}
🧶 placeholder 옵션이란?
이미지가 로딩 중일 때 svg형태로 미리 이미지를 띄우는 옵션이다.
공식 문서: https://nextjs.org/docs/app/api-reference/components/image#placeholderplaceholder를 테스트할 수 있는 데모 사이트
https://image-component.nextjs.gallery/placeholder
실질적으로 이미지를 최적화하는 함수는 아래 코드로 되어 있다.
// https://github.com/vercel/next.js/blob/main/packages/next/src/server/image-optimizer.ts#L433-L473
export async function optimizeImage({
buffer,
contentType,
quality,
width,
height,
}: {
buffer: Buffer
contentType: string
quality: number
width: number
height?: number
}): Promise<Buffer> {
const sharp = getSharp()
const transformer = sharp(buffer).timeout({ seconds: 7 }).rotate()
if (height) {
transformer.resize(width, height)
} else {
transformer.resize(width, undefined, {
withoutEnlargement: true,
})
}
if (contentType === AVIF) {
const avifQuality = quality - 20
transformer.avif({
quality: Math.max(avifQuality, 1),
})
} else if (contentType === WEBP) {
transformer.webp({ quality })
} else if (contentType === PNG) {
transformer.png({ quality })
} else if (contentType === JPEG) {
transformer.jpeg({ quality, mozjpeg: true })
}
const optimizedBuffer = await transformer.toBuffer()
return optimizedBuffer
}
Next는 내부적으로 sharp
패키지를 사용하여 최적화를 하고 있어서 코드가 굉장히 간단하다.
각 이미지 포맷에 맞춰서 최적화하고 리턴하는게 전부이다.
❗️ Next v15부터는 squoosh 를 제거하고
sharp
를 선택하였다.그렇게 되면서
sharp
를 따로 수동으로 설치하지 않고 Next에서 자동으로sharp
를 사용하게 된다.
맨 처음 기존 img 태그와 Next/image 컴포넌트를 비교했을 때 searchParams를 사용하여 렌더링하는 차이점이 있었다. 그럼 이 부분에 대해서 어떻게 처리하는지도 찾아보았다.
이는 공통으로 쓰기 위해서인지 next/src/shared/lib
하위로 유틸함수를 만들어두었다.
/**
* A shared function, used on both client and server, to generate the props for <img>.
*/
export function getImgProps(
// ...
// https://github.com/vercel/next.js/blob/main/packages/next/src/shared/lib/get-img-props.ts#L662-L670
// generateImgAttrs는 props로 받은 것으로 'w','h','q'를 만들어주는 역할을 한다.
const imgAttributes = generateImgAttrs({
config,
src,
unoptimized,
width: widthInt,
quality: qualityInt,
sizes,
loader,
})
const props: ImgProps = {
...rest,
loading: isLazy ? 'lazy' : loading,
fetchPriority,
width: widthInt,
height: heightInt,
decoding: 'async',
className,
style: { ...imgStyle, ...placeholderStyle },
sizes: imgAttributes.sizes,
srcSet: imgAttributes.srcSet,
src: overrideSrc || imgAttributes.src,
}
const meta = { unoptimized, priority, placeholder, fill }
return { props, meta }
}
이 함수는 결국 Next/Image
에 Image 컴포넌트에 사용되게 된다.
이렇게 설정한 src 경로를 통해 라우트 핸들러(handleNextImageRequest
)에서 확인하여 처리를 하는 것이다.
라우터 핸들러에서 ImageOptimizerCache
인스턴스를 생성하고 유효성 체크가 이뤄진다.
// Next/Image 컴포넌트 요청에 쓰이는 함수
protected handleNextImageRequest: NodeRouteHandler = () => {
//...
// https://github.com/vercel/next.js/blob/main/packages/next/src/server/next-server.ts#L834C7-L837C9
// ImageOptimizerCache 인스턴스를 생성
const imageOptimizerCache = new ImageOptimizerCache({
distDir: this.distDir,
nextConfig: this.nextConfig,
})
// https://github.com/vercel/next.js/blob/main/packages/next/src/server/next-server.ts#L852-L857
// validateParams: https://github.com/vercel/next.js/blob/main/packages/next/src/server/image-optimizer.ts#L168
// 아까 위에서 봤던 코드인데, 여기서 유효성 체크를 하는 것이다.
const paramsResult = ImageOptimizerCache.validateParams(
req.originalRequest,
parsedUrl.query,
this.nextConfig,
!!this.renderOpts.dev
)
...
}
Next에서 이미지를 마법처럼 최적화하는 듯 해보이지만 그 내부에는 코드로 세세히 처리를 하는 과정들이 들어 있었다. 결국 Next에서도 이미지를 최적화하는 것은 sharp 라이브러리에 의존하고 있기 때문에 다음에 시간이 되면 sharp 라이브러리에서는 이미지를 어떻게 최적화 하는지에 대해서도 정리해보려고 한다.
이 외에도 Image 컴포넌트에서 제공하는 다양한 Options과 Props, Config들이 있는데 이는 공식문서를 통해 살펴보고 마친다.