Next.js 이미지 최적화(Image Optimization) + sharp

Doeunnkimm·2023년 7월 12일
15

Next

목록 보기
2/3
post-thumbnail

이미지 최적화란

이미지 최적화는 웹 사이트의 성능을 개선하기 위해 이미지의 크기와 품질을 최적화하는 프로세스입니다. 이미지의 품질 저하 없이 개선하여 사용자에게 이미지를 빠르고 효율적으로 제공할 수 있도록 합니다.

이를 통해 웹 사이트 로딩 시간을 크게 단축하여 렌더링 속도를 개선할 수 있고 이는 더 나은 사용자 경험과 연결됩니다.

뿐만 아니라, 이미지 최적화는 검색 엔진 크롤러에게 웹 사이트가 더 빠르게 표시될 수 있으므로 SEO 순위 향상에도 도움이 될 수 있습니다.

궁긍적으로, 이미지 최적화는 웹 사이트 로딩 시간을 줄이고 전반적인 사용자 경험을 개선하는 데 도움이 될 수 있다.

이미지 최적화를 해야하는 이유

Web Almanac에 따르면 이미지는 일반적인 웹사이트 페이지 무게의 상단 부분을 차지합니다. 또한 이미지는 웹사이트의 LCPCLS 성능과 직결되어 있습니다.

결론적으로, 웹 성능을 위해 이미지 최적화를 해야한다.

이미지가 웹 성능에 미치는 영향

이는 Web vitals를 통해 살펴보면 좀 더 이해가 쉬운데요!

Web vitals

Web vitals은 웹 페이지 로딩 속도, 모바일 친화성, 세이프 브라우징, 암호화(HTTPS 적용 여부), 방해요소 여부 등과 같은 웹 콘텐츠 사용자의 경험에 미치는 다양한 측정 가능한 값들을 말합니다.

구글은 이를 통해 웹 페이지의 품질을 평가한다.

즉, 해당 지표를 통해 이미지 최적화가 웹 성능에 어떠한 영향을 미칠 수 있는지를 알 수 있습니다.

이 중에서도 로딩 속도레이아웃 관련된 지표 2가지에 대해서만 알아보려고 합니다. (이미지 관련된 이야기만 해봅시다)

1. LCP(Largest Contentful Paint, 최대 콘텐츠풀 페인트)

LCP는 사용자가 화면에 렌더링된 콘텐츠를 보는데 걸리는 시간입니다.

LCP는 웹 페이지의 로딩 속도에 대한 지표

LCP는 다음과 같이 사용자 경험과 관련된 로드 시간만 계산합니다.

  • 이미지
  • 비미오 썸네일
  • CSS를 사용한 배경 이미지
  • 단락, 제목 및 목록과 같은 텍스트 요소

2. CLS(Cumulative Layout Shife, 누적 레이아웃 시프트)

CLS는 방문자에게 콘텐츠가 얼마나 불안정한지 측정하는 사용자 경험 측면 항목입니다. 뉴스 기사를 보려고 들어간 웹 사이트에서 기사 링크를 클릭한 순간 레이아웃이 이동해서 광고가 나타나 기사가 아닌 광고를 클릭한 경험이 한번씩 있을텐데...

페이지에 들어갔을 때 갑작스럽게 발생하는 레이아웃 이동의 정도를 합산한 이동 거리 개념을 도입
→ 얼마나 안정적인 레이아웃이냐를 따진다.

다음은 CLS에 악영향을 미치는 영향입니다.

  • 치수가 없는 이미지
    이미지가 로드되는 동안 브라우저가 문서의 공간을 올바르게 할당하여 영역을 미리 확보해 두는 것이 중요하다.

이미지 최적화 전략

위에서 웹 성능에 미치는 영향이 다양한 것처럼, 이를 토대로 이미지 최적화 전략에 대해 정리해 보면 다음과 같습니다.

1. webp 사용하기 → avif 사용하기
webp란 구글에서 개발한 이미지 포맷으로 pngjpeg 형식의 이미지보다 이미지 파일 용량이 훨씬 작습니다.
webp가 나온 이후에 avif라는 보다 압축률이 좋은 포맷 방법도 개발되었는데요.

위 사진을 보게 되면 큰 품질 저하 없이 훨씬 적은 용량으로 이미지를 사용할 수 있어졌습니다. webp도 많은 용량을 절약할 수 있지만 avif는 보다 적은 용량으로 이미지를 사용할 수 있는 것을 확인할 수 있었습니다.

