이미지 최적화를 위한 전략 feat.Nextjs

yumyum·2022년 10월 11일
144
post-thumbnail
  • 이미지 최적화를 위한 전략들을 설명합니다.
  • 제 프로젝트 환경이 Nextjs 였으므로, Nextjs에선 어떻게 이미지를 최적화 하는지를 더불어 설명합니다.

이미지 최적화는 왜 하는것일까?

이미지 최적화는 투자대비 성능 효율이 가장 좋습니다. 그리고 신경 쓰지 않을 경우, 성능에 가장 큰 해악을 미칠지도 모릅니다.

Web Vital이란 탁월한 사용자 경험을 제공하기 위한 핵심 지침입니다. 이 Web Vital에는 3가지 핵심 지표가 존재하는데, 그 중 LCP(Lagest Contentful Paint)는 페이지가 처음으로 로드를 시작한 시점을 기준으로 화면에 있는 가장 큰 이미지 또는 텍스트블록의 렌더링 시간을 알려줍니다. 그리고 텍스트보다도 특히 이미지의 사이즈,갯수,레이아웃, 로딩 우선순위에 따라서 이 지표가 달라집니다. 따라서 이미지를 최적화 하는 것은 성능을 최적화해야 할 여러 요소들 가운데, 가장 먼저 고려되는 요소입니다.

그렇다면 이미지 최적화를 위한 전략에는 어떤 것들이 있을까요?

이미지 최적화 전략에는 무엇이 있는가?

이미지를 최적화하기 위한 전략에는 다양한 방법이 존재합니다.

  1. webp 사용하기 -> avif 사용하기 :

    • web.dev 문서에 의하면 여타 png, jpeg 형식의 이미지보다 webp 형식의 이미지 파일이 용량이 훨씬 더 작습니다.
    • 최근에 알게된 사실이지만, webp 보다도 avif형식이 압축률이 높다고 합니다. mdn에서도 소개하고 있습니다.
  2. 이미지 레이지 로딩하기 :

    • 화면에 나타나기 전에는 placeholder 이미지를 넣어두었다가, 화면에 나타났을 때 리소스를 요청해도록 하는 방법이 있습니다. 이렇게 하면 화면 전체에 있는 이미지를 한번에 불러오는 것이 아니라, 필요한 순간에만 네트워크에서 요청하도록 만듦으로써 성능을 최적화할 수 있습니다.
  3. 이미지 리사이징 :

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

    • 이미지의 용량을 압축하는 방법이 있습니다. 해당 방법은 화질이 저하될 우려가 있으므로 신경을 써야 합니다.
  5. 이미지 캐싱하기 :

    • 이미지를 캐싱해두면 네트워크 요청시 속도를 매우 향상시킬 수 있습니다.
    • 캐싱해둘 수 있는 공간은 브라우저 캐시와, CDN 서버가 있습니다.

이런 리스트들이 존재합니다. 그럼 각각의 리스트를 조금 더 상세히 알아보고, 저의 프로젝트에는 적용했는지 소개해보겠습니다.


이미지 최적화 적용하기 :

1.WebP사용하기. 아니, Avif 사용하기

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

이미지의 여러 포맷들 :

이미지형식에는 다양한 것들이 있습니다. jpg, png, webp, avif 등등입니다. 이미지의 다양한 형식을 이해할 때 중심점이 되는 것이 있는데, 바로 압축형식입니다.

손실압축 vs 무손실압축 :

압축의 형식에는 손실 압축과 무손실 압축이 있습니다. 손실 압축은 이미지의 품질을 희생하고 더 적은 용량을 선택한 방식입니다. 이미지의 중요한 정보만 최대한 보존하고, 불필요한 정보는 조금씩 빼는 방식으로 압축됩니다. 그래서 이 방식으로 저장을 하면 자동으로 이미지가 손실되며, 저장이 누적될수록 손실도 누적됩니다.

무손실압축은 그 반대라고 할 수 있습니다. 이미지의 품질을 떨어뜨리지 않은 채로 압축하는 방식입니다.

1.jpg :

jpg는 Joint Photograph Experts Group의 줄임말입니다. 이 친구는 25년 동안 가장 널리 지원되었던 이미지 형식입니다. 이미지계의 고인물입니다. jpg는 대표적으로 손실압축 방식을 채택한 이미지 형식입니다. 따라서 jpg로 저장하기만 해도, 이미지에는 손실이 생깁니다. 그럼에도 용량의 이점이 있기는 했습니다.

