Nextjs
에서의 Image
사용에 있어 최소한의 최적화 시도는 해봐야지 않겠나라는 마음가짐으로 공부하고 적용한 과정을 기록해나가고 있다.
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 image
는 width
, height
에 맞게 알아서 이미지를 조정해주지만 fill
속성을 사용할 경우 그렇지 않다.
실제로 위 카드의 thunbmail
컴포넌트의 경우 사이즈가 고정된 경우도 있지만 아닌 경우에도 사용하고 있고 사이즈가 고정되어있어도 의도하지 않은 비율의 이미지가 뭉개지는 일을 방지하기 위해 width
height
를 지정해둔 것이 아닌
fill
props
와 object-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
에서도 경고를 보여줬던 것이다.
문서에도 fill
속성을 사용할 때 sizes
를 설정하는 것은 큰 퍼포먼스 향상을 기대할 수 있다고 명시되어있다.
위의 카드 목록 썸네일 이미지의 경우 뷰포트가 클 때 오히려 뷰포트에 비해 이미지 사이즈가 작기 때문에 최적화하기 딱 좋은 상황이라고 생각된다.
default srcSet
기본적으로 사이즈가 고정되어있으면 1x,2x 두개의 srcSet을 생성해 사용한다.
하지만 fill
속성일 경우 기본적으로 설정되는 srcSet을 사용해 뷰포트를 가지고 인식해 로드한다.
deviceSizes
디바이스 breakpoint 기준점
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840]
imagesSizes
이미지 너비 목록으로 deviceSizes
와 연결되어 전체 srcSet
을 형성한다.
imageSizes
는 sizes props
이 사용될 때 deviceSizes` 보다 작음을 나타낼 때의 이미지에만만 사용된다고 한다.
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384]
imageSizes
와 deviceSizes
는 지나치게 많다고 느껴지거나 추가적인 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
이기 때문에 imageSize
의 384
를 사용하여 이미지를 로드한다.
커스텀한 브레이크 포인트를 사용하는 것은 상관없지만 실제로 뷰포트에 따라 어떤 사이즈의 이미지가 로드될지를 잘 생각해서 srcset
을 커스텀 해야할 것 같다.
해당 썸네일 목록 페이지의 경우 1000px 이상의 환경에서는 무조건 320px의 이미지가 업로드되도록 의도했지만 최소 imageSize
는 384px이기에 원본이 384px보다 큰 이미잘면 384px의 이미지가 로드될 것이다.
이런식으로 필요한 것들을 넣고 불필요한 것들은 좀 제거해줘야할 것 같다.
사실 아직까지는 어느 정도의 breakpoint가 얼마나 필요할지는 판단되지 않지만 우선 필요한 것들을 추가해보자.
콤퓨타 뷰포트에서의 썸네일 이미지는 이제 어느정도 의도한대로 최적화되었다고 할 수 있을 것이다.
결과보기
1920px 뷰포트 기준으로 전/후 이미지 로드 사이즈나 속도를 비교해보았다.
적용 전
적용 후
로드 속도의 경우는 사실 개선되었다고 하기에는 표본이 적고 캐시를 끄고 해서 S3에 요청할 때 마다 좀 달랐기에 크게 의미가 없다고 생각했다.
정확할지는 모르겠지만 다 회 테스트 해본 결과 LCP도 1초 이상 줄어들었다고 볼 수 있다. 표본이 많았더라면 좀 더 의미있는 시간 차이가 있지 않았을까 싶다.
하지만 로드하는 파일 사이즈의 경우는 매우 개선되었다.
용량이 가장 컸던 이미지의 경우 968kb
에서 40.7kb
로 개선되었다.
사실 원본이 거의 4MB에 4096px인 이미지라 극단적인 감이 있지만 그래도 다운로드 사이즈와 속도에 안정성을 줄 수 있다는 것이 의미있는 것 같다.
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
속성을 사용하지만 높이를 따로 지정해두지 않았기 때문에 실제로 레이아웃 시프트가 발생하고 있었다.
blur
image를 만들어 이미지가 로드되기 전에 보여지게 해보자
blur image란
이미지의 실제 컨텐츠를 일부 가려서 해당 이미지를 흐리게 만드는 작업입니다. 이 기술은 이미지를 불러오는 데 걸리는 시간 동안 사용자가 페이지를 이용할 수 있도록 하며, 이미지가 로드될 때까지 사용자가 대기하지 않도록 합니다.
왜 Plaiceholder 사용?
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 이미지를 생성해 데이터베이스에 함께 관리되도록 하고 이를 사용한다던가 어떻게든 방법을 찾을 수는 있을 것 같다.
plaiceholder 사용시 next.config를 mjs 파일로 바꿔야 해서 require를 못 사용한다고 되어있는데, require가 동작하나요? next.config 확장자를 어떻게 사용하고 계신지 궁금합니다