2. 이미지 lazy loading
한 페이지에 많은 양의 이미지가 있다고 할 때, 사용자가 모든 콘텐츠를 보지 않는다면? 해당 화면에 있는 모든 이미지를 로드하는 것은 분명 비효율적일 것입니다.
따라서 화면에 나타나기 전에는 placeholder 이미지를 넣어두었다가, 화면에 나타났을 때(viewport에 걸렸을 때) 리소스를 요청하도록 하는 방법이 있습니다.
이렇게 하면 필요한 순간에 네트워크에 요청하도록 만들어 성능을 최적화할 수 있습니다.

3. 이미지 리사이징
모든 디바이스 화면에서 같은 화질, 같은 사이즈로 이미지를 보여줄 필요가 없습니다. 디바이스의 크기에 알맞게 적절한 이미지를 보여주면 성능을 최적화할 수 있습니다.

4. 이미지 캐싱
이미지를 캐싱해두면 네트워크 요청 시 속도를 매우 향상시킬 수 있습니다. 하지만 캐싱하기 위해서는 메모리가 필요한 법이니 고민있는 선택이 필요합니다.

Next.js에서의 이미지 최적화

1. 이미지 포맷 활용

어떤 포맷을 사용할지 결정하기 전에 이미지 포맷에는 어떤 것들이 있는지, 각각에는 어떤 특징들이 있는지 알아봐야겠습니다 :)

이미지의 여러 포맷들

이미지 포맷 형식에는 jpg, png, webpg, avif 등이 있습니다.

이미지의 다양항 형식을 이해할 때 중심점이 되는 것은 압축형식이다.

손실 압축 vs 무손실 압축

  • 손실 압축
    • 이미지의 품질을 희생하고 더 적은 용량을 선택한 방식
    • 이미지의 중요한 정보만 최대한 보존하고, 불필요한 정보는 조금씩 빼는 방식으로 압축
    • 저장을 하면 자동으로 이미지가 손실되며, 저장이 누적될수록 손실도 누적
  • 무손실 압축
    - 이미지의 품질을 떨어뜨리지 않은 채로 압축하는 방식

1. jpg

jpg는 Joint Photograph Experts Group의 줄임말입니다. jpg는 대표적으로 손실압축 방식을 채택한 이미지 형식입니다. 따라서 jpg 이미지는 저장하기만 해도 이미지에는 손실이 발생합니다.

2. png

png는 Portable Network Graphics의 줄임말입니다. 말 그래도 인터넷에서 표현될 이미지를 염두에 두고 만들어졌습니다. 그래서 색상값은 RGB를 사용하며 투명도를 표현할 수 있습니다. 무손실 압축을 사용하기 때문에 고품질을 유지한다는 특징이 있습니다.

3. webp

webp는 2010년 구글에서 만들었습니다. webp는 고품질의 이미지를 표현하면서도 png, jpg 등 기존의 포맷보다 파일의 크기가 작습니다. 무손실 압축 방식입니다.

4. avif

avif는 2019년 AOMedia에서 만들었습니다. avif는 여러 형식(jpg, webp, ..) 보다 훨신 더 나은 무손실 압축과 고품질을 자랑합니다. 단순히 jpg와 비교했을 때는 동일한 품질 대비, 최대 10배나 적은 용량을 가집니다.
webp와 비교했을 때는 20% 더 높은 압축률을 보여줍니다.

🤩 적용해보자

avif를 사용하는 경우 webp보다 20% 높은 압축률을 자랑한다고 했으니, avif 포맷 형식을 사용해봅시다.

📌 참고
next/image<Image>를 사용하면 Next가 알아서 webp 형식으로 이미지를 최적화 해준다.

하지만, 이미지를 avif 형식을 사용하고 싶다면, next.config.js에서 설정이 필요합니다.

📄 next.config.js

const nextConfig = {
  images: ['image/avif', 'image/webp'],
}

위와 같이 설정해주고 이미지를 불러오면 모든 이미지가 avif 형식의 이미지로 불러와지고, 해당 설정이 없으면 모든 이미지를 알아서 webp 형식의 이미지로 불러옵니다.

직접 동일한 이미지(크기도 동일하게)에 대해 포맷 형식만 다르게 하여 비교해 보았습니다.

포맷 형식Network사이즈
avif202B
webp16.5kB

직접 Network 탭에서 확인했을 때 avif가 webp에 비해 80% 작은 사이즈를 가지는 것을 확인할 수 있었습니다.

