이 글은 NextJS 14.2.23 버전을 기준으로 작성되었습니다.
웹에서 이미지는 페이지 로딩 속도와 사용자 경험에 큰 영향을 미친다. 최적화되지 않은 이미지는 페이지 로딩을 느리게 하고 사용자 이탈률을 높이며, SEO에도 부정적인 영향을 미칠 수 있다.
이를 해결하기 위해 NextJS는 next/image
컴포넌트를 제공해 자동으로 최적화를 수행한다.
공식 문서와 소스 코드를 기반으로 next/image
의 이미지 최적화 원리를 알아보자.
무엇을 최적화할까?
NextJS 공식 문서에 따르면 next/image
컴포넌트는 다음과 같이 이미지를 최적화한다.
- 이미지 크기 최적화: 브라우저에 따라 적절한 크기의 이미지를 제공
- 최신 포맷으로 변환: WebP, AVIF 등 브라우저가 지원하는 최적의 포맷으로 변환
- Lazy Loading 기본 지원: 필요할 때만 이미지를 로드하여 성능 최적화 (뷰포트에 진입할 때 이미지 로드)
- Placeholder Blur 지원: 이미지가 로드되기 전에 블러 효과 제공
- CDN 최적화: Vercel 배포 시 자동으로 CDN을 활용한 최적화 수행
어떤 포맷으로 변환할까?
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 배열의 값을 모두 지원하지 않는다면 원본 포맷을 유지한다.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
formats: ['image/avif', 'image/webp'],
},
}
export default nextConfig
js
options는 nextConfig
에서 설정할 수 있으며 기본값은 ['image/webp']
이다.
최적화되지 않는 이미지
// 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}
를 권장한다.
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}
를 반드시 적용해야 한다.
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
의 최적화는 빌드 시점이 아닌, 브라우저에서 이미지를 요청할 때 이루어진다.
최적화 과정은 다음과 같다.
-
HTML에 이미지 태그 렌더링
-
브라우저가 서버에 이미지 요청 (이때 Accept 헤더에 지원하는 이미지 포맷 정보 전송)
- 예)
Accept: image/avif,image/webp,image/*,*/*;q=0.8
- 예)
-
서버가 이미지 최적화 수행
-
최적화된 이미지 응답
다음 예시를 통해 살펴보자.
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&w=1080&q=75 1x,
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.5a33257e.png&w=3840&q=75 2x
"
src="/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ftest.5a33257e.png&w=3840&q=75"
/>
html
img
태그와 달리 next/image
컴포넌트는 이미지 URL을 /_next/image?url=...
형태로 변환한다. 이 URL로 요청이 들어오면 Next.js의 이미지 최적화 미들웨어가 동작하여 다음과 같은 결과를 반환한다.
네트워크 탭을 살펴보면 기본 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
를 변경하거나 캐시 파일을 직접 삭제해야 함
- 만약 강제로 갱신하려면
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 이미지가 생성되기 때문에 런타임에 추가 계산이 필요 없고, 미리 생성된 메타데이터를 바탕으로 최적화를 수행하므로 width
와 height
속성을 별도로 전달할 필요가 없다.
<Image
src="/images/test.png"
width={500}
height={300}
placeholder="blur"
alt="테스트 이미지"
/>
jsx
만약 위처럼 문자열로 직접 경로를 지정하면 Layout Shift를 방지하기 위해 width
와 height
를 반드시 지정해야 한다.
또한 미리 blurDataURL
데이터를 가지고 있는 정적 import 방식과 달리 placeholder="blur"
옵션을 사용하면 첫 요청 시점에 blurDataURL
이 생성된다.
Sharp VS Squoosh
next/image
컴포넌트는 Sharp 또는 Squoosh 라이브러리를 사용해 이미지를 최적화한다.
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가 필수이며, 없을 경우 에러가 발생한다.
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
컴포넌트 사용법은 공식 문서에서 더 자세하게 살펴볼 수 있다.