[nextjs] 최소한의 이미지 최적화하기

pds·2023년 5월 12일
20

TIL

목록 보기
56/60

Nextjs에서의 Image 사용에 있어 최소한의 최적화 시도는 해봐야지 않겠나라는 마음가짐으로 공부하고 적용한 과정을 기록해나가고 있다.


Sharp library 설치하기

nextjs 앱을 빌드할 때 sharp 라이브러리를 설치하지 않았다면 (13.2 기준) 다음과 같은 경고메시지가 식별된다.

Warning: For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended. Run 'yarn add sharp', and Next.js will use it automatically
for Image Optimization.

기본으로 동작하는 다른 로더 보다 성능이 좋다고 권장하니 설치하자

yarn add sharp

최적화 전 알아본 것

nextjs 에서는 기본적으로 next/image 컴포넌트를 사용하면 이미지 최적화를 해준다.

포맷 및 사이즈 변환

Automatically serve correctly sized images for each device, using modern image formats like WebP and AVIF.

lazy loading

Images are only loaded when they enter the viewport using native browser lazy loading, with optional blur-up placeholders.

기본적으로 lazy-load 설정으로 되어있으며 eager 옵션이나 priority 설정으로 해제할 수 있다.

Visual Stability

Prevent layout shift automatically when images are loading

외부이미지에 대해서도 width, height 옵션을 강제하여 정확한 이미지 비율을 계산해서 로딩중일 때도 layout shift가 발생하지 않게 한다.

그럼 따로 최적화 할 필요 없는 것 아님??

이라고 사실 생각하고 있었는데 다음과 같은 성능 진단 경고를 확인할 수 있었다.

목록 썸네일 카드로 사용되는 이미지들인데 사이즈가 적절하지 않다고 한다.

대략 이런 느낌으로 썸네일 이미지로 사용되는 페이지를 진단했을 때의 결과이다.

사실 일부러 실제로 보여지는 사이즈에 비해 매우 큰 이미지들을 넣었다.

대용량의 이미지들을 로드하다보니 다음과 같은 진단도 있었다.

왜 그럴까?

next imagewidth, height에 맞게 알아서 이미지를 조정해주지만 fill 속성을 사용할 경우 그렇지 않다.

실제로 위 카드의 thunbmail 컴포넌트의 경우 사이즈가 고정된 경우도 있지만 아닌 경우에도 사용하고 있고 사이즈가 고정되어있어도 의도하지 않은 비율의 이미지가 뭉개지는 일을 방지하기 위해 width height를 지정해둔 것이 아닌

fill propsobject-fit: cover; 옵션을 사용하고 있었다.

사이즈가 고정된 경우에서의 썸네일의 경우 의도와 너무 다른 실제 이미지의 크기일 경우에는 이미지의 윗부분만 제대로 보여주는 형태였다.

벨로그도 이미지 사이즈가 썸네일이랑 다를 경우에도 강제로 맞춰주는 것이 아니라 중간부분만 짤라서 보여진다!


layout: fill

A boolean that causes the image to fill the parent element instead of setting width and height.

The parent element must assign position: "relative", position: "fixed", or position: "absolute" style.

By default, the img element will automatically be assigned the position: "absolute" style.

아니 부모 크기에 맞춰서 잘 보여지는거면 문제 없는 것 아닌가? 라고 생각할 수 있지만 공식문서를 확인했을 때 간과했던 점이 있다.

sizes props

the value of sizes is used by the browser to determine which size of the image to download, from next/image's automatically-generated source set.
When the browser chooses, it does not yet know the size of the image on the page, so it selects an image that is the same size or larger than the viewport.
If you don't specify a sizes value in an image with the fill property, a default value of 100vw (full screen width) is used

sizes 속성은 어떤 사이즈의 이미지를 로드할지 뷰포트를 기준으로 기본으로 생성된 source set으로 부터 가져와 로드한다.

막줄이 핵심인데 따로 설정하지 않을 경우 기본적으로 100vw, 즉 뷰포트 전체 width로 인식해 가져온다는 것이다.

아까 그 썸네일 목록 페이지를 생각해보면 썸네일로 사용될 이미지의 크기는 320px 남짓이었다.

하지만 기본적으로 따로 width, height를 설정하지 않고 fill props를 사용해 로드하면 원본 이미지 사이즈에 따라 최대 뷰포트 만큼의 이미지를 가져오는 것이다.