이미지 화질 면에서도 차이를 보여드리자면 (참고로 원래는 png 형식의 이미지입니다)

avifwebp

두 이미지 모두 png 이미지에서 보다 큰 화질 저하는 없어 보였습니다. 뿐만 아니라 두 이미지는 80배의 용량 차이가 있지만, 두 이미지을 두고 비교해 보아도 큰 차이가 눈으로는 보이지 않았습니다.

브라우저가 지원하지 않는다면?

AVIF는 요즘 대부분의 브라우저에서 지원하지만 일부 지원하지 않는 경우도 있으니 사용할 대 주의해야 합니다.

아래는 caniuse.com에서 AVIF를 검색했을 때의 결과입니다.

그럼 AVIF를 사용하지 말아야 할까?

아닙니다! 일반적으로 이런 경우 <picture> 태그를 사용할 수 있습니다.

<picture>
	<source srcset="img/photo.avif" type="image/avif">
	<source srcset="img/photo.webp" type="image/webp">
	<img src="img/photo.jpg" alt="Description" width="360" height="240">
</picture>

picture 태그를 사용하면 <picture> 태그 안에 있는 이미지를 순서대로 지원 가능한지를 검사하고 브라우저가 인식하지 못하는 이미지라면 건너뛰게 됩니다.

2. 이미지 lazy loading

lazy loading이란

이미지 lazy loading이란 웹 페이지에서 이미지를 지연해서 로드하는 전략입니다. 사용자가 스크롤하거나 필요할 때까지 이미지를 로드하지 않고, 해당 이미지가 뷰포트에 가까워질 때 동적으로 로드합니다.

기존 React에서는 lazy loading을 적용하기 위해서 intersection observer API를 이용하거나 이벤트 함수를 활용하는 방법이 있습니다.

🤩 적용해보자 - Next.js에서는 기본적으로 지원

next/image<Image> 컴포넌트를 통해 이미지를 로드할 경우, 기본적으로 lazy loading을 지원해 줍니다. 뿐만 아니라 placeholder라는 옵션을 통해 네트워크에서 불러오는 동안 임시로 보여줄 이미지도 자동으로 blur 처리를 해줍니다.

<Image
  src='/images/test.png'
  alt='test'
  width={300}
  height={300}
  placeholder='blur'
  blurDataURL='image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
/>

위의 경우 static 이미지를 사용한 경우이고, external 이미지를 이용하려는 경우 추가적인 설정이 필요합니다.

external 이미지 사용하기

next.js에서 외부 이미지를 사용하려면 추가적인 설정이 필요합니다.

📄 next.config.js

const nextConfig = {
  images: {
    domains: ['picsum.photos'],
  },
}

위와 같이 가져올 이미지의 도메인을 설정에 추가해주면 됩니다.

3. 이미지 리사이징 - 반응형 이미지

가로 길이가 480px인 모바일 화면에서 4K 이미지를 보여주어야 할 일은 없을 것입니다. 480px의 화면에선 그 크기에 알맞는 정도의 이미지만 가져오면 되빈다.
필요 이상 크기의 이미지를 가져오는 것은 네트워크 대역폭을 낭비합니다. 때문에 화면에 알맞는 크기의 이미지를 보여주는 것은 성능을 최적화하는 데에 도움이 됩니다.

우리가 해야할 일은 브라우저 화면에 알맞는 이미지를 가져오도록 하는 것
→ 이를 resolution switching이라고 한다.

이미지 리사이징 하는 방법 - srcset & sizes

일반적으로 다음과 같이 <img> 태그 안에 src와 alt를 넣어 구성하죠?

<img src="test.png" alt="테스트 이미지">

여기서 반응형 이미지를 제공하고 싶다면, 브라우저가 인지할 수 있도록 srcset 속성과 sizes 속성을 사용할 수 있습니다.

<img srcset="test-320w.png 320w,
             test-480w.png 480w,
             test-800w.png 800w"
     sizes="(max-width: 320px) 280px,
            (max-width: 480px) 440px,
            800px"
     src="test.png" alt="테스트 이미지">

각각에 대해 알아봅시다.

srcset

브라우저에게 어떤 크기의 이미지를 보여주면 되는지 알려주는 역할을 합니다. 각각의 이미지의 크기도 함께 정의하면서 보여줍니다.

srcset=이미지파일명 픽셀너비w

sizes