2.png :

png는 Portable Network Graphics의 줄임말입니다. 단어 뜻에서도 알 수 있듯이 이는 인터넷에서 표현될 이미지를 염두에 두고 만들어졌습니다. 그래서 색상값 중에서도 CMYK 대신 RGB를 사용하는데, 이 때문에 다양한 투명도를 표현할 수 있습니다.(jpg는 불가) png의 중요한 특징이라면 무손실 압축으로 이루어져있으며, 이 때문에 고품질을 유지한다는 것입니다.

3.webp :

webp는 2010년에 구글이 만들었습니다. 구글은 이를 만들고 무료로 배포했는데, 이렇게 많은 사람이 webp를 이용하게 만듦으로써 그들의 서비스의 성능도 높이고자 했던 것입니다. webp는 고품질의 이미지를 표현하면서도 png, jpg 등 기존의 포맷보다 파일의 크기가 작습니다. png보다 최대 26%까지 줄일 수 있다고 합니다.

4.avif :

avif는 2019년에 AOMedia에 의해서 만들어졌습니다. avif는 오늘날 인기 있는 여러 형식(jpeg, webp, jpeg2000)보다 훨씬 더 나은 무손실 압축과 고품질을 자랑합니다. 단순히 jpeg와 비교했을 때는 동일한 품질 대비, 최대 10배나 적은 용량을 가집니다.
webp와 비교했을 땐 20% 더 높은 압축률을 보여줍니다. 하지만, 경우에 따라서는 webp 무손실 압축이, avif 무손실 압축보다 더 나을수도 있긴합니다.

이미지 형식에 대한 더 자세한 설명을 원하시면 아래의 자료를 참고해보시면 좋을 것 같습니다.

JPEG vs. PNG: 적절한 이미지 포맷 선택하기 (1) - JPEG편
JPEG vs. PNG: 적절한 이미지 포맷 선택하기 (2) - PNG편
MDN : avif_image
webp-avif comparison


😎 나는 어떻게 적용했나?

avif를 사용하는 경우 webp보다 20% 높은 압축률을 자랑합니다. 그래서 저 또한 avif 이미지 형식을 사용하기로 했습니다.

일반적으로 webp와 같은 형식을 지원하기 위해선, 이미지 형식을 변형시켜줘야 할 것입니다. Nextjs에서는 자동으로 이미지 변환을 지원해줍니다. 사용법은 그냥 next/image에서 제공되는 Image 컴포넌트를 사용하면 됩니다.

하지만, 이미지를 avif형식으로 사용하고 싶다면, next.config.js에 아래와 같이 설정해줘야합니다.

module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],
  },
}

🧐 브라우저가 지원하지 않으면 어떡하지?

혹 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 태그를 사용하면 브라우저가 인식하지 못하는 이미지를 건너 띌 수 있습니다. 코드에 보이시는것과 같이 본인이 우선적으로 보여주길 원하는 순서대로 이미지 형식을 나열하면 됩니다. 그러면 브라우저는 맨 첫번째 소스부터 지원가능한지를 확인할 것입니다.

그러나 저의 경우 이런 fallback의 기능도 Nextjs에서 제공해주고 있었습니다. 방식은 특별할 것 없습니다. 위에 보이시는대로 next.config.js에서 formats의 배열을 활용합니다. 공식문서에 의하면 Accept 헤더에서 지원하는 이미지 형식을 확인한 후, formats 배열에 들어가있는 순서대로 매칭 시켜서 파일 형식을 변환한다고 합니다. 뒤에 있는 순으로 fallback 이 됩니다.

크롬에서 확인해보았을 때, 아래와 같이 이미지 형식이 avif로 변환되었습니다.

caniuse페이지에서 확인해보면, 사파리에서는 지원하지 않는다는 것을 알 수 있습니다. 그럼 뒤에 fallback으로 webp를 넣어둔 것이 화면에 잘 나오는지 확인해봐야겠습니다.

위 사진은 사파리 네트워크탭에서 이미지 부분을 확인해 본 사진입니다. avif형식을 지원하지 않아 webp로 변환해 가져오는 것을 볼 수 있습니다. 그럼 webp는 지원하지 않는 브라우저가 없을까요? 단 한군데가 있는데, 바로 IE브라우저 입니다. IE브라우저는 2022년 6월부로 은퇴했으니, 고려하지 않아도 괜찮을 것 같습니다.