webp로 변환되어서 실제 사이즈만큼은 아니지만 어마어마한 사이즈의 이미지를 로드하느라 캐시를 끄면 체감으로도 로드가 매우 느렸고 lighthouse에서도 경고를 보여줬던 것이다.


sizes 최적화하기

문서에도 fill 속성을 사용할 때 sizes를 설정하는 것은 큰 퍼포먼스 향상을 기대할 수 있다고 명시되어있다.

위의 카드 목록 썸네일 이미지의 경우 뷰포트가 클 때 오히려 뷰포트에 비해 이미지 사이즈가 작기 때문에 최적화하기 딱 좋은 상황이라고 생각된다.

default srcSet

기본적으로 사이즈가 고정되어있으면 1x,2x 두개의 srcSet을 생성해 사용한다.

하지만 fill 속성일 경우 기본적으로 설정되는 srcSet을 사용해 뷰포트를 가지고 인식해 로드한다.

deviceSizes

디바이스 breakpoint 기준점

deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840]

imagesSizes

이미지 너비 목록으로 deviceSizes와 연결되어 전체 srcSet을 형성한다.

imageSizessizes props이 사용될 때 deviceSizes` 보다 작음을 나타낼 때의 이미지에만만 사용된다고 한다.

imageSizes: [16, 32, 48, 64, 96, 128, 256, 384]

imageSizesdeviceSizes는 지나치게 많다고 느껴지거나 추가적인 breakpoint가 필요하다면 next.config.js에 커스텀해서 사용할 수 있다.


최적화해보자

문제의 Thumbnail 컴포넌트에 sizes 설정을 해보자

      <Image
        src={src}
        fill
        alt={altText}
        onError={onError}
        sizes="(max-width: 732px) 90vw, (max-width: 992px) 45vw, 320px"
      />

breakpoint를 기본 srcSet과 무관하게 설정했는데
breakpoint에 따라 지정된 사이즈를 사용하게끔 요청하지만
반드시 위의 사이즈대로 이미지를 로드하는 것은 아닌 것에 주의해야 한다.

예를 들어 뷰포트가 730px이라고 치자.

730px => 90vw = 657px 인 것이 아니다!

지정된 srcSet에서 해당 사이즈보다 크지만 가장 작은 사이즈를 찾아 그만큼의 사이즈를 로드한다.

기본 설정이라면 750으로 로드한다.

예를 들어 뷰포트가 750px이라고 치자.

750px => 45vw = 337.5px이기 때문에 imageSize384를 사용하여 이미지를 로드한다.

커스텀한 브레이크 포인트를 사용하는 것은 상관없지만 실제로 뷰포트에 따라 어떤 사이즈의 이미지가 로드될지를 잘 생각해서 srcset을 커스텀 해야할 것 같다.

해당 썸네일 목록 페이지의 경우 1000px 이상의 환경에서는 무조건 320px의 이미지가 업로드되도록 의도했지만 최소 imageSize는 384px이기에 원본이 384px보다 큰 이미잘면 384px의 이미지가 로드될 것이다.


이런식으로 필요한 것들을 넣고 불필요한 것들은 좀 제거해줘야할 것 같다.

사실 아직까지는 어느 정도의 breakpoint가 얼마나 필요할지는 판단되지 않지만 우선 필요한 것들을 추가해보자.

콤퓨타 뷰포트에서의 썸네일 이미지는 이제 어느정도 의도한대로 최적화되었다고 할 수 있을 것이다.


결과보기

1920px 뷰포트 기준으로 전/후 이미지 로드 사이즈나 속도를 비교해보았다.


적용 전


적용 후

로드 속도의 경우는 사실 개선되었다고 하기에는 표본이 적고 캐시를 끄고 해서 S3에 요청할 때 마다 좀 달랐기에 크게 의미가 없다고 생각했다.

정확할지는 모르겠지만 다 회 테스트 해본 결과 LCP도 1초 이상 줄어들었다고 볼 수 있다. 표본이 많았더라면 좀 더 의미있는 시간 차이가 있지 않았을까 싶다.

하지만 로드하는 파일 사이즈의 경우는 매우 개선되었다.

용량이 가장 컸던 이미지의 경우 968kb에서 40.7kb로 개선되었다.

사실 원본이 거의 4MB에 4096px인 이미지라 극단적인 감이 있지만 그래도 다운로드 사이즈와 속도에 안정성을 줄 수 있다는 것이 의미있는 것 같다.


Placeholder Image

placeholder

A placeholder to use while the image is loading. Possible values are blur or empty. Defaults to empty.

기본으로 empty로 설정되어있다. empty로 설정되어도 width, height 값이 있으면 레이아웃 시프트는 발생하지 않는 듯 하지만 공백의 화면이 이미지를 로드하기까지 보여진다.

blur 옵션을 사용하면 정적 이미지의 경우 알아서 blur image를 보여주며 외부이미지의 경우 blurDataURL에 지정된 base64 이미지를 지정해주어야 한다.

왜 쓸까?

이미지를 불러올 때 용량이 크고 로딩 시간이 오래 걸릴 경우, 페이지의 성능이 저하될 수 있다.

이때 이미지에 placeholder를 사용하면 이미지 로딩 전에 빈 칸에 이미지의 위치와 크기를 차지하면서 로딩 시간을 줄일 수 있다.

또한, placeholder를 사용하면 사용자가 이미지를 보기 전에 이미지가 로드되기 전까지 레이아웃이 깨지는 것을 방지할 수 있다.

따라서 placeholder를 사용함으로써 페이지의 사용성을 향상시키고, 사용자 경험을 개선할 수 있다.


위의 썸네일과 비슷하지만 높이가 모두 다른, masonry layout으로 되어있는 리스트 페이지가 있었는데 fill 속성을 사용하지만 높이를 따로 지정해두지 않았기 때문에 실제로 레이아웃 시프트가 발생하고 있었다.


해결하기 - Plaiceholder 사용

blur image를 만들어 이미지가 로드되기 전에 보여지게 해보자

blur image란

이미지의 실제 컨텐츠를 일부 가려서 해당 이미지를 흐리게 만드는 작업입니다. 이 기술은 이미지를 불러오는 데 걸리는 시간 동안 사용자가 페이지를 이용할 수 있도록 하며, 이미지가 로드될 때까지 사용자가 대기하지 않도록 합니다.

왜 Plaiceholder 사용?

  • nextjs 공식문서에 언급되어있다.
  • 내부적으로 sharp 라이브러리를 사용하는데 sharp라이브러리 사용을 next에서 권장하고 있음

Under-the-hood, Plaiceholder uses the wonderful and powerful sharp library to transform images.
Some frameworks or libraries include sharp by default, so double-check before you install.


시작하기

yarn add plaiceholder
yarn add @plaiceholder/next

next.config.js에 설정 추가

const { withPlaiceholder } = require('@plaiceholder/next');
const nextConfig = {
  // ...
}
module.exports = withPlaiceholder(nextConfig);

사용하기

node 환경에서 사용되게 의도된 라이브러리로 브라우저에서는 사용하지 말라고 한다.

Plaiceholder is a Node.js library. It's designed only to work in a Node.js environment, not the browser.

예시나 구현도 모두 getStaticProps 또는 getServerSideProps에서 적용한 사례만 있다.

클라이언트에서 무한스크롤로 추가적인 로딩을 할 때는 다른라이브러리로 처리한다던가 하는 방법을 생각해봐야 할 것 같다.

그 외 사용법은 매우 쉽고 사용할만한 메서드도 getPlaiceholder 하나라 문서만 확인해보면 된다.

비동기로 호출하며 여러 값들이 리턴되는데 여기서 확인해보면 된다.

  await queryClient.fetchInfiniteQuery(
    [queryKey.mecas, categoryId],
    async () => {
      const mecaList = await getMecaList(categoryId, isMine, queryClient);
      const mecaListContentsWithBlurURL = await Promise.all(
        mecaList.contents.map(async (meca) => {
          const thumbnail = extractFirstImageSrc(meca.description);
          if (!thumbnail) {
            return meca;
          }
          const { base64: blurThumbnail } = await getPlaiceholder(thumbnail);
          return { ...meca, blurThumbnail };
        }),
      );
      return {
        ...mecaList,
        contents: mecaListContentsWithBlurURL,
      };
    },
    {
      getNextPageParam: (lastPage) => lastPage.hasNext,
    },

목록 데이터를 prefetch하고 썸네일 이미지를 찾아 base64URL을 얻어 캐시에 저장하고 이를 클라이언트가 사용하게 의도했다.

테스트하기

jest에서 테스트해보았다. 따로 mocking하지 않아도 실제로 잘 돌아가지만 mocking해서 thumbnail 이미지가 있는 만큼 해당 함수를 호출하는지 정도만 테스트했다.

import { getPlaiceholder } from 'plaiceholder';

jest.mock('plaiceholder', () => ({
  getPlaiceholder: jest.fn(),
}));

it('test', async () => {
      (getPlaiceholder as jest.Mock).mockImplementationOnce((img) => ({
        base64: img + '-base64',
        img: { src: img },
      }));
      // 테스트 코드 생략
      expect(getPlaiceholder).toHaveBeenCalledTimes(1);
});

처리를 해보니 blur image의 경우 잘 식별되나 해당 이미지의 경우 fill 속성을 사용하지만 부모 엘리먼트에서 높이를 따로 지정하지 않았고최소 높이만 지정했기에 딱 최소 높이만큼만 blur image가 적용되는 모습이다.


const { base64: blurDataURL, img } = await getPlaiceholder(thumbnail, { size: 12 });

blurImage를 만들 때 실제 이미지 정보인 img: ILoadImageImg를 받아와 width, height를 가져오고

aspect-ration를 활용해 fill-cover 이미지의 부모 엘리먼트에서 적절한 높이와 너비를 가지도록 설정했다.

const CardThumbnailWrapper = styled.div<{ ratioWidth: number; ratioHeight: number }>`
  position: relative;
  overflow: hidden;
  aspect-ratio: ${(props) => `calc(${props.ratioWidth} / ${props.ratioHeight})`};
  & > img {
    object-fit: cover;
  }
