abonglog logoabonglog

NextJS 는 어떻게 이미지 최적화를 구현하는가 ?  의 썸네일

NextJS 는 어떻게 이미지 최적화를 구현하는가 ?

웹 브라우저 지식

프로필 이미지
yonghyeun

이전 개발블로그를 개발 할 때 모든 이미지들에 대해서 Image 컴포넌트를 이용했더니 월별 이용량을 모두 넘어 더 이상 이미지 최적화가 일어나지 않는다는 경고문을 본 적이 있다.

그래서 이번 블로그를 만들 때는 필요한 부분에 최소한만큼만 월별 이용량을 사용하기 위해 Image 컴포넌트를 설정하고, img 태그만으로 이미지를 최적화 할 수 있도록 CustomImage 컴포넌트를 생성해봤다.

그런 과정들을 이번 게시글에 적어보려 한다.

이미지 최적화가 필요한 이유

글자들로만 이뤄진 html 문서에 비해 이미지는 용량이 크기에 얼마나 이미지를 적절히 최적화 했는지에 따라 완성된 웹 문서를 보여주는데 걸리는 시간이 좌우된다.

웹 문서에서 FCP와 LCP웹 문서에서 FCP와 LCP

이미지는 최대 컨텐츠 렌더링 시간 (Largest Contentful Paint , (LCP)) 와 깊은 연관이 있는데 위 예시에서 이미지가 느리게 렌더링 될 수록 사용자는 빈 부분을 보는 시간이 늘어날 것이며 이는 사용자 경험 속과 SEO 측면에서 좋지 않은 영향을 미칠 것이다.

LCP 와 관련된 문서는 Google lighthouse -LCP 에서 읽어볼 수 있다.

이미지 최적화를 위해 필요한 기술

이미지 최적화는 결국 얼마나 빠르게 이미지를 불러오느냐 와 관련있다.

이미지를 빠르게 불러오기 위해선 다음과 같은 기술들을 사용 할 수 있다.

  1. 적절한 크기의 이미지 선택
  2. 적절한 시기에 필요한 이미지 로딩
  3. 자주 사용하는 이미지 캐싱

이런 기술들을 사용하기 위해 기본적인 img 태그에서 다양한 속성들을 제공하며 Nextimg 태그의 속성을 활용하여 이미지 최적화가 되어있는 Image 컴포넌트를 제공한다.

NextJS 는 어떻게 이미지 최적화를 제공하는가

1. 적절한 크기의 이미지 변환

Image 컴포넌트 사용 예시
import Image from 'next/image'
 
export default function Page() {
  return (
    <Image
      src="/profile.png"
      width={500}
      height={500}
      alt="Picture of the author"
    />
  )
}

다음과 같이 이미지 태그를 사용하였다고 생각해보자

NextJS 에선 해당 태그를 next.config.js 에 작성된 deviceSizes , imageSizes 속성을 조합하여 기존 src 주소가 아닌 다른 엔드포인트를 가리키는 srcset 으로 생성한다.

혹은 직접 Image 컴포넌트에서 sizes props 를 통해 제어 할 수 있다.

srcset 에서 엔드포인트는 _next/image?url={src로 제공한 경로}&w={}&q={} 형태로 변경된다.

만약 next.config.js 에서 두 값들을 정해주지 않으면 다음과 같은 기본값들을 이용한다.