참고 : acceptable formats



2.이미지 레이지 로딩하기 :

레이지 로딩이란

레이지 로딩이란 특정 리소스를 네트워크에서 요청하지 않고 있다가, 딱 필요한 순간에만 리소스를 요청하는 최적화 전략을 말합니다. 이를 통해 화면을 로드하는 초기에 적은 데이터만을 요청해서, 더욱 빠른 속도로 화면을 렌더링할 수 있게 됩니다. 가장 많이 적용되던 리소스는 역시 이미지였습니다.

과거에는 이 이미지에 레이지 로딩을 적용시키는 방법은 intersection observer API을 이용하거나, scroll&resize&orientationChange 이벤트등을 활용하는 방법이 있었습니다. 그러나 최근에는 Chromium 기반의 브라우저인(Chrome, Edge, Opera) 및 Firefox에서 기본적으로 지원되는 기능이 되었습니다. 사파리는 아직 진행중이라고 합니다.

이미지 태그에서 loading 속성을 사용해서 레이지 로딩을 적용할 수 있습니다.

<img src="image.png" loading="lazy" alt="…" width="200" height="200">

loading의 속성에는 다음의 것들이 있습니다.

  • auto : 기본 지연 로딩 동작
  • lazy : 뷰포트로부터 계산된 거리에 도달할 때까지 리소스 로딩을 지연
  • eager : 리소스를 즉시 로드

그리고 보통은 이미지를 레이지 로딩을 할 때, 유저가 빠르게 스크롤을 내리다가 미처 이미지를 받아오기 전에 화면을 마주할 수도 있습니다. 그럴 때 유저는 빈화면을 보다가 갑자기 나타난 이미지를 보게 될 것입니다. 이런 그림은 유저경험에 그렇게 유익하지 못할 것 같습니다. 이때 사용하는 것이 placeholder 이미지 입니다.

위 gif에서 볼 수 있듯이 대체할 수 있는 blur이미지나, 색을 채운 이미지를 넣어뒀다가 이미지가 도착하면 대체하는 방식의 전략을 사용합니다.

😎 나는 어떻게 적용했나?

사실 이 레이지 로딩은 nextjs에서도 기본적으로 지원합니다. 심지어 static하게 import해오는 파일의 경우 이미지를 네트워크에서 불러오는 동안 임시로 보여줄 이미지도 자동으로 blur 처리를 해줍니다. placeholder="blur" 옵션을 넣어주기만 하면 됩니다. 그럼 이미지가 로드하기 전까지 blur 이미지를 보여줄 수 있습니다. 하지만 static 이미지가 아닌, external 이미지를 이용하려는 경우 저희가 해 줄 일이 있습니다.

external 이미지 사용하기

nextjs에서 외부 이미지를 사용하려면 추가적인 설정이 필요합니다. blur이미지 또한 마찬가지입니다. 우선 외부 이미지를 사용하려면 next.config.js에 설정합니다. 이 내용 또한 공식문서에서 소개하고 있습니다. 아래는 제가 설정한 방법입니다.

const nextConfig = {
	...
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'recoen.s3.ap-northeast-2.amazonaws.com',
        port: '',
        pathname: '/next-s3-uploads/**',
      },
    ],
  },
};

하나의 도메인에서 이미지를 가져올 경우에는 다음과 같이 설정해주면 됩니다. 그런데 저의 경우에는 이런 방식으로 설정해줬을 때,

Error: Invalid src prop 
(https://recoen.s3.ap-northeast-2.amazonaws.com/next-s3-uploads/
 34606707-2b1e-4f0f-ac8b-d4d937a240d4/sumner-mahaffey-7Y0NshQLohk-unsplash.jpg) 
on `next/image`, hostname "recoen.s3.ap-northeast-2.amazonaws.com" 
is not configured under images in your `next.config.js`

이런 에러를 만나게 되었습니다. 이것을 해결하기 위해서 링크가 걸려있는 공식문서 페이지를 들어가니, 여러 개의 도메인을 등록할 때, 사용하는 방법을 권유하고 있었습니다. 그래서 아래와 같이 다시 외부 이미지를 사용할 수 있도록 허용해주었습니다.

...
  images: {
    domains: ['recoen.s3.ap-northeast-2.amazonaws.com'],
  },