미디어 조건문을 나타냅니다. 그래서 특정 화면에서 어떤 크기가 최적인지를 나타냅니다.

sizes=미디어조건문 이미지가채울슬롯의너비

srcset & sizes를 명시했을 때, 브라우저는

위와 같은 속성들을 명시해주었을 때, 브라우저에서 일어나는 일의 순서는 다음과 같습니다.

  1. 기기의 너비를 확인한다.
  2. sizes에서 가장 먼저 참이 되는 조건문을 확인한다.
  3. 그 조건문에서 제공하는 슬롯의 크기를 확인한다.
  4. 그 슬롯의 크기에 가장 근접한 이미지를 srcset에서 찾는다.

🤩 적용해보자 - Next.js에서는 기본적으로 지원

이번에도 역시 next/image<Image>에서 기본적으로 제공합니다.
Next.js는 srcset을 자동으로 설정하여 이미지 후보들을 생성하고 viewport의 너비에 따라 로드될 이미지 후보들 중에서 선택하여 로드합니다.

<Image>의 layout 속성

이를 사용하기 위해서는 <Image>layout 이라는 속성이 필요합니다.
이 속성은 해당 이미지가 viewport에 따라 어떻게 반응하는지에 대한 속성입니다.

4가지 옵션이 존재합니다.

  • intrinsic
    default값이며, 이미지의 width와 height에 따라 얼마나 많은 자리를 차지하는지 계산

  • fixed
    이미지의 정확한 width와 height를 사용하여 표시

  • fill
    이미지를 상위 엘리먼트의 width와 height에 맞추기 위해 자동으로 width와 height를 조절.
    반드시 상위 엘리먼트는 position: relative을 적용해야 한다.
    ⭐️ 이미지 사이즈를 모를 때 사용하면 좋은 옵션

  • responsive
    부모 컨테이너의 width에 맞게 이미지를 확대. 반드시 부모 컨테이너에 display: block을 추가해야 한다.

반응형을 위해서는 fill 혹은 responsive로 사용

🚨 주의할 점은 layout 속성을 사용하면 width와 height 속성을 사용할 수 없습니다.

<Image>의 sizes 속성

현재 뷰포트의 너비에 따라 로드될 이미지를 설정할 수 있습니다. sizes는 layout 속성 값이 responsive와 fill인 경우메나 사용되는 속성입니다.

<div style={{ position: 'relative', width: '300px', height: '300px' }}>
      <Image
        src='/images/test.png'
        alt='test'
        // width={300}
        // height={300}
        layout='fill'
        sizes='(max-width: 768px) 50vw,
         (max-width: 1024px) 100vw'
        placeholder='blur'
        blurDataURL='image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
      />
    </div>

자동으로 srcset이 지정되어 있는 모습을 볼 수 있었습니다.

4. 이미지 용량 압축하기

이미지의 용량은 성능에 많은 영향을 미칩니다. 때문에 가능하면 용량이 적은 이미지를 사용하는 것이 좋지만, 이미지를 업로드하는 경우 압축이 필요할 수 있습니다.

🤩 적용해보자

이미지를 압축해주는 라이브러리를 우선 몇 가지 살펴봅시다.

browser-image-compressioncompressorjs 중에 고르면 좋을 것 같은데, 거의 모든 조건이 비슷한 것 같아 번들 사이즈가 좀 더 작은 compressorjs를 선택하려고 했으나 class 기반으로 되어 있어, 비교적 사용법이 간단한 browser-image-compression 로 선택하게 되었습니다.

$ yarn add browser-image-compression

📄 utils/compressImage.ts

import imageCompression from 'browser-image-compression';

export const compressImage = async (file: File) => {
  const options = {
    maxSizeMB: 0.2,
    maxWidthOrHeight: 1920,
    useWebWorker: true,
  };
  return await imageCompression(file, options);
};

위와 같이 설정해서 압축한 후 업로드하면 되겠습니다.

5. 이미지 캐싱하기

캐싱이란 파일 사본을 캐시 혹은 임시 저장소에 저장해서 보다 빠르게 접근할 수 있도록 하는 하나의 프로세스입니다.
쉽게 말하면, 가까운 곳에 데이터를 임시로 보관하는 것이라고 말할 수 있는데요!

가까운 곳에 저장해두면 먼 곳까지 데이터를 가지러 안 가도 되로고, 혹은 오래 걸리는 연산을 다시 하지 않도록 해서 성능을 최적화하는 기법이라고 할 수 있습니다.

