Next에서 이미지 최적화는 마법이 아니다.

데브현·2025년 2월 15일
22

프론트엔드 모음집

목록 보기
12/13
post-thumbnail

최근에 링크드인에 Next 이미지 최적화에 대한 글이 올라온 것을 보고 문득 나도 궁금해져서 실제 코드를 살펴보면서 정리해보고 싶어 글을 쓰게 되었다.

같이 보면 좋은 글

아래 글은 Next.js v15 main 브랜치를 기준으로 작성하였습니다.

🧵 Next.js Optimization의 기능

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} />

1️⃣ 네트워크 환경에 따라 이미지 크기와 로드되는 시간이 다르다.

  • 제한 없음

  • 느린 4G

  • 3G

네트환경이 좋지 않은 환경일 수록 차이가 더 심해지는 모습을 보인다.

2️⃣ 응답 헤더의 이미지 포맷이 image/jpeg,image/webp로 서로 다른 형태를 응답받게 된다.

3️⃣ 렌더링 되는 src의 차이가 있다

  • 컴포넌트에 쓰인 src 경로 그대로 /test.jpg 렌더링된다.
  • /_next/image경로 하위로 searchParams로 어떠한 값들을 관리합니다. (/_next/image?url=%2Ftest.jpg&w=1920&q=75)
    • w는 width를 의미, q는 quality를 의미

4️⃣ Cache가 동작하는 방식

Next/Image를 사용하게 되면 첫 요청 이후에는 .next/cache 폴더 하위에 이미지를 캐시하고 이를 계속 재사용하게 된다.

위에 사진에서 눈치를 챘는지 모르겠지만, Next/Image를 사용하면 X-Nextjs-Cache라는 필드가 있다. 여기에 Cache 상태를 확인하여 이미지를 내려주게 된다.

실제로 어떻게 동작하는지 네트워크 요청을 살펴보면,

- .next/cache에 없으면 X-Nextjs-Cache 가 MISS 로 내려온다.

  • 이후 요청에서는 .next/cache에 이미지가 존재하므로 X-Nextjs-CacheHIT 가 내려오게 된다.

당연하게도 캐시가 된 이후에는 요청시간도 줄어든다.

여기서 신기한 점은 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 TypeContent 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에 들어가는 데이터타입을 명시하는 용도로 사용된다.

둘의 차이점?

  • Content-Type 보다 MIME type이 더 상위의 개념이다.

쉽게 표현하면 요청 Body에 들어가는 데이터 타입을 MIME type으로 표기하면 그게 Content-Type인 것이다.


다시 위의 코드로 돌아와서 브라우저 별로 지원하는 이미지 포맷이 다르기 때문에 이를 확인하여 자동으로 지원하는 포맷으로 변환해주는 것이다.

브라우저 별로 지원하는 이미지 포멧이 다른 것을 볼 수 있다.

Can I Use: Webp, AVIF

지원하는 포멧으로 변경하는 실제 코드

내부 코드는 다음과 같이 되어 있다.

// 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#placeholder

placeholder를 테스트할 수 있는 데모 사이트

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를 사용하게 된다.

관련 PR: https://github.com/vercel/next.js/pull/63321

🧵 마지막으로 src 경로의 searchParams에 대해서

맨 처음 기존 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들이 있는데 이는 공식문서를 통해 살펴보고 마친다.

profile
I am a front-end developer with 4 years of experience who believes that there is nothing I cannot do.

0개의 댓글

관련 채용 정보