이렇게하니 문제 없이 이미지를 가져올 수 있었습니다.

external 이미지에서 blur 옵션 사용하기

위에서 언급했듯이, static한 이미지 파일을 이용하는 경우에는 blur 옵션을 이용하기 위해 추가적인 설정이 필요없습니다. 하지만 external 이미지를 사용하는 경우에는 blur 처리를 할 때도 추가적으로 해주어야 하는 일이 있습니다. blurDataUrl프로퍼티에 blur처리된 이미지url을 넣어주어야하는 것입니다.

이것을 사용하기 위해서 넥스트 js가 추천하는 라이브러리가 있습니다. Plaiceholder라는 라이브러리입니다. 해당 라이브러리는 내부적으로 sharp라는 라이브러리를 이용합니다. sharp 라이브러리는 이미지를 가공하기 위해서 운영체제영역에서의 기능을 사용하는데, 이 때문에 plaiceholder는 브라우저 상의 js에서 실행되어서는 안됩니다. nodejs 환경에서만 정상작동합니다.

때문에 plaiceholder 공식문서에서 보여주는 예제에서는 blur처리된 이미지를 얻어내는 로직이 getStaticProps 안에 들어가있습니다. build 타임시에 서버쪽에서 해당 작업을 처리한다는 것이죠.아래는 공식문서에서 보여주는 예시입니다.

export const getStaticProps = async () => {
  const { base64, img } = await getPlaiceholder(
    "https://images.unsplash.com/photo-1621961458348-f013d219b50c?auto=format&fit=crop&w=2850&q=80",
    { size: 10 }
  );

  return {
    props: {
      imageProps: {
        ...img,
        blurDataURL: base64,
      },
      title: config.examples.pages.base64.title,
      heading: config.examples.variants.single.title,
    },
  };
};

위의 예시에선 한 페이지에 하나의 이미지가 들어가 있는 상황인 것 같습니다. 그러나 저의 경우엔 이미지 리스트를 보여주어야 했기 때문에, 여러 이미지를 blur처리해 가져와야했습니다. 때문에 getStaticProps 안에서 다시 데이터를 가공해주었습니다.

export const getStaticProps: GetStaticProps = async () => {
  try {
    console.log('CONNECTING TO MONGO IN ARTICLE PAGE');
    await connectMongo();
    console.log('CONNECTED TO MONGO IN ARTICLE PAGE');

    console.log('FETCHING DATA IN ARTICLE PAGE');
    const res = await ArticleModel.find();
    console.log('FETCHED DATA IN ARTICLE PAGE');

    const articles = JSON.parse(JSON.stringify(res));

    const articlesWithBlurURL = await Promise.all(
      articles.map(async (article: ArticleT) => {
        const { base64 } = await getPlaiceholder(article.imgUrl);
        return { ...article, blurDataURL: base64 };
      }),
    );
    return {
      props: {
        articles: articlesWithBlurURL,
      },
    };
  } catch (error) {
    console.log(error);
    return {
      notFound: true,
    };
  }
};

이렇게 props를 구성해주었습니다. 그리고 이미지를 보여주는 컴포넌트에 아래와 같이 blurDataUrl을 넣어주었습니다.

...
  <CustomImage
    src={imgUrl}
    alt="Thumbnail of article"
    width="320"
    height="200"
    layout="responsive"
    placeholder="blur"
    blurDataURL={blurDataURL}
/>
...

그 결과물은 아래와 같습니다.

이미지가 로드되는 동안에는 blur 처리된 이미지가 임시로 나오는 모습입니다. 덕분에 이미지를 로드해오는 동안 유저는 비어있는 칸을 보지 않아도 됩니다.

참고 : 웹용 브라우저 수준 이미지 지연 로딩


3.이미지 리사이징하기

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

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

그럼, 이미지를 리사이징 하는 방법에 대해서 알아보겠습니다.
일반적으로는 다음과 같이 img 태그에 단일한 src를 넣어줍니다.

<img src="pikachu-800w.jpg" alt="잠자는 피카츄">

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

<img srcset="pikachu-320w.jpg 320w,
             pikachu-480w.jpg 480w,
             pikachu-800w.jpg 800w"
     sizes="(max-width: 320px) 280px,
            (max-width: 480px) 440px,
            800px"
     src="pikachu-800w.jpg" alt="잠자는 피카츄">