다른 리소스에 비해 꽤 큰 용량을 차지하는 이미지의 경우, 캐싱을 해두면 성능 향상에 도움이 될 수 있습니다.

캐싱 방법1: 헤더를 통한 캐싱

HTTP/1.1 버전부터 cache-control 및 expire, validation 등의 기능이 지원되면서 캐싱을 효율적으로 할 수 있게 되었습니다.

캐시을 할 때에는 기본적으로 2가지 전략이 있습니다.

  • mutable한 리소스
  • immutable한 리소스

각각 다른 전략을 취해야 합니다.

mutable한 리소스

mutable한 리소스란 index.html파일처럼 그 내부가 변경될 가능성이 있는 파일입니다. 그래서 리소스를 캐싱해두었다가, 리소스의 내용물이 변경되었을 경우에 캐싱을 무효하게 만들고, 다시 데이터를 요청하도록 하는 방법입니다. 이때 헤더에 명시할 내용은 다음과 같습니다.

Cache-Controlno-cache를 설정합니다. no-cache는 캐싱을 하지 않겠다는 말이 아니라, 서버에게 새로운 컨텐츠가 있는지를 묻는 역할을 합니다. 그래서 새로운 컨텐츠가 있다면 그것을 다운로드합니다.

또한 ETags(Entity Tag)를 사용합니다. 위에서 no-cache를 통해 서버에게 새로운 데이터가 있는지 확인한다고 했지만, 반드시 함께 사용해야 하는 속성이 바로 ETags입니다. ETags은 특정 리소스에 대한 토큰을 발급합니다. 그래서 해당 토큰값을 가지고 있다가, 컨텐츠가 변경되면 새로운 토큰을 발급합니다. 토큰 값을 비교하면서, 서로 다른 토큰값이 발견되면 컨텐츠에 변경이 생겼다는 것을 알아차리는 방식입니다.

🤔 no-cache와 no-store
이름은 비슷하지만 두 값의 동작은 좀 다릅니다.

  • no-cache
    캐시는 저장하지만 사용하려고 할 때마다 서버에 재검증 요청을 보내야 합니다.

  • no-store
    캐시를 절대로 해서는 안 되는 리소스일 때 사용합니다.

위 사진을 보게 되면 서버에서 발급받은 ETags를 브라우저는 가지고 있다가 리소스를 요청합니다. 서버 쪽에서 ETags를 확인했더니 일치하는 토큰입니다. 즉, 변경 사항이 없었다는 것입니다. 304 상태코드와 함께 응답을 보냅니다.

위 사진은 토큰이 일치하지 않은 경우입니다. 브라우저가 가지고 있던 ETags와 함께 리소스를 요청했는데, 서버 쪽에서 확인을 해보니 토큰이 달랐던 겁니다. 그래서 상태코드 200와 함께 새로운 ETags와 변경된 리소스를 함께 응답으로 보내줍니다.

이것이 바로 mutable한 리소스에 대한 캐싱 전략입니다.

서버는 리소스 요청과 함께 온 ETags를 받고 변경 사항이 없다면 304 상태 코드와 캐싱된 리소스를 보내준다
만약 변경 사항이 있다면 200 상태 코드와 함께 변경된 리소스를 응답으로 보내준다.

immutable한 리소스

immutable한 리소스의 대표적인 것에는 이미지 파일이 있습니다. 우리가 이미지 파일을 내부적으로 변경시킬 일은 없을 것입니다. 또한 파일 이름을 통해서 버전관리를 하는 파일들도 immutable한 리소스에 포함됩니다. 우리가 vscode에서 파일 이름을 변경하면 git은 전 파일이 delete되고 새로운 파일이 추가된 것처럼 인식하는 것을 봐도 그렇습니다. immutable한 리소스에 대한 캐싱 전략을 알아봅시다.

Cache-Controlmax-age를 설정해줍니다. 만약 1년을 캐싱하도록 하고 싶다면 max-age: 31536000로 설정하면 됩니다. 또 다른 방법으로는 expires 헤더를 사용하는 것입니다. max-age는 어느정도 시간동안 캐싱하겠다고 나타낸다면, expires는 특정 날짜까지 캐싱을 하겠다고 명시하는 것입니다.

위와 같이 한 번 받아온 리소스의 유효 기간이 지나기 전이라면, 브라우저는 서버에 요청을 보내지 않고 디스크 또는 메모리에서만 캐시를 읽어와 계속 사용합니다.