`;
const CardThumbnail = ({ src, altText, preloadedInfo, onError }: CardThumbnailProps) => {
  const { ratioWidth, ratioHeight } = getRatioSize(preloadedInfo);
  return (
      <CardThumbnailWrapper ratioWidth={ratioWidth} ratioHeight={ratioHeight}>
        <Image
          src={src}
          fill
          alt={altText}
          onError={(e) => {
            if (onError) {
              onError();
              return;
            }
            const imageElement = e.target;
            (imageElement as HTMLImageElement).src = '/images/noimage.png';
          }}
          blurDataURL={preloadedInfo?.blurDataURL ?? THUMBNAIL_BLUR_URL}
          placeholder="blur"
          sizes={`${MEDIA.mobile} 92vw, (max-width: 632px) 92vw, ${MEDIA.tablet} 46vw, 320px`}
        />
      </CardThumbnailWrapper>
  );
};

slow 3g로 테스트해서 매우 느려서 blur image가 잘 보인다.

좀 많이 흐릿한 감이 있지만 초기 페이지 로드 시 레이아웃 시프트가 전혀 없을 것이다!

로드중에 빈 화면을 보여주는 것보다 훨씬 자연스러워 진 것 같다.

걱정되는 것은 썸네일 목록을 보여주는 페이지가 초기에 로드될 때 최대 12개의 카드가 보이는데 12번 base64 이미지를 생성하게 된다.

SSG 페이지는 빌드할 때 처리해서 괜찮을 것 같지만 SSR 페이지에서 응답 속도에 큰 영향을 줄 것 같은데 해당 문제가 있을지 파악해봐야할 것 같다.


두번째 문제는 사이즈가 고정된 썸네일의 경우는 스켈레톤이나 로컬 blur 이미지를 보여주면 되는데

위와 같이 사이즈가 고정되지 않은 썸네일의 경우 사용자가 스크롤을 내려서 새로운 데이터를 클라이언트 쪽에서 패치하여 목록이 추가적으로 보여질 때는 어떤 처리가 없는 상태라는 것이다.

고민해보니 서버사이드 렌더링 페이지 내에서 blur 이미지 처리를 직접 하지 않고 blur 이미지 처리가 필요한 목록 조회 api를 api route로 감싸 사용하여 클라이언트 쪽에서도 사용할 수 있게 한다던가

이미지 등록 시점에 blur 이미지를 생성해 데이터베이스에 함께 관리되도록 하고 이를 사용한다던가 어떻게든 방법을 찾을 수는 있을 것 같다.


TODO LIST

  • Next/Image sizes 설정하기
  • blur 이미지 사용으로 사용자 경험 개선해보기
  • avif 확장자는 무엇일까??
  • lazy, eager(priority) 설정, 어떤 상황에 사용해야 할까

References

profile
강해지고 싶은 주니어 프론트엔드 개발자

2개의 댓글

comment-user-thumbnail
2024년 4월 23일

plaiceholder 사용시 next.config를 mjs 파일로 바꿔야 해서 require를 못 사용한다고 되어있는데, require가 동작하나요? next.config 확장자를 어떻게 사용하고 계신지 궁금합니다

1개의 답글