각각에 대해서 알아보겠습니다.

  • srcset는 브라우저에게 어떤 크기의 이미지를 보여주면 되는지 알려주는 역할을 합니다. 각각의 이미지의 크기도 함께 정의하면서 보여줍니다. 작성방법은 다음과 같습니다.
    1. 이미지 파일명
    2. (공백 다음에) 이미지 고유 픽셀 너비 w (w는 width를 의미합니다)

  • sizes는 미디어 조건문을 설정합니다. 그래서 특정 화면에서 어떤 크기가 최적인지를 나타냅니다. 작성방법은 다음과 같습니다.
    1. 미디어 조건문 (max-width:320px)
    2. 미디어 조건문이 참일 때, 이미지가 채울 슬롯의 너비

이런 속성들을 명시했을 때, 브라우저에서 일어나는 일의 순서는 다음과 같습니다.

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

외에서 sizes속성을 사용하지 않고, x 서술자로 표현하는 방법도 있습니다.

<img srcset="pikachu-320w.jpg,
             pikachu-480w.jpg 1x,
             pikachu-800w.jpg 2x"
     src="pikachu-800w.jpg" alt="잠자는 피카츄">

x서술자 같은 경우에는 css픽셀이 기기의 1픽셀에 대응되는지 여부를 나타냅니다. 만약 css픽셀과 기기의 픽셀이 대응된다면 pikachu-320w.jpg가 화면에 나타날 것입니다. 반면, 기기의 2픽셀이 css의 1픽셀에 대응되는 고해상도의 기기라면, pikachu-800w.jpg가 화면에 나타날 것입니다. 일반적으로는 x 서술자보다는 sizes를 더 많이 사용하는듯합니다.

😎 나는 어떻게 적용했나?

그렇다면 저는 이 이미지 리사이징을 어떻게 적용했을까요? 제가 한 것은 아무것도 없습니다. 이 역시 next/image에서 기본적으로 제공해주는 기능이었습니다 🫠 property로 layout에서 responsive 혹은 fill을 사용하면됩니다. 해당 프로퍼티를 적용하고 이미지를 로드하면 개발자 탭에서 다음과 같은 모습을 볼 수 있습니다.

자동으로 srcset이 지정되어 있는 모습을 볼 수 있습니다. 각각의 값은 next.config.js에 default로 지정되어 있는 값들입니다. 공식문서에 소개되어있습니다. 아래의 값들은 따로 설정해줄 필요가 없으며 default로 지정되어 있는 값입니다.

module.exports = {
  images: {
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
  },
}

만약 추가적인 이미지 사이즈를 제공하고 싶다면, next.config.js에 이미지 크기를 넣어주면 됩니다.

예를 들어서, 700과 800을 넣겠다고 하면 다음과 같이 넣으시면 됩니다.

  images: {
	...
    imageSizes: [700, 800],
  },

MDN-반응형이미지
Next.js image optimization techniques
Creating responsive images with image-set
HTML IMG의 srcset과 sizes 속성(updated)
riiid-이미지 리사이징


4.이미지 용량 압축하기

이미지의 용량은 성능에 많은 영향을 미칩니다. 때문에 가능하면 용량이 적은 이미지를 사용하는 것이 좋겠지만, 마음대로 되는 일이 아닙니다. 그렇다면 용량이 큰 경우에는 용량을 압축시켜주는 방법이 있습니다. 저 또한 이미지를 업로드하는 경우에 이미지를 압축한 후 업로드했습니다. 제가 압축을 하기 위해 선택한 방법은 browser-image-compression라이브러리를 활용하는 것입니다. 주간 다운로드수가 8만이 넘어가고, 6개월전에 최근 업데이트한 것을 보니, 주기적으로 관리를 해주고 있는 것 같았습니다.

사용법은 무척 간단합니다. 이미지 파일과 옵션을 지정해서 넣어주면됩니다.

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);
};

위와 같이 설정해서 압축한 후 aws에 업로드했습니다. 기존에 제가 올린 이미지 파일의 용량은 약 2.8MB였습니다.

압축이 잘 되었는지 aws의 s3버킷에서 확인해봤습니다. 154.2KB까지 압축된 것을 확인할 수 있습니다.

이렇게 많이 압축이 되었다면, 화질이 많이 저하되지는 않았을지가 걱정입니다. 그래서 한번 확인을 해봤습니다.

