안녕하세요. 마이다스인에서 인재 채용 플랫폼 잡다의 웹 프론트엔드 개발을 담당하고 있는 배준형입니다.
잡다 메인 페이지에는 다양한 회사 로고, 대표 이미지, 취업 콘텐츠 썸네일 등 수많은 이미지가 로딩됩니다. 이 중 취업 콘텐츠 썸네일은 내부에서 업로드하여 관리할 수 있지만, 회사 로고와 대표 이미지는 각 기업의 인사담당자가 업로드하기 때문에 이미지 크기가 제각각이며, 웹 페이지에 표시되는 영역에 비해 지나치게 큰 용량을 가진 경우도 있습니다.
이러한 상황에서 저는 Next.js에서 제공하는 next/image
의 Image
컴포넌트와 On-demand Image Resizing 기능을 활용하여 이미지 로딩을 최적화했습니다. 이 글에서는 해당 과정을 단계별로 소개하고자 합니다.
이미지 최적화를 적용하기 전, 잡다 메인 페이지 접속 시 약 9.4MB 크기의 리소스(이미지, 스크립트, 스타일시트 등)가 로딩되었고, 페이지와 모든 의존 리소스의 로딩이 완료되기까지 6.89초가 소요되었습니다.
상황에 따라 차이가 있겠지만, 전반적으로 로딩 속도가 느린 편입니다. 특히 실제 사용되는 영역에 비해 과도하게 큰 원본 이미지를 그대로 사용하는 것이 로딩 지연의 주요 원인으로 보입니다.
대표적인 예로 로고 이미지를 들 수 있을 것 같은데요. 위 경우 로고는 48 x 48 px
크기로 화면에 표시되지만, 실제로는 720 x 720 px
의 고해상도 이미지를 다운로드하여 사용하고 있습니다. 경우에 따라 로고 이미지의 크기는 200 x 200 px
부터 3840 x 3840 px
까지 다양하게 존재하고요.
이처럼 불필요하게 큰 이미지를 로딩하는 것은 페이지 성능에 부정적인 영향을 미칩니다. 따라서 적절한 이미지 최적화 기법을 통해 로딩 속도를 개선할 필요가 있어 보입니다.
Next.js에서 제공하는 next/image
의 Image
컴포넌트는 다양한 이미지 최적화 기능을 지원합니다. Next.js 공식 문서에 따르면 Image
컴포넌트는 다음과 같은 이점을 제공합니다.
따라서 <img>
태그를 <Image>
컴포넌트로 대체하는 것만으로도 효과적인 이미지 최적화를 기대할 수 있을 것 같아요.
적용 전
<img src={src} alt={alt} className={className} />
적용 후
import Image from 'next/image';
<Image
src={src}
alt={alt}
width={48}
height={48}
className={className}
/>
// 또는
<Image
src={src}
alt={alt}
fill
className={className}
/>
이미지의 크기를 명확히 알고 있다면 width
와 height
속성을 사용하여 지정해 주어야 하고, 이미지 크기를 모르는 경우에는 fill
속성을 사용할 수 있습니다.
Next.js 버전 13 미만에서는 fill
속성 대신 layout="fill"
속성을 사용하면 됩니다.
결과
next/image
의 Image
컴포넌트를 도입한 결과, 리소스 다운로드 용량이 9.4MB에서 6.4MB로 약 3MB(-30%) 감소했고, 로딩 시간도 6.89초에서 4.24초로 약 2.65초(-38%) 단축되었습니다.
Image
컴포넌트를 사용하는 것만으로 3MB의 리소스를 절감할 수 있었는데, 이는 이미지 크기 최적화 등이 자동으로 적용되면서 줄어든 것으로 보입니다.
리소스 용량과 로딩 시간이 개선되었음에도 불구하고, 몇 가지 문제점이 보였는데요.
fill
속성을 사용하면 뷰포트 크기에 따라 가변적으로 너비가 변하는 영역에 최대 3840px 크기의 이미지를 로드하게 됩니다. 이렇게 최적화된 이미지를 생성하는 작업은 서버에서 처리됩니다.
위 이미지의 src
속성을 보면 3840px 크기의 이미지를 로드하고 있습니다. 실제로 렌더링되는 이미지 크기가 280 x 180 px
임을 감안하면, 필요한 크기보다 약 15배나 큰 이미지를 로드하는 셈입니다.
이미지 크기 최적화 시 고해상도 디스플레이를 고려하여 렌더링될 UI 요소의 2배 정도 크기로 최적화하면 선명한 화질을 유지할 수 있습니다. 위 예시의 경우 560 x 360 px
정도로 최적화해도 충분한 것이죠.
그런데 실제로는 사이트 방문자마다 최적의 이미지를 제공하기 위해 다양한 크기의 이미지를 생성하고 있습니다. next/image
를 사용하면 서버에서 크기와 포맷 등을 최적화한 이미지를 생성하는데, 불필요하게 생성되는 이미지들로 인해 서버에 부담을 줄 수 있습니다.
srcset
속성은 사이트 방문자의 뷰포트에 따라 1개의 이미지만 사용되고 나머지는 무시되지만, 서버에서 각 이미지를 생성하는 과정은 무시되지 않기에 부하를 줄 수 있습니다.
위에서 640px ~ 3840px에 맞게 이미지를 서버에서 생성한다고 했습니다. 그런데, 실제 사용되고 있는 이미지를 보면 적절히 선택되는 것이 아닌 최대 화면 너비(100vw) 크기로 세팅이 되는데요. 이는 sizes 기본 값과 관련이 있습니다.
Image
컴포넌트의 sizes
속성은 각 미디어 조건에 따라 어떤 크기의 이미지를 로드할 것인지 지정하는 역할을 합니다. 그런데 sizes
속성에 아무 값도 전달하지 않으면 기본값으로 100vw
가 설정됩니다. 따라서 캡처 이미지에서는 화면 크기인 1080px 크기의 이미지가 사용되고 있습니다. 만약 sizes
속성 값을 50vw
로 변경하면, 사용되는 이미지 크기도 그에 맞춰 줄어들게 됩니다.
이론적으로는 100vw
에서 50vw
로 감소시켰을 때 이미지 크기가 1080 x 355 px
에서 540 x 177 px
로 줄어들어야 할 것 같지만, 실제로는 next.config.js
에서 설정한 이미지 크기 값에 따라 640 x 210 px
로 조정됩니다. 이에 대한 자세한 설명은 뒷부분에서 다루도록 하겠습니다.
next/image
를 그대로 사용했을 때 일부 이미지에서 화질 저하 현상이 나타날 수 있습니다. 왼쪽은 next/image
의 <Image />
컴포넌트로, 오른쪽은 html
의 <img />
태그로 렌더링한 로고인데, next/image
를 사용한 경우 로고가 더 흐릿하게 보이죠.
이는 config에 따라 이미지 최적화를 자동으로 진행하면서 크기를 과도하게 줄인 탓에 화질이 저하된 것으로 보입니다. 이런 경우라면 최적화를 적용하는 것보다 원본 이미지를 그대로 사용하는 편이 나을 것 같기도 합니다.
fill
속성을 사용하면 서버에서 최적화된 이미지를 생성할 때 640px부터 3840px까지 다양한 크기의 이미지를 생성하게 됩니다. 이 범위는 next.config.js
에서 설정을 변경할 수 있습니다.
next/image
의 설정 코드를 직접 살펴보면 기본값이 다음과 같이 되어 있습니다. 이 기본 설정값이 적용되기 때문에 640px부터 3840px까지의 이미지가 생성됐던 것이죠.
next/src/shared/lib/image-config.ts
export const imageConfigDefault: ImageConfigComplete = {
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
path: '/_next/image',
loader: 'default',
loaderFile: '',
domains: [],
disableStaticImages: false,
minimumCacheTTL: 60,
formats: ['image/webp'],
dangerouslyAllowSVG: false,
contentSecurityPolicy: `script-src 'none'; frame-src 'none'; sandbox;`,
contentDispositionType: 'inline',
remotePatterns: [],
unoptimized: false,
};
deviceSizes
: next/image 컴포넌트가 고려할 디바이스 크기의 배열imageSizes
: next/image 컴포넌트가 사용할 수 있는 이미지 크기의 배열앞서 100vw에서 1080px, 50vw에서 640px로 크기가 줄어든 것은 devicesSizes
의 가장 작은 값이 640으로 설정되어 있기 때문입니다.
이제 next.config.js
에서 deviceSizes
와 imageSizes
를 적절히 수정해 줍니다. 잡다 서비스의 경우 PC와 모바일의 소스코드가 다른데요. 저는 PC를 담당하고 있어서 PC로 접속했을 때의 크기를 고려하여 작성해 두었습니다.
next.config.js
module.exports = {
images: {
deviceSizes: [적절한 사이즈 부여],
imageSizes: [적절한 사이즈 부여],
},
}
위와 같이 작성하면 srcset
에 deviceSizes에 해당하는 이미지만 생성하게 됩니다. 각 서비스 환경에 맞게 설정을 조정해주면 될 것 같아요.
이미지 크기가 부모 요소에 의해 결정되는 것이 아니라 가로와 세로 크기를 정확히 알고 있는 경우에는 fill
속성 대신 width
와 height
속성을 사용하는 것이 더 유리합니다.
fill 속성 부여
정확한 width, height 속성 부여
width
와 height
속성을 사용하더라도 이미지의 선명도를 유지하면서 서버에서 생성해야 하는 이미지의 수를 줄일 수 있으므로 서버 부하를 감소시킬 수 있습니다.
그래서 이미지 크기를 미리 알고 있다면 fill
속성보다는 width
와 height
속성을 활용하는 것이 더 좋습니다. 이를 통해 불필요한 서버 부하는 줄이면서 적절한 이미지를 제공할 수 있습니다.
next/image
의 Image
컴포넌트를 그대로 사용하면 이미지 크기가 자동으로 조정되어 너무 작아진 이미지가 사용되는 문제가 있었습니다. 물론 경우에 따라 적절하게 최적화되기도 하지만, 제 경우에는 로고를 사용할 때 너무 이미지가 줄어들어서 흐릿해지는 문제가 있었습니다. 이를 해결하기 위해 저희 팀에 구현되어 있었지만 사용하지 않고 있았던 On-demand Image Resizing 방식을 활용하기로 했습니다.
커스텀 Image
컴포넌트를 만들고 loader
속성을 직접 정의하여 사용하는 방식으로 수정했습니다.
common/Image.tsx
import React, { useState } from 'react';
import Image, { ImageLoaderProps, ImageProps } from 'next/image';
import { isNumber } from '@utils/typeGuard';
interface CustomImageProps extends ImageProps {
ErrorImage?: React.ReactNode;
pointer?: boolean;
}
const customLoader = ({ src, width, ratio }: ImageLoaderProps & { ratio: number }) => {
return `${src}?w=${width}${isNumber(ratio) ? `&h=${Math.floor(ratio * width)}` : ''}`;
};
const CustomImage = ({ ErrorImage, pointer, ...props }: CustomImageProps) => {
const [isImgError, setIsImgError] = useState(false);
const onError = () => {
setIsImgError(true);
};
if (isImgError) {
return ErrorImage;
}
const width = props.width as number;
const height = props.height as number;
const src = props.src as string;
return (
<Image
{...props}
alt={props.alt}
src={src}
loader={(props) => customLoader({ ...props, ratio: height / width })}
onError={onError}
/>
);
};
export default CustomImage;
customLoader
함수를 정의하여 이미지 URL에 height
값도 전달하는 코드를 추가했습니다. 저희 팀에서 사용 중인 On-demand Image Resizing 방식은 URL 뒤에 w
, h
쿼리 파라미터를 전달하여 사용하고 있는데요. next/image
의 기본 로더에는 w
(width)와 q
(quality)만 전달하고 있으므로, 이를 계산하기 위해 ratio
를 추가로 전달해 계산하여 사용하도록 했습니다.
height
를 그대로 전달하지 않은 이유는 width
와 height
속성을 함께 사용할 경우, 1x
와 2x
이미지 세트를 생성할 때 width
는 이에 맞게 조정되지만 height
는 고정되기 때문입니다.
예를 들어, width={240}
, height={240}
으로 설정하면 1x
에 해당하는 이미지 URL은 {url}?w={240}&h={240}
이 되어야 하고, 2x
에 해당하는 이미지 URL은 {url}?w={480}&h={480}
이 되어야 합니다. 그러나 실제로는 2x
에서 {url}?w={480}&h={240}
로 height
값이 고정되게 돼서 width
에 비례하여 height
값을 설정할 수 있도록 ratio
를 사용하여 계산하는 방식을 적용했습니다.
결과
Image
컴포넌트를 그대로 사용했을 때와 customLoader
와 자체 On-demand Image Resizing을 적용했을 때의 화면을 비교해 보면, 오른쪽의 개선된 버전에서 이미지가 훨씬 선명하게 보입니다. 또한 이미지 크기가 적절히 조정되었으므로 리소스 용량도 감소했을 것으로 예상됩니다.
이미지 최적화를 적용한 결과, 리소스 다운로드 용량은 기존의 9.4MB에서 4.0MB로 약 5.4MB(-57%) 감소했고, 로딩 시간은 6.89초에서 4.35초로 약 2.54초(-37%) 단축되었습니다.
Image
컴포넌트로 교체한 직후에 측정했을 때와 비교해도 리소스 용량이 6.4MB에서 4.0MB로 줄었고, 로딩 시간은 4.24초에서 4.35초로 약 0.11초 증가했습니다. 로딩 시간은 새로고침할 때마다 다르게 측정되지만, 평균적으로 봤을 때 최초에 원본 이미지만 사용했을 때보다는 확실히 개선된 것으로 보입니다.
이러한 개선 효과는 이미지 크기를 정확히 알고 있는 경우 width
와 height
속성을 적절히 지정한 것, next.config.js
에서 deviceSizes
와 imageSizes
설정을 최적화한 것, 자체 On-demand Image Resizing을 활용하면서 이미지 화질을 유지한 것 등이 종합적으로 작용한 결과입니다.
이번 포스팅에서는 Next.js에서 제공하는 next/image
의 Image
컴포넌트를 활용하여 이미지 최적화를 적용하는 과정을 알아보았습니다.
<img>
태그를 <Image>
컴포넌트로 교체할 때 알아서 지원되는 최적화가 많기 때문에 좋기만할 줄 알았고, 여러 가지 srcset
을 생성하면서 발생하는 서버 부하, 너무 사이즈를 줄여서 흐릿하게 보이는 현상 등의 문제가 있다는 것은 모르고 있었습니다. 이번 기회에 조금이나마 알게 되어서 해당 내용들도 같이 수정했는데, 실제로 효과가 있었으면 좋겠습니다.
무언가를 사용할 때 좋은 점만 보기 보단 단점도 같이 확인하면서 상황에 따라 적절하게 선택하여 사용하는 것이 중요하겠다 하는 생각도 듭니다.