abonglog

          • 소프트웨어 개발방법론

            • 로우파이 와이어프레임과 하이파이 와이어프레임
          • 자료구조 및 알고리즘

            • 다익스트라 알고리즘
            • 플로이드-워셜 알고리즘
            • 외판원 순회 문제(TSP) 를 완전 탐색 , DP로 풀어보자
            • 순열,조합과 그래프의 관계에 대해 알아보자
            • 백준 10986 - 나머지합 (모듈러 연산 , 누적합, 중복조합)
          • 함수형 자바스크립트

            • 모나드와 함께하는 함수형 프로그래밍 - Maybe 모나드
            • 복잡한 상태관리, 함수형으로 생각하며 리팩토링하기
            • 이터레이터와 이터러블, 제네레이터, 비동기 이터러블
            • 멀티패러다임 프로그래밍 서적 리뷰
            • 제네레이터를 이용해 자바스크립트의 큐 자료구조 10줄로 구현하기
            • 함수형 자바스크립트 모나드 알아보기
            • 함수형 자바스크립트의 펑터와 적용형 펑터
            • 커링 (currying) 에 대해 알아보자
            • 함수형 프로그래밍의 정의와 기초지식 및 가볍게 살펴보는 활용 예제
            • 함수형 자바스크립트 프로그래밍 학습 커리큘럼
          • 컴퓨터 공학 지식

            • 고급 프롬프트 엔지니어링을 위한 개념 정리
            • 개방형 와이파이에서도 폼 데이터는 안전할까 ?
          • 독서 노트

            • 솔로프리너의 시대 서평 리뷰
          • 웹 브라우저 지식

            • 경험에 의거한 FSD (Feature Sliced Design) 구조 완전 공략
            • zustand는 어떻게 마법같이 동작할까?
            • 이번에 합성 컴포넌트를 이용하여 디자인 시스템을 만들어봤던 경험
            • 함수형 컴포넌트의 useEffect에 대한 사견, 부수효과 관점에서 다시 보기
            • 브라우저의 캐시 사용법 및 NextJS 에서 캐시를 사용하는 방법
            • NextJS 는 어떻게 이미지 최적화를 구현하는가 ?
          • introduction to algorithms

            • 이진 검색 트리 (이진 탐색 트리)
          • mostly-adequate-guide

            • Chapter 13: Monoids bring it all together [번역]
            • Chapter 12: Traversing the Stone [번역]
            • Chapter 11: Transform Again, Naturally [번역]
            • Chapter 10: Applicative Functors [번역]
            • Chapter 09: Monadic Onions [번역]
            • Chapter 08: Tupperware [번역]
            • Chapter 07: Hindley-Milner and Me [번역]
            • Chapter 06: Example Application [번역]
            • Chapter 05: Coding by Composing [번역]
            • Chapter 04: Currying [번역]
            • Chapter 03: Pure Happiness with Pure Functions [번역]
            • Chapter 02: First Class Functions [번역]
            • Chapter 01: What Ever Are We Doing? [번역]
          • Zero to One

            • 2번의 프로젝트 관리 실패로 배운 1인 개발의 씁쓸한 회고록
            • Zero to one 시리즈를 시작하며
          abonglog logoabonglog
          NextJS 는 어떻게 이미지 최적화를 구현하는가 ?  의 썸네일

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

          웹 브라우저 지식
          프로필 이미지
          yonghyeun3/23/2025, 12:34:26 PM

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

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

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

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

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

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

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

          note

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

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

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

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

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

          이런 기술들을 사용하기 위해 기본적인 img 태그에서 다양한 속성들을 제공하며 Next 는 img 태그의 속성을 활용하여 이미지 최적화가 되어있는 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 으로 생성한다.

          note

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

          note

          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],
            },
          }

          deviceSizes 는 Image 컴포넌트가 감지 할 디바이스 사이즈를 결정하고 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 으로 제어 할 수 있다.

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

          Next 의 Image 컴포넌트는 기본적으로 모든 로딩이 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를 쓰지 않는 곳에서 사용 할 수도 있으니 불필요했던 경험은 아니라 생각하며 .. 크갸갹

          • 이미지 최적화가 필요한 이유
          • 이미지 최적화를 위해 필요한 기술
          • NextJS 는 어떻게 이미지 최적화를 제공하는가
            • 1. 적절한 크기의 이미지 변환
            • 2. 적절한 시기에 필요한 이미지 로딩
            • 3. 사용했던 이미지 캐싱
          • 나는 어떻게 Image 컴포넌트를 이용했는가
            • Image 컴포넌트를 사용하지 않는 곳에선 어떻게 했는가?
              • CustomImage 컴포넌트
          • 회고

          abonglog

          공부한 내용을 기록하고 함께 성장하고 싶어 만든 두 번째 블로그입니다.
          주로 웹개발과 관련된 내용을 포스팅합니다.

          Githubttddcc119@naver.com

          © 2026 abonglog All rights reserved.

          다음 포스트브라우저의 캐시 사용법 및 NextJS 에서 캐시를 사용하는 방법