왼쪽이 본래 이미지고, 오른쪽이 압축되어 s3에 올라간 이미지입니다. 다행히 화질차이가 크게 나지 않는 것 같습니다. 화질에도 문제가 없고, 성공적으로 압축된 것 같습니다.


5.이미지 캐싱하기

캐싱이랑 쉽게말하면, 가까운 곳에 데이터를 임시로 보관하는 것입니다. 그렇게 함으로써 다음번에 똑같은 데이터를 가져올 때 먼 곳까지 데이터를 가지러 안가도 되도록, 혹은 반복된 연산을 하지 않도록 성능을 최적화하는 기법 중 한 가지입니다. 다른 리소스에 비해 꽤 큰 용량을 차지하는 이미지의 경우, 캐싱을 해두면 눈에 띄게 성능을 향상시킬 수 있습니다. 때문에 이미지를 최적화하고자 한다면, 이미지 캐싱 또한 반드시 고려해야만 하는 요소입니다. 그 전에 먼저 캐싱을 하는 방법부터 알아보겠습니다.

헤더를 통한 caching :

HTTP/1.1버전부터 cache-control 및 expire, validation 등등의 기능이 지원되면서 캐싱을 효율적으로 할 수 있게 되었습니다. 캐싱을 할 때는 기본적으로 2가지 전략이 있습니다. mutable한 리소스와 immutable한 리소스에 대해서 각각 다른 전략을 취하는 것입니다.

mutable한 리소스 :

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

Cache-Control에 no-cache를 설정합니다. no-cache는 오해하기 쉽지만, 캐싱을 하지 않겠다는 말이 아닙니다. 서버에게 새로운 컨텐츠가 있는지를 묻는 역할을 합니다. 그래서 새로운 컨텐츠가 있다면 그것을 다운로드 합니다. 이 directives는 캐싱되어있는 데이터를 사용하면서도, 데이터에 변경이 있는지 계속해서 확인하려고 할 때 사용합니다. 만약 캐싱을 하지 않기를 원한다면, no-store를 명시해주면 됩니다.

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

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

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

이것이 바로 mutable한 리소스에 대한 캐싱전략입니다. 다음으로는 immutable 리소스에 대한 캐싱 전략을 알아보겠습니다.

immutable한 리소스 :

immutable한 리소스에는 어떤 것들이 있을까요? 바로 이미지 파일입니다. 저희는 이미지 파일을 내부적으로 변경시킬 일이 없습니다. 또한 파일이름을 통해서 버전관리를 하는 js 파일이나, 여타 html, css 파일도 immutable한 리소스에 해당할 수 있습니다. app_v1.js 파일 같은 경우에는 그 내부에 변경사항이 없을 것입니다. 만약 변경사항이 생겼다면, 아예 새로운 파일을 만들고 그 파일에 app_v2.js라고 이름을 붙여야 할 것입니다. 이렇게 내부내용에 대해서 변경사항이 없는 리소스를 향해서 immutable한 리소스라고 합니다. 그리고 저렇게 리소스의 url에 버전정보를 붙이고, 각 url에 대해선 내용물에 변경이 없도록 하는 방법을 cache-busting 패턴이라고 부르기도 합니다. 그럼 이런 immutable한 리소스에 대한 캐싱전략을 알아봅시다.

Cache-Control에 max-age를 설정해줍니다. 저희는 유저가 이미지 데이터를 처음 한 번만 다운받고 그 이후로는 다시 다운받게 하고 싶지 않습니다. 때문에 max-age의 길이를 31536000로 설정해줍니다. 이것은 1년을 나타내는 숫자입니다. 31536000초라는 말인데, 31536000초가 1년이라는 것은 이번에 처음 알았습니다. 아무튼 이렇게 설정을 해두면, max-age가 될 때까지 해당 리소스를 캐싱해두겠다는 의미가 됩니다. max-age를 사용하는것이 권장되지만, max-age외에도 캐싱 기간을 설정할 수 있는 방법이 있습니다. expires 헤더를 사용하는 것입니다. max-age 같은 경우에는 어느정도 시간동안 캐싱을 하겠다고 나타낸다면, expires 같은 경우에는 특정 날짜까지 캐싱을 하겠다고 명시합니다.

immutable한 리소스의 경우 이런 방식으로 캐싱합니다. 저희는 현재 이미지를 다루고 있기 때문에 immutable한 방식에서 사용하던 전략을 채택해야합니다.