🤔 캐시의 유효 기간이 지나면 어떻게 될까?
캐시의 유효 기간이 끝나면 캐시가 완전히 사라질까요? 그렇지는 않습니다. 대신 브라우저는 서버에 조건부 요청(Conditional request)을 통해 캐시가 유효한지 재검증(Revalidation)을 수행합니다.

재검증 결과, 브라우저가 가지고 있는 캐시가 유효하다면 서버는 304 상태코드로 매우 빠르게 리소스 응답이 가능합니다. 만약 캐시가 유효하지 않다면 200 상태 코드와 함께 최신 값을 내려받을 수 있도록 합니다.

캐싱 방법2: 메모리 캐시와 디스크 캐시

실제로 어떻게 캐싱을 적용하기 전에 메모리 캐시와 디스크 캐시에 대해 알아봐야겠습니다.

우선 그 전에, 캐싱이 잘 되고 있는가를 확인하려면 네트워크 탭에서 위와 같이 (memory cache) 혹은 (disk cache)라고 적혀 있습니다.

자세히 보면 특징이 있습니다. memory cache의 경우 리소스를 다운 받는데 걸린 시간이 0ms, disk cache의 경우에는 2ms 혹은 3ms인 것을 확인할 수 있습니다. 무슨 차이가 있길래 그런 걸까요?

memory cache

memory cache의 경우, 리로스를 메모리(RAM)에 저장해 둡니다. 때문에 훨씬 빠르지만, 휘소성이 있습니다. 브라우저가 닫히기 전까지는 메모리에 캐싱되어 있다가 사용됩니다.

disk cache

disk cache의 경우는 영구적입니다. 즉, 휘소성이 없습니다. 이는 유저의 디스크에 저장되어 있다가, 리소스를 요청하면 디스크로부터 가져오기 때문에 메모리보다는 느립니다.

그래서 브라우저를 껐다가 바로 다시 켰을 때는 모든 데이터가 disk cache에서 가져오고, 그 이후에 새로고침을 하게 되면 몇몇 데이터는 memory cache에서 가져오기 시작할 것입니다.

🤩 적용해보자 - Next.js에서는 기본적으로 지원

Next.js에서 이미 이미지까지 캐싱하도록 지원을 하고 있습니다. 기본적으로 static 폴더에 들어가 있는 모든 파일들은 자동으로 캐싱됩니다. 또한 동적으로 불러오는 이미지들의 경우에도 캐싱이 되고 있습니다. .next/cache 폴더에서 /image 폴더에 들어가보면, 캐싱되어 있는 external 이미지들을 확인할 수 있었습니다. 요청했던 사이즈별로 이미지가 캐싱되어 있습니다.

13버전의 Image 컴포넌트 개선

Next.js 13 버전이 나오면서 많은 부분들이 변경되었다고들 합니다. 여기에는 Image 컴포넌트도 포함되는데요! 공식문서에 13 버전 업데이트 노트에 Image 컴포넌트의 변경에 대해서도 중요하게 다루고 있습니다.

The new Image component

  • Ships less client-side JavaScript
  • Easier to style and configure
  • More accessible requiring alt tags by default
  • Aligns with the Web platform
  • Faster because native lazy loading doesn't require hydration

위 내용만 보면, 어떻게 변경되었길래 JS를 덜 쓰게 되었으며, 렌더링 방식이 어떻게 변경되었길래 이전엔 hydration이 필요했고 지금은 필요하지 않을까요?

실제 렌더링 시의 차이점

간단하게 Next.js에서 두 가지 컴포넌트를 렌더링해보겠습니다.

📌 참고
13버전 이전의 Image 컴포넌트는 'next/legacy/image로 이동했습니다.

import Image from 'next/image'
import LegacyImage from 'next/legacy/image'

export default function Home() {
  return (
    <main>
      <label>Image(13)</label>
      <Image
        src='/images/test.png'
        alt='test'
        width={300}
        height={300}
      />
      <label>Legacy</label>
      <LegacyImage
        src='/images/test.png'
        alt='test'
        width={300}
        height={300}
      />
    </main>
  )
}

겉으로 보기에는 동일했습니다. 그러나 차이점은 DOM tree에서 발견할 수 있었는데요.

DOM tree에서의 차이

legacy의 경우에는 이미지 DOM의 상단에 <span> 태그가 존재합니다. 그리고 <span> 태그 하위에 <span> 그리고 <img> 가 있습니다. 이것들은 무슨 역할일까요?