deviceSizes 기본값
module.exports = {
  images: {
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
  },
}
IamgeSizes 기본값
module.exports = {
  images: {
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
}

deviceSizesImage 컴포넌트가 감지 할 디바이스 사이즈를 결정하고 imageSizes 는 실제 생성할 이미지의 크기를 의미한다.

NextJS 에선 deviceSizes 별로 이미지의 사이즈를 imageSizes 별로 만들어놔 하나의 디바이스 크기에서 여러 사이즈의 이미지를 제공하여 반응형 이이미지로서 이미지를 제공한다.

이후 런타임 시점에서 사용자의 뷰포트의 크기를 감지하여 제공해야 할 이미지의 크기를 결정하고 해당 이미지를 가리키는 srcset 을 통해 이미지를 전달 받는다.

이 때 _next/image/url={어떤 경로}?w={1080}&q={100} 이란 경로에 대한 이미지 요청을 최초로 받았을 때 NextJS 는 다음과 같은 과정을 거친다.

cache/images에 이미지가 캐싱된 모습cache/images에 이미지가 캐싱된 모습

  1. 어떤 경로 에 해당하는 w 크기만큼의 이미지를 생성한다. 이 때 이미지의 크기가 w 보다 크다면 크기를 줄이며, 압축률이 높은 webp 형태의 확장자로 변경한다. (변경하고자 하는 확장자 또한 next.config.js.image.format 을 통해 제어 할 수 있다.)
  2. 변경한 파일을 <distDir>/cache/images 폴더에 저장해둬 캐싱해둔다.
  3. 저장한 이미지 파일을 요청한 곳으로 전송한다.
  4. 동일한 url 에 대한 요청이 온 경우 캐싱해둔 이미지를 제공하고 헤더에 x-nextjs-cache 캐시 적중 결과를 MISS , HIT ,STALE 등으로 표현한다.

Image 컴포넌트의 사용 가능한 월별 사용량이 이 캐싱되는 이미지의양을 의미한다.

2. 적절한 시기에 필요한 이미지 로딩

이미지가 언제 로딩 될지는 img 태그의 loading 으로 제어 할 수 있다.

loadingeager 속성은 페이지 로드 시 이미지를 요청하고 lazy 속성은 페이지 로드 후, 뷰포트에 해당 이미지 태그가 보일 때 이미지를 요청하도록 한다.

NextImage 컴포넌트는 기본적으로 모든 로딩이 lazy 로 되어있어 뷰포트에 나타나야 할 이미지만 선택으로 요청함으로서 불필요한 요청을 줄여 필요한 이미지만 적절히 나타나도록 한다.

img 태그엔 존재하지 않는 priority props 도 존재한다.

해당 priority = true 로 설정된 Image 태그는 ReactDOM.preload 를 통해 html 문서 head 태그 내부에서 preload , fetchPrioirty = high 로 설정되어 모든 DOM이 그려지기 전 이미지를 먼저 로드하도록 한다.

깃허브에서 Image 컴포넌트의 코드를 보면 priority 값에 따라 ImagePreload 컴포넌트를 마운트 시키는 것을 볼 수 있다.

Image 컴포넌트의 코드 일부
export const Image = forwardRef<HTMLImageElement | null, ImageProps>(
  (props, forwardedRef) => {
    ...
     const { props: imgAttributes, meta: imgMeta } = getImgProps(props, {
      defaultLoader,
      imgConf: config,
      blurComplete,
      showAltText,
    })
 
    return (
      <>
        {
          <ImageElement
            ...
          />
        }
        {imgMeta.priority ? (
          <ImagePreload
            isAppRouter={isAppRouter}
            imgAttributes={imgAttributes}
          />
        ) : null}
      </>
    )
  }
)
ImagePreload 컴포넌트
function ImagePreload({
  isAppRouter,
  imgAttributes,
}: {
  isAppRouter: boolean
  imgAttributes: ImgProps
}) {
  const opts = {
    as: 'image',
    imageSrcSet: imgAttributes.srcSet,
    imageSizes: imgAttributes.sizes,
    crossOrigin: imgAttributes.crossOrigin,
    referrerPolicy: imgAttributes.referrerPolicy,
    ...getDynamicProps(imgAttributes.fetchPriority),
  }
 
  if (isAppRouter && ReactDOM.preload) {
    // See https://github.com/facebook/react/pull/26940
    ReactDOM.preload(
      imgAttributes.src,
      // @ts-expect-error TODO: upgrade to `@types/react-dom@18.3.x`
      opts
    )
    return null
  }
 
  return (
    <Head>
      <link
        key={
          '__nimg-' +
          imgAttributes.src +
          imgAttributes.srcSet +
          imgAttributes.sizes
        }
        rel="preload"
        // Note how we omit the `href` attribute, as it would only be relevant
        // for browsers that do not support `imagesrcset`, and in those cases
        // it would cause the incorrect image to be preloaded.
        //
        // https://html.spec.whatwg.org/multipage/semantics.html#attr-link-imagesrcset
        href={imgAttributes.srcSet ? undefined : imgAttributes.src}
        {...opts}
      />
    </Head>
  )
}

이렇게 priority = true 로 설정된 이미지는 html 을 파싱하는 동안 요청된 후 브라우저 캐시에 저장되어, Image 태그가 마운트 될 때 브라우저 캐시에 저장 된 이미지를 불러와 빠르게 이미지를 그릴 수 있다.

3. 사용했던 이미지 캐싱

1번에서도 말했듯 한 번 생성했던 이미지는 웹 서버 내 폴더에 캐싱되어 동일한 요청이 웹 서버에 도달 했을 때 생성해뒀던 이미지라면 미리 만들어둔 이미지를 전송한다.

그 뿐 아니라 Vercel 을 통해 배포하게 되면 Vercel CDN 을 통해 이미지를 캐싱해두기 때문에 더욱 빠르게 이미지를 제공 받을 수 있다.

나는 어떻게 Image 컴포넌트를 이용했는가

나는 Image 태그의 월별 이용량을 넘지 않게 하기 위해 LCP 에 영향을 미치는 요소들에만 Image 태그를 이용했다.

웬만한 기능들은 Image 태그 없이도 구현 할 수 있지만 한 번 요청했던 이미지를 캐싱해두는 기능을 구현하려면 Redis 같은걸 사용해야해서 배보다 배꼽이 커지는 거 같더라

게시글에 사용되는 이미지 예시게시글에 사용되는 이미지 예시

현재 내 블로그에서 사용되는 이미지들은 모두 600px, 800px,1920px 의 크기를 갖는다.

next.config.js
const nextConfig: NextConfig = {
  images: {
    // 2025/03/23
    // 현재 나는 Image 태그를 썸네일 이미지에만 사용하고 있기에
    // 불필요하게 크거나 작은 크기의 이미지를 .next/cache/image 에 저장하지 않도록 하기 위해
    // 필요한 이미지의 타입 (모바일 , 태블릿 , 피시) 만 저장하도록 deviceSizes 를 설정했다.
    // 또한 600, 800, 1920 외의 다른 이미지들을 저장하지 않도록 imageSizes 를 빈 배열로 설정했다.
    deviceSizes: [600, 800, 1920],
    imageSizes: [],
...

따라서 생성 할 이미지들의 크기를 정적으로 정의해주었다.

이후 LCP 에 영향을 미치는 이미지에는 priority 속성을 넣어줌으로서 이미지를 preload 하도록 수정하였다.

imageSizes 에서 빈 배열을 넣어줬던 이유는 월별 사용량을 사용해야 할 만큼 블로그에서 반응형 이미지가 중요하지 않을 것 같았기 때문이다.

Image 컴포넌트를 사용하지 않는 곳에선 어떻게 했는가?

Image 컴포넌트의 월별 사용량 요금이 올해 2월달들어 매우 싸졌다는 글을 보았다. 그래서 찾아봤더니 오마이갓

NextJS 의 이미지 컴포넌트 요금 정책NextJS 의 이미지 컴포넌트 요금 정책

사실 이 챕터는 이제 이 블로그에선 사용되지 않는 부분이다. 요금제가 변경된걸 보고 나서 무료 티어에서도 걱정 없이 이미지 캐싱 기능을 사용 할 수 있을거라 생각했기 때문이다.

그냥 경험삼아 했던 내역을 적은거 정도로만 알아주면 좋겠다.

CustomImage 컴포넌트

CustomImage 컴포넌트
export const CustomImage: React.FC<PhotoProps> = ({
  src,
  alt,
  sizes,
  srcSet,
  priority = false,
  ...props
}) => {
  const type = src.split(".").pop();
 
  if (!type) {
    throw new Error(
      "적합한 이미지 경로가 아닙니다. 이미지 경로는 반드시 파일 확장자를 포함해야 합니다."
    );
  }
 
  return (
    <>
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img
        src={src}
        alt={alt}
        sizes={sizes}
        srcSet={srcSet}
        loading="lazy"
        decoding="async"
        {...props}
      />
      {priority && CustomImagePreload(srcSet, sizes, type)}
    </>
  );
};

이렇게 기본적인 img 컴포넌트에서 loading = lazy, decoding = async 로 된 커스텀 이미지 컴포넌트를 하나 만들어주었다. (CustomImagePreload 컴포넌트는 Next 에서 사용되는 프리로드 컴포넌트와 같다.)

이후 해당 컴포넌트를 사용하는 사용처에서 다음과 같이 srcset , sizes 를 정의해준다.

CustomImage 컴포넌트 사용 예시
  return (
    <CustomImage
      src={src}
      alt={alt}
      sizes="(max-width: 500px) 100vw, (max-width: 800px) 800px, 1000px"
      srcSet={`${src}?width=500 500w, ${src}?width=800 800w, ${src}?width=1000 1000w`}
      className="mx-auto rounded-lg shadow-md"
      {...props}
    />
  );
};

이후 srcSet 에 대한 요청을 받을 라우트 핸들러를 정의한다.

정의하기 전 이미지를 효과적으로 리사이징 하기 위해 sharp 라이브러리를 이용하여 리사이징 하는 메소드를 정의한다.

resizeAndConvertToWebp
import sharp from "sharp";
 
type ResizeAndConvertToWebp = (
  file: Blob,
  targetWidth: number,
  quality?: number
) => Promise<Buffer<ArrayBufferLike>>;
 
export const resizeAndConvertToWebp: ResizeAndConvertToWebp = async (
  file,
  targetWidth,
  quality = 80
) => {
  const arrayBuffer = await file.arrayBuffer();
 
  return sharp(arrayBuffer)
    .resize(targetWidth, null, {
      fit: "inside",
      withoutEnlargement: true
    })
    .webp({ quality })
    .toBuffer();
};

이후 해당 메소드를 통해 원본 이미지를 리사이징 한 후 전송하는 라우트핸들러를 정의해주었다.

이미지 요청을 받는 라우트 핸들러
import { resizeAndConvertToWebp } from "@backend/image/lib";
import { downloadImage } from "@backend/image/model";
import { createErrorResponse } from "@backend/shared/lib";
import { NextRequest, NextResponse } from "next/server";
 
const STORAGE_NAME = "article_image";
const BASE_IMAGE_WIDTH = 1000;
const getStoragePath = (articleId: string, imageId: string) => {
  return `images/${articleId}/${imageId}`;
};
 
/**
 * images/[articleId]/[imageId] 라우트의 GET요청은
 * 이미지를 리사이징하여 반환합니다.
 *
 * 이 때 주의해야 할 점은 resizing을 하지 않을 이미지 타입 (image/gif) 의 경우엔
 * 해당 라우트를 통해 이미지를 서빙하지 않도록 해야 합니다.
 */
export const GET = async (
  req: NextRequest,
  { params }: { params: Promise<{ articleId: string; imageId: string }> }
) => {
  const { articleId, imageId } = await params;
 
  const url = new URL(req.url);
  const width = new URLSearchParams(url.search).get("width") || "1000";
 
  const storagePath = getStoragePath(articleId, imageId);
 
  const { data: imageData, error } = await downloadImage(
    STORAGE_NAME,
    storagePath
  );
 
  if (error) {
    return createErrorResponse(error);
  }
 
  const contentType = imageData.type;
  if (contentType === "image/gif") {
    return NextResponse.json(
      {
        code: 400,
        message: "GIF 이미지는 리사이징할 수 없습니다."
      },
      {
        status: 400
      }
    );
  }
 
  const resizedImage = await resizeAndConvertToWebp(
    imageData,
    parseInt(width, 10) || BASE_IMAGE_WIDTH,
    100
  );
 
  const cacheKey = `${articleId}-${imageId}-${width}`;
  const cacheHeaders = {
    "Cache-Control": "public, max-age=31536000, immutable",
    "CDN-Cache-Control": "public, max-age=31536000, immutable",
    "Vercel-CDN-Cache-Control": "public, max-age=31536000, immutable",
    "Content-Type": "image/webp",
    ETag: cacheKey,
    "Last-Modified": new Date().toUTCString(),
    Vary: "Accept-Encoding"
  };
 
  return new NextResponse(resizedImage, {
    status: 200,
    headers: cacheHeaders
  });
};

이 때 매번 이미지를 리사이징 하지 않게 하기 위해 캐시를 빵빵하게 설정하여 응답으로 보낸다.

회고

오마이갓 기능을 모두 개발하고 포스팅하며 보니 Image 의 정책이 예전에 내가 쓰던 때 보다 훨씬 좋아졌다 ..

이걸 알았다면 삼일간 고민하지 않아도 됐을텐데 라는 생각이 든다.

뭐 그래도, Next를 쓰지 않는 곳에서 사용 할 수도 있으니 불필요했던 경험은 아니라 생각하며 .. 크갸갹