+추가적인 캐싱 전략 :

추가적인 캐싱전략이 있습니다. 토스 블로그에서 소개된 내용입니다. 캐시 데이터가 저장되는 위치는 크게는 유저의 브라우저, CDN서버가 있습니다. 경우에 따라 저희는 브라우저에는 데이터를 캐싱하지 않고, CDN서버에만 데이터를 캐싱하고 싶을 수도 있습니다. 토스에서는 이런 섬세한 작업을 하기 원했던 것 같습니다. 이 경우에는 max-age와 s-maxage를 함께 설정하고, s-maxage에는 캐싱하기 원하는 시간을 입력하고, max-age에는 0을 입력하면 됩니다.
s-maxage를 조금 설명할 필요가 있을 것 같은데, 기본적으로 max-age와 비슷합니다. 차이점이 있다면, shared cache 데이터에 대해서 max-age를 지정해줍니다. 그러니까 CDN처럼 공유되는 캐시 데이터에 대해서 캐싱하기 원하는 시간을 설정하는 것입니다.
만약 s-maxage=600 max-age=0 이렇게 지정을 해줬다면, CDN에서는 10분동안 데이터를 저장하고, max-age에서는 매번 데이터가 변경되었는지 검증해주겠다는 것입니다. 이렇게 섬세하게 캐싱할 위치를 지정하는 방법도 있다는 것을 소개해드리고 싶었습니다.

웹서비스 캐시 똑똑하게 다루기

메모리 캐시와 디스크 캐시 :

실제로 어떻게 캐싱을 적용했는지 알아보기 전에, 메모리 캐시와 디스크 캐시에 대해서 알아보겠습니다. 실제로 네트워크 탭을 통해서 캐싱이 잘되고 있는가를 확인하면, (disk cache) 혹은 (memory cache)라고 적혀있는 것을 볼 수 있습니다.

보시면 memory cache 같은 경우엔 리소스를 다운 받는데 걸린 시간이 0ms, disk cache 같은 경우엔 리소스를 다운 받는데 걸린 시간이 2ms 혹은 1ms인 것을 볼 수 있습니다. 이 둘은 어떤 차이가 있을까요?

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

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

아마 브라우저를 껐다가 다시 켰을 때 네트워크 탭을 확인해보면 모든 데이터를 disk cache에서 가져오는 것을 알 수 있습니다. 그 상태에서 한번 더 새로고침을 하면 몇몇 데이터들은 memory cache에서 가져오기 시작할 것입니다. 어떤 데이터를 memory cache에 올려놓지 않는지는 잘모르겠으나, 브라우저 입장에선 Memory의 공간을 아껴야 할 것이고, 비교적 빠르게 가져와야 하는 데이터를 memory cache에 올려놓지 않을까 추측을 해봅니다.

🫠 나는 어떻게 적용했나?:

그럼 저는 어떻게 nextjs에서 이미지를 캐싱했을까요? 사실 이 또한 제가 한 일이 없습니다.. nextjs에서 이미 이미지까지 캐싱하도록 다 지원을 하고 있었습니다. 기본적으로 static 폴더에 들어가있는 모든 파일들은 자동으로 캐싱됩니다. 저는 vercel 서버를 이용중인데, 이것을 이용하는 경우 최대 31일까지 캐싱해둡니다. 또한 동적으로 불러오는 이미지들의 경우에도 캐싱이 되고 있었습니다. nextjs의 폴더에 가면 .next/cache라는 폴더를 볼 수 있습니다. 그곳에서 /images 부분을 들어가면, 캐싱되어있는 external 이미지들을 확인할 수 있습니다. 요청했던 사이즈별로 이미지가 캐싱되어있더군요.

이미지 외에도 getStaticProps를 통해서 얻어오는 데이터들은 빌드시에 이미 cdn에 캐싱됩니다. getServerSideProps 같은 경우도 기본적으로 캐싱이 지원됩니다. 만약 커스텀하고 싶다면, 그것을 위한 방법도 제공됩니다. 공식문서에 보면 나와있습니다.

그래서 저는 캐싱이 잘되는지 확인하기 위해서 사이트를 열어 확인해보았습니다.
production 환경에서 external image는 아래와 같이 cache-control이 설정되어있습니다.

필요한 부분만 뽑아내면 다음과 같습니다.

cache-control: public, max-age=630720000
...
x-vercel-cache: HIT