12버전의 Image

CLS 해결

Next.js12까지는 Image 컴포넌트를 사용할 때 span 태그로 감싸고 투명한 png를 로드합니다. 이때 투명한 이미지의 크기를 결정하기 위해서 Image 컴포넌트의 width, height를 필수 props로 전달해야 합니다.그리고 이미지가 로딩되면 투명 이미지에 absolute 속성을 준 진짜 img 태그가 보이는 형태로 만들어져 있습니다.

CLS를 해결하기 위해서 이미지가 로딩되기 전에 투명 이미지로 레이아웃을 잡아놓고 이미지가 로딩되면 화면에 보이도록 한 것입니다.

13버전의 Image

아까 잠깐 보았을 때, 13버전의 Image 컴포넌트를 사용했을 때는 이전 버전과 다르게 img 태그 하나만 화면에 렌더링된 것을 확인할 수 있었습니다.

CLS 해결

📄 next.js/packages/next/src/shared/lib/get-img-props.ts

if (!fill) {
  if (!widthInt && !heightInt) {
    widthInt = staticImageData.width
    heightInt = staticImageData.height
  } else if (widthInt && !heightInt) {
    const ratio = widthInt / staticImageData.width
    heightInt = Math.round(staticImageData.height * ratio)
  } else if (!widthInt && heightInt) {
    const ratio = heightInt / staticImageData.height
    widthInt = Math.round(staticImageData.width * ratio)
  }
}

width, height 값이 있다면 해당 값을 사용해서 비율을 맞추게 됩니다. width, height가 둘 중 하나의 값만 있다면 비율을 계산해서 값을 얻습니다.

해당 값을 img에 넘기면 width, height 크기를 잡아 놓기 때문에 layout shift가 생기지 않습니다.

🤔 그럼 13버전 이전에는 width랑 height가 어떻게 처리 되었길래?
아래는 legacy Image 컴포넌트에 해당하는 DOM tree 내용입니다.

13버전과 달리, 보게 되면 어디에도 제가 입력했던 width=300height=300을 확인할 수 없었습니다.
Image 컴포넌트를 사용하면 Next.js는 지정한 widthheight 를 기반으로 필요한 크기의 이미지를 동적으로 생성하는데, 이때 생성한 이미지는 실제 DOM에 추가되는 것이 아니라, 생성된 이미지의 url을 src 속성에 할당하여 브라우저에 로드합니다.

결론적으로, 내부적으로 처리하고, 최적화된 이미지를 로드할 때 겉에 <span> 태그를 하나 더 두어서 투명 레이아웃으로 layout shift를 방지하려고 했던 것 같습니다.

반면, 13버전에서는 img의 기본 property인 widthheight 로 크기를 잡아놓는 방법으로 layout shift를 방지합니다.

Hydration이 불필요

이 내용은 lazy loading과 관련이 있는데요. next13 이전까지는 intersection observer를 사용하여 구현했습니다. next13 버전부터는 기본 property인 loading으로 구현 방법을 변경하여 JS 사용량을 줄였습니다. 따라서 hydration이 불필요해졌다는 말이였습니다.

next13 버전 이후의 Image 컴포넌트 변경 사항 정리

Next.js 13에서는 Image 컴포넌트의 로딩 방식이 변경되었고 특히 CLS를 해결하는 방식과 lazy loading을 구현하는 방식이 변경되었습니다.

  1. lazy loading
    intersection observer를 사용하다가 img의 기본 property인 loading으로 구현 방법 변경하여 JS 사용량이 줄어들었고, 이 덕분에 hydration 불필요

  2. CLS 방지
    span으로 감싸고 투명 이미지를 불러와서 absolute로 이미지 위치리를 잡는 것을 제거, img 기본 property인 width와 height를 사용해서 구현, 이 덕분에 스타일링 간편

Next.js는 모든 이미지를 최적화해주진 않아요

Next.js의 이미지 최적화 모듈의 코드 일부분을 보면 다음과 같습니다.

const vector = VECTOR_TYPES.includes(upstreamType)
const animate =
      ANIMATABLE_TYPES.includes(upstreamType) && isAnimated(upstreamBuffer)

if (vector || animate) {
  return { buffer: upstreamBuffer, contentType: upstreamType, maxAge }
}

SVG와 같은 vector 이미지, 그리고 GIF와 같은 상대적으로 복잡하고 최적화에 오래 걸리는 애니메이션 이미지의 경우에서는 기본적으로 최적화 기능을 제공하지 않고 바로 응답을 내려주게 되어 있습니다.

