diff --git a/.prettierrc.mjs b/.prettierrc.mjs index bf43c6c..36a1fbf 100644 --- a/.prettierrc.mjs +++ b/.prettierrc.mjs @@ -17,5 +17,11 @@ export default { parser: 'astro', }, }, + { + files: '*.mdx', + options: { + printWidth: 80, + }, + }, ], } diff --git a/public/images/nextjs-image-optimization/img-test.avif b/public/images/nextjs-image-optimization/img-test.avif new file mode 100644 index 0000000..900e116 Binary files /dev/null and b/public/images/nextjs-image-optimization/img-test.avif differ diff --git a/src/content/post/blog/nextjs-image-optimization.mdx b/src/content/post/blog/nextjs-image-optimization.mdx new file mode 100644 index 0000000..420d7a1 --- /dev/null +++ b/src/content/post/blog/nextjs-image-optimization.mdx @@ -0,0 +1,330 @@ +--- +title: NextJS는 어떻게 이미지를 최적화할까? +date: 2025-02-08 +updatedDate: 2025-02-08 +tags: [nextjs, next Image, nextjs 이미지 최적화] +image: '' +--- + +:::note +이 글은 NextJS **14.2.23** 버전을 기준으로 작성되었습니다. +::: + +웹에서 이미지는 **페이지 로딩 속도**와 **사용자 경험**에 큰 영향을 미친다. 최적화되지 않은 이미지는 페이지 로딩을 느리게 하고 사용자 이탈률을 높이며, SEO에도 부정적인 영향을 미칠 수 있다. + +이를 해결하기 위해 NextJS는 `next/image` 컴포넌트를 제공해 자동으로 최적화를 수행한다. +공식 문서와 소스 코드를 기반으로 `next/image`의 이미지 최적화 원리를 알아보자. + +## 무엇을 최적화할까? + +[NextJS 공식 문서](https://nextjs.org/docs/app/building-your-application/optimizing/images)에 따르면 `next/image` 컴포넌트는 다음과 같이 이미지를 최적화한다. + +- **이미지 크기 최적화**: 브라우저에 따라 적절한 크기의 이미지를 제공 +- **최신 포맷으로 변환**: WebP, AVIF 등 브라우저가 지원하는 최적의 포맷으로 변환 +- **Lazy Loading** 기본 지원: 필요할 때만 이미지를 로드하여 성능 최적화 (뷰포트에 진입할 때 이미지 로드) +- **Placeholder Blur** 지원: 이미지가 로드되기 전에 블러 효과 제공 +- **CDN 최적화**: Vercel 배포 시 자동으로 CDN을 활용한 최적화 수행 + +## 어떤 포맷으로 변환할까? + +```ts title="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']) +``` + +`next/image`의 이미지 최적화를 담당하는 `image-optimizer.ts` 코드를 살펴보면 브라우저의 **Accept** 헤더 값과 config의 **formats**로 변환할 포맷을 결정한다. + +브라우저의 **Accept** 헤더는 클라이언트가 처리할 수 있는 **MIME 타입**을 서버에 알리는 역할을 한다. 쉽게 설명하자면 Accept 헤더에 **브라우저가 지원하는 포맷 정보**가 담겨있다. + +`getSupportedMimeType` 함수를 실행해 **formats** 배열 값 중 클라이언트가 지원하는 최적의 포맷으로 `mimeType`을 결정하는데, 만약 formats 배열의 값을 **모두 지원하지 않는다면 원본 포맷을 유지**한다. + +```js title="next.config.mjs" +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + formats: ['image/avif', 'image/webp'], // [!code highlight] + }, +} + +export default nextConfig +``` + +options는 `nextConfig`에서 설정할 수 있으며 **기본값**은 `['image/webp']`이다. + +## 최적화되지 않는 이미지 + +```ts title="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 .` + ) + return { + buffer: upstreamBuffer, + contentType: upstreamType, + maxAge, + } +} +``` + +GIF 등 **Animatable 이미지**는 최적화가 이루어지지 않는다. 이미지 최적화 과정을 건너뛰기 위해 `unoptimized={true}`를 권장한다. + +```ts title="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, + } +} +``` + +**벡터 이미지**인 SVG도 마찬가지인데, `next/image`에서 SVG를 사용하려면 `unoptimized={true}`를 **반드시 적용**해야 한다. + +{/* prettier-ignore-start */} +```js title="next.config.mjs" +const nextConfig = { + images: { + formats: ['image/avif', 'image/webp'], + dangerouslyAllowSVG: true, // [!code highlight] + contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", // [!code highlight] + }, +} +``` +{/* prettier-ignore-end */} + +프로젝트 전체에 적용하고 싶다면 위처럼 `dangerouslyAllowSVG` 설정이 필요하다. +SVG는 JavaScript를 포함할 수 있어 **XSS 공격 등 보안상의 위험** 때문에 'dangerous'라는 이름이 붙었으며, `contentSecurityPolicy` 설정을 통해 보안 정책을 설정할 수 있다. + +## 최적화는 언제 이루어질까? + +`next/image`의 최적화는 빌드 시점이 아닌, 브라우저에서 **이미지를 요청**할 때 이루어진다. +최적화 과정은 다음과 같다. + +1. HTML에 이미지 태그 렌더링 +1. 브라우저가 서버에 이미지 요청 (이때 Accept 헤더에 지원하는 이미지 포맷 정보 전송) + + - 예) `Accept: image/avif,image/webp,image/*,*/*;q=0.8` + +1. 서버가 이미지 최적화 수행 +1. 최적화된 이미지 응답 + +다음 예시를 통해 살펴보자. + +```tsx +import Image from 'next/image' +import testImage from '../public/test.png' + +export default function Page() { + return ( + <> + 테스트 이미지 + 테스트 이미지 + + ) +} +``` + +위 코드는 다음과 같이 렌더링 된다. + +```html highlight={2,16} + +테스트 이미지 + +테스트 이미지 +``` + +`img` 태그와 달리 `next/image` 컴포넌트는 이미지 URL을 `/_next/image?url=...` 형태로 변환한다. 이 URL로 **요청이 들어오면** Next.js의 이미지 최적화 미들웨어가 동작하여 다음과 같은 결과를 반환한다. + +![slow 4g 환경에서 테스트한 결과](/images/nextjs-image-optimization/img-test.avif) + +네트워크 탭을 살펴보면 기본 `img` 태그의 요청은 원본 PNG 이미지(794 KB)를 그대로 전송하고 `next/image` 컴포넌트의 요청은 WebP 형식(45.7 KB)으로 **최적화되어 전송**되는 것을 확인할 수 있다. +`next/image`의 두 번째 요청부터는 **캐시에서 즉시 응답**한다. + +최적화 과정을 다시 한번 요약해 보자면 다음과 같다. + +1. 클라이언트가 `/_next/image?url=...` 경로로 요청을 보냄 +1. 서버가 Sharp(또는 Squoosh)를 사용해 리사이징 및 포맷 변환 수행 +1. 변환된 이미지를 `/.next/cache/images/`에 저장 (같은 요청이 오면 캐싱된 파일 반환) +1. 최적화된 이미지가 브라우저로 전달됨 + +이처럼 **요청 시점(런타임)에 최적화**하기 때문에 디바이스나 브라우저 등 **클라이언트의 상황에 맞춰 최적화**가 가능하다. +최적화된 이미지는 **캐시에 저장**되어 같은 요청이 왔을 때 최적화 과정 없이 바로 응답한다. + +### 캐시 정책 + +`next/image`의 이미지 캐시는 다음과 같은 정책으로 운영된다. + +- **캐시 저장**: 최적화된 이미지는 `/cache/images` 디렉토리에 저장됨 +- **캐시 유효 기간** + - `minimumCacheTTL` 설정값과 원본 이미지의 `Cache-Control` 헤더 중 **더 큰 값**을 사용 + - 기본적으로 **60초** 동안 캐시 되며, `next.config.js`에서 `minimumCacheTTL` 설정으로 조절 가능 + - 별도의 캐시 무효화 메커니즘이 없기 때문에 `minimumCacheTTL`을 너무 길게 설정하지 않는 것을 권장 + - 만약 강제로 갱신하려면 `src`를 변경하거나 캐시 파일을 직접 삭제해야 함 + +```js title="next.config.mjs" +const nextConfig = { + images: { + minimumCacheTTL: 60, // 캐시 유효 기간을 60초로 설정 // [!code highlight] + }, +} +``` + +캐시의 유효 기간이 만료된 이미지를 요청할 경우 **만료된(stale) 이미지를 즉시 제공**하고 백그라운드에서 새로운 이미지를 가져와 **캐시를 업데이트**한다. +캐시의 상태에 따라 `x-next-cache` 응답 헤더의 값이 `MISS`, `STALE`, `HIT`로 결정된다. + +- `MISS`: 캐시에 이미지가 없는 경우 (최초 요청 시) +- `STALE`: 캐시는 있지만 유효 기간이 만료된 경우 +- `HIT`: 유효한 캐시가 있는 경우 + +## 빌드 타임 최적화 + +런타임에 이미지 최적화가 진행되지만, 빌드 시점(또는 개발 환경의 컴파일 시점)에 미리 준비할 수도 있다. + +```jsx +import testImage from '../public/test.png' + +export default function Page() { + return ( + 테스트 이미지 + ) +} +``` + +이렇게 정적으로 이미지를 불러오면 NextJS는 다음과 같은 메타데이터 객체를 생성한다. + +```js +{ + 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 +} +``` + +빌드 시점에 이미지 메타데이터와 blur 이미지가 생성되기 때문에 **런타임에 추가 계산이 필요 없고**, 미리 생성된 메타데이터를 바탕으로 최적화를 수행하므로 `width`와 `height` 속성을 별도로 전달할 필요가 없다. + +```jsx +테스트 이미지 +``` + +만약 위처럼 문자열로 직접 경로를 지정하면 Layout Shift를 방지하기 위해 `width`와 `height`를 **반드시 지정**해야 한다. +또한 미리 `blurDataURL` 데이터를 가지고 있는 정적 import 방식과 달리 `placeholder="blur"` 옵션을 사용하면 **첫 요청 시점**에 `blurDataURL`이 생성된다. + +## Sharp VS Squoosh + +`next/image` 컴포넌트는 Sharp 또는 Squoosh 라이브러리를 사용해 이미지를 최적화한다. + +```tsx title="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') + // ... +} +``` + +Sharp가 설치되어 있지 않다면 Squoosh를 사용해 최적화를 진행한다. 단, 개발 모드에서는 Sharp 없이도 경고가 발생하지 않지만 production 모드에서는 **Sharp 설치를 권장**한다. +특히 output: "standalone" 설정이 되어 있다면 Sharp가 필수이며, 없을 경우 에러가 발생한다. + +:::warning{title="Next15부터 달라진 점"} +[NextJS 15부터는 **기본적으로 sharp를 사용**](https://github.com/vercel/next.js/blob/canary/packages/next/src/server/image-optimizer.ts)하기 때문에 sharp를 수동 설치할 필요 없다. +소스 코드를 살펴보면 이전과 달리 조건문 없이 Sharp를 사용해 최적화를 진행하는 것을 확인할 수 있다. +::: + +### Sharp를 권장하는 이유 + +[Sharp](https://github.com/lovell/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` 컴포넌트 사용법은 [공식 문서](https://nextjs.org/docs/app/api-reference/components/image#configuration-options)에서 더 자세하게 살펴볼 수 있다.