또한 production 환경에서 static image는 아래와 같이 cache-control이 설정되어있습니다.

static 이미지는 조금 차이가 있네요.

cache-control: public,max-age=31536000,immutable
...
x-matched-path: /_next/image
x-vercel-cache: HIT

여기서 x-vercel-cache: HIT는 캐시가 적중했음을 의미합니다. MISS,STALE 등의 상태도 존재합니다. 공식문서를 참고해보세요.
헤더 각각의 의미를 조금만 더 살펴보겠습니다. public은 캐싱을 어디에 할 것인지를 설정합니다. public이라고 설정한 경우 브라우저에도 캐싱을 하고, CDN처럼 캐싱데이터를 공유할 수 있는 곳에도 캐싱을 하겠다는 의미가 됩니다. private이라고 설정한 경우는 유저의 브라우저에만 캐싱하겠다는 의미가 될 것입니다.
max-age는 위에서 설명했듯이, 몇 초의 시간 동안 캐싱된 데이터를 fresh하다고 인지할 것인지를 지정합니다. static image같은 경우에는 뒤에 immutable 도 붙어있습니다. 이것이 의미하는 바는 다음과 같습니다. "이 친구는 데이터가 내부적으로 변경될 일이 없어. 그러니까 revalidation처럼 불필요한 요청은 하지마"

결론 :

nextjs는 정말 개발자에게 친절한 프레임워크라는 것을 다시 한 번 느꼈습니다. 공식문서도 친절한 편인데, 여러 최적화 전략까지 이미 다 갖추고 있었습니다. 덕분에 제가 할 일은 조금 사라졌지만요..(nextjs 다해먹어라!) 그렇다고 아무것도 안 할 수는 없어서, 각각의 이미지 최적화 전략은 무엇이고, nextjs는 어떻게 이미지를 최적화해주는지 알아보는 시간을 가져보았습니다. 혹시 잘못된 내용이 있다면 알려주시면 감사하겠습니다.

참고자료 :
MDN - cache control
http-caching
A Quick Start Guide to Frontend Caching
A Web Developer’s Guide to Browser Caching
Next.js image optimization techniques

profile
맛있는 세상 shdpcks95@gmail.com

10개의 댓글

comment-user-thumbnail
2022년 10월 12일

안녕하세요, 좋은 내용 공유 감사합니다!

이미지 레이지 로딩 관련 질문이 있습니다. 말씀해주신 코드 내용 내용처럼 Next의 Image 컴포넌트를 사용했을 경우,

  <CustomImage
    src={imgUrl}
    alt="Thumbnail of article"
    width="320"
    height="200"
    layout="responsive"
    placeholder="blur"
    blurDataURL={blurDataURL}
/>

네트워크 탭에서 확인했을 때 두 이미지(1.Next Image 로 처리된 .avif 이미지, 2. 블러를 위한 원본(.jpg)이미지) 를 불러오기도 하나요?

제가 느낀 placeholder="blur" blurDataURL={...} 프로퍼티의 문제점이 blur 처리하는 과정에서 s3 의 원본 데이터를 src={} 에 넣는 것처럼 blurDataURL={}에 해당 경로를 아래와 같이 그대로 넣을 경우,

  <CustomImage
    src={imgUrl}
    alt="Thumbnail of article"
    width="320"
    height="200"
    layout="responsive"
    placeholder="blur"
    blurDataURL={imgUrl}
/>

Next/Image 에 의해 처리된 확장자(.avif)의 이미지가 아닌 원본 이미지(.jpg)를 불러오는데, 이로 인해 결과적으로 용량 압축의 의미가 없었습니다.

작성자님의 방식대로 Plaiceholder 라이브러리를 사용하기 전이지만, 이미 적용하신 부분이 궁금하여 질문 남깁니다!

1개의 답글
comment-user-thumbnail
2022년 10월 15일

안녕하세요! 혹시 vercel 서버를 이용하게 되면 next/image를 적용했을때 캐싱되기 전 첫 렌더시에 용량이 작은 이미지들을 불러오는데 500ms~1s 정도 걸리는 현상을 겪어보신적 있으신가요?? 겪어보셨다면 이 부분에 대한 해결방법이나 이유를 아신다면 공유부탁드리겠습니다!

1개의 답글
comment-user-thumbnail
2024년 3월 5일

Next 이미지 최적화 찾고있었는데 도움많이되었습니다 !!

답글 달기