Next.js가 권장하는 sharp 라이브러리

Next.js에서는 sharp 라이브러리를 사용할 것을 권장하고 있는데요.

Sharp Missing in Production

Next.js는 sharp를 import해서 설치 유무를 확인합니다

이미지 최적화 모듈을 초기화할 때, sharp를 import함으로써 sharp의 설치 여부를 확인하고 이후에 동작하는 로직에서 sharp 변수를 기준으로 동작하는 방식으로 코드가 작성되어 있습니다.

바로 아래에는 sharp 가 없을 경우, warning도 show하도록 되어있죠!

let sharp: typeof import('sharp') | undefined

try {
  sharp = require(process.env.NEXT_SHARP_PATH || 'sharp')
  if (sharp && sharp.concurrency() > 1) {
    // Reducing concurrency should reduce the memory usage too.
    // We more aggressively reduce in dev but also reduce in prod.
    // https://sharp.pixelplumbing.com/api-utility#concurrency
    const divisor = process.env.NODE_ENV === 'development' ? 4 : 2
    sharp.concurrency(Math.floor(Math.max(cpus().length / divisor, 1)))
  }
} catch (e) {
  // Sharp not present on the server, Squoosh fallback will be used
}

let showSharpMissingWarning = process.env.NODE_ENV === 'production'

Next.js는 기본 이미지 최적화 모듈로 Squoosh를 사용해요

Next.js는 Squoosh를 기본 이미지 최적화 모듈로 사용하고 있습니다. Squoosh를 기본으로 사용하는 이유는 빠르게 설치할 수 있고 개발 환경에 적합하기 때문이라고 합니다.

⭐️ 그런데, 운영 환경에서는 sharp를 사용하는 것을 매우 강력하게 권장하고 있죠!

sharp 라이브러리의 소개를 보게 되면, 다양한 크기의 JPEG, PNG, WebP, GIF, AVIF와 같은 이미지들을 더 작은 크기로, 그리고 웹에 진화적으로 변환해 주는 매우 빠른 속도의 모듈이라고 설명하고 있습니다.

sharp와 Squoosh 성능 비교

제가 레퍼런스로 보고 있는 올리브영의 테크블로그에 의하면 다음과 같습니다.

코드는 동일하고 sharp 라이브러리를 추가해서 Next.js 서버를 구동한 휘 설치 전/후 이미지 최적화 결과를 비교합니다.

PNG → Webp 변환

Webp 파일로 변환했을 때의 모습입니다. 원본은 1.9MB의 이미지 파일입니다.

이미지 크기를 비교했을 때, Squoosh는 17.1KB, sharp는 16.9KB로 크기를 감소했습니다.

크기를 비교했을 때는 큰 차이가 없지만, 응답 속도를 비교한다면 Squoosh는 228ms, sharp는 64ms입니다. sharp를 사용했을 때 약 3~4배 정도 빠르게 응답을 받을 수 있었습니다.

PNG → AVIF 변환

AVIF 파일로 변환했을 때의 모습입니다. 원본은 1.9MB의 이미지 파일입니다.

이미지 크기를 비교했을 때, Squoosh는 10.8KB로, sharp는 13.1KB로 크기를 감소했습니다.

이번에도 크기를 비교했을 때는 큰 차이가 없지만, 응답 속도를 비교한다면 Squoosh는 1.24s, sharp는 202ms입니다. sharp를 사용했을 때 약 6배 정도로 빠르게 응답을 받을 수 있었습니다.

⭐️ 속도 면에서 상당한 차이가 있었다. sharp를 사용하지 않을 이유가 없다..!

결론

Next.js는 최적화 전략을 이미 많이 제공하고 있어 간편하게 사용이 가능했습니다. 그렇지만 이번 포스팅을 통해 Next가 무엇을 위해 최적화 전략을 제공하며 어떤 내용을 포함하고 있는지 알아볼 수 있었습니다. 뿐만 아니 next@13 버전의 Image 컴포넌트 개선에는 어떤 내용들이 포함되어 있는지까지도 알아보았습니다. 앞으로 어떤 더 나은 최적화 전략을 적용해 나갈지 기대가 되기도 합니다 :)

참고 문서

profile
개발자와 사용자 모두의 눈👀을 즐겁게 하는 개발자가 되고 싶어요 :) 👩🏻‍💻

0개의 댓글