이미지 최적화를 개선하기 위해서 NextJS에서 제공하는 Image 를 사용하게 됩니다.
현재 프로젝트 기준으로 LightHouse로 페이지 성능을 측정하게 되면 아래와 같은 수정항목에 대한 요청이 있습니다.
우선 이미지 최적화를 설명하기 전에 NextJS Image를 사용하여 최적화를 진행할 예정입니다. 그렇기 때문에 간단하게 NextJs Image의 특징에 대해서 설명하려고 합니다.
Next/image에서 제공하는 기능
Next/image에서 기본적으로 제공하는 기능은 아래와 같습니다.
Improved Performance : 최신 이미지 형식을 사용하여 언제나 디바이스 사이즈에 맞게 최적화된 이미지를 제공합니다.
Visual Stability : Cumulative Layout Shift (CLS)를 자동으로 방지해줍니다..
Faster Page Loads : 이미지가 뷰포트에 들어왔을 때만 로드되기 때문에 초기 페이지 로드 속도가 빠릅니다.
Asset Flexibility : 외부에 저장되어 있는 이미지까지도 리사이징이 가능합니다.
이 항목은 현재 이미지 부분들이 jpeg, png 로 로드되기 때문에 용량이 적고 속도가 빠른 webp 사용을 추천하고 있습니다.
next/image를 사용하여 jpeg, png 등의 이미지 형식을 webp로 변환해 이미지 용량을 반절 이상 줄여주게 됩니다.
Next/Image 적용 전
Next/Image 적용 후
이미지 크기 적절하기 설정하기의 경우 실제로 보여지는 사이즈에 비해 큰 이미지가 보여질 경우 나타나게 됩니다.
네트워크 페이로드가 커지지 않도록 관리하기 이미지의 크기 및 용량이 클 경우 나타나게 됩니다.
이 두개의 경우 next/image를 사용할 경우 sizes props 를 이용하여 이미지 관련한 내용을 해결할 수 있습니다.
sizes prop를 적용하게 되는 경우를 아래에서 정리하였습니다.
이미지가 있다고 가정합니다. 2가지 경우가 존재합니다.
이미지가 반응형인 경우
fill props와 object-fir: cover 옵션을 사용해야 합니다.
<div className="relative h-[150px]">
<Image
src={list.image}
alt="main_guide_banner"
className={clsx(
'rounded-lg',
'pointer-events-none',
'object-cover',
)}
fill
/>
</div>
위에는 이미지가 반응형일 경우 layout:fill 을 적용한 코드입니다.
여기서 확인을 해보면 이미지들은 의도한대로 반응형 이미지로 나타나게 됩니다. 하지만 이미지 크기 적절하기 설정하기, 네트워크 페이로드가 커지지 않도록 관리하기 일부는 해결이 되었을 수도 있지만 완전히 해결되지는 않게 됩니다.
next/image의 fill 또는 responsive size를 사용할 경우 sizes 속성은 어떤 사이즈의 이미지를 로드할지 뷰포트를 기준으로 기본으로 생성된 source set으로 부터 가져와 로드하게 됩니다.
따로 설정하지 않을 경우 기본적으로 100vw, 즉 뷰포트 전체 width로 인식해 가져오게 됩니다.
그렇기 때문에, next/image를 사용해서 webp로 변환되어서 실제 사이즈만큼은 아니지만 sizes를 설정하지 않게 되면 100vw로 가져오게 되기 때문에 큰 사이즈의 이미지를 로드하게 됩니다.
<Image src”…” alt=”…” width={300} height={200} />
width, height가 정해져 있는 경우 srcSet은 1x, 2x 로 생성되는 것을 확인할 수 있습니다
deviceSizes
디바이스 breakpoint 기준점
//next.config.js
//기본 값 [640, 750, 828, 1080, 1200, 1920, 2048, 3840]
deviceSizes: [480, 1080, 1200, 1920]
imagesSizes
이미지 너비 목록으로 deviceSizes와 연결되어 전체 srcSet을 형성합니다.
imageSizes는 sizes props이 사용될 때 deviceSizes 보다 작음을 나타낼 때의 이미지에만만 사용됩니다.
//next.config.js
//기본 값 [16, 32, 48, 64, 96, 128, 256, 384]
imageSizes: [32, 48, 64, 150, 350, 500]
이 위에 있는 사항은 기본 사항입니다. 위에 breakpoint에 따라서 수정하면 됩니다.
Next.js의 Image 컴포넌트의 가장 강력한 기능 중에 자동으로 source set을 생성하는 기능이 있는데, source set을 생성하면 뷰포트에 맞게 알맞은 사이즈의 이미지를 로딩해서 페이지의 레이아웃을 망치지 않게 됩니다.
예시로 next.config.js에서 디바이스의 최소 사이즈는 760으로 설정하고, imageSizes의 [32, 48, 64, 150, 350, 500]설정 합니다. (deviceSizes는 사용자의 예상 기기 사이즈를 알고 있다면 next.config.js의 deviceSizes 속성을 사용하여 기기 너비 중단점 목록을 지정할 수 있습니다.)
이 너비는 next/image 컴포넌트가 사용자의 기기에 올바른 이미지가 표시되도록 하기 위해 size prop를 사용할 때 사용됩니다.
next.config.js 파일에서 images.imageSizes 속성을 사용하여 이미지 너비 목록을 지정할 수 있습니다. 이러한 너비는 디바이스 크기 배열과 연결되어 이미지 srcset을 생성하는데 사용되는 전체 크기 배열을 형성합니다.
deviceSizes, imageSizes 두가지가 분리되는 이유는 이미지가 화면의 전체 너비보다 작음을 나타내는 sizes props를 제공하는 이미지에만 imageSizes가 사용되기 때문입니다.
이때 imageSizes의 크기는 모두 deviceSizes의 가장 작은 크기보다 작아야 합니다.
아래 sizes를 30vw로 지정해 둘 경우, breakPonint는 480이므로 480 * 30vw = 144 이므로, imageSizes의 64, 150 중 64 보다는 크기 때문에 150 사이즈의 이미지를 다운로드 합니다.
이미지 고화질로 로드가 안됐던 이슈
프로젝트를 진행하면서 반응형 웹 디자인을 구현해야 했습니다. 이 과정에서 이미지의 최대 넓이가 768px에서 시작하여 화면 크기에 따라 자동으로 조절되는 로직이 필요했습니다. 이를 위해 Next.js의 이미지 반응형 기능을 사용하였습니다.
그러나 문제가 있었습니다. 화면을 확대했을 때, 이미지 안의 작은 글씨까지 선명하게 보여야 했는데, 실제로는 글자 부분이 깨져 보였습니다. S3에 업로드된 이미지는 2300px 해상도를 가진 고화질 이미지였고, next.config.js에서 deviceSizes 설정은 [480, 768]으로 되어 있었습니다.
따라서 스크린 해상도가 1200px인 경우에도 Next.js가 자동으로 768px 해상도의 이미지를 가져와서 사용하였고, 이 때문에 고화질로 업로드한 이미지 안의 글자 부분이 깨져 보였습니다.
해결방법
문제의 원인은 deviceSizes 설정 때문이었습니다. deviceSizes: [480, 768] 설정은 디스플레이 넓이(width)가 860px일 때 로드되어야 할 이미지 크기가 실제로는 860 X 100vw = 860px 이상이어야 하나, 설정된 deviceSizes 값 중 가장 큰 값인 768px만큼만 로드되기 때문입니다.
따라서 모바일 환경에서도 저해상도(480px 또는 768px)의 이미지가 로드되어서 글자 부분이 깨져 보였습니다.
해결 방법
위 문제를 해결하기 위해서는 deviceSizes: [1440,2304]와 같은 설정을 적용하는 것입니다. 여기서 [1440,2304] 값을 선택한 이유는 DPR(Device Pixel Ratio) 값을 고려한 것입니다.
모바일 환경: 프로젝트 기준 해상도(480px)과 DPR(3.0)을 곱하여 실제 표시될 해상도를 계산합니다 (480 *3 =1440).
PC 환경: 마찬가지로 기준 해상도(768px)와 DPR(3.0)을 곱하여 실제 표시될 해상도를 계산합니다 (768 * 3 = 2304).
DPR은 한 개의 CSS 픽셀을 얼마나 많은 실제(물리적) 픽셀로 나타낼 것인지 결정하는 값입니다. 따라서 높은 DPR 값을 설정하면 이미지가 깨지지 않고 고화질로 보여질 수 있습니다.
이렇게 설정하면, 기기의 화면 크기와 상관없이 항상 고화질의 이미지를 로드하여 선명한 이미지를 제공할 수 있습니다.
결과
1. lighthouse의 네트워크 페이로드에 대한 용량이 줄어듦.
2. srcSet은 1440w, 2304w 설정
Next 공식문서에 Image 예제 부분이 추가되었네요. 이 부분 함께 첨부합니다.
responsive
import Image from 'next/image'
import mountains from '../public/mountains.jpg'
export default function Responsive() {
return (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Image
alt="Mountains"
// Importing an image will
// automatically set the width and height
src={mountains}
sizes="100vw"
// Make the image display full width
style={{
width: '100%',
height: 'auto',
}}
/>
</div>
)
}
Fill Container
import Image from 'next/image'
import mountains from '../public/mountains.jpg'
export default function Fill() {
return (
<div
style={{
display: 'grid',
gridGap: '8px',
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, auto))',
}}
>
<div style={{ position: 'relative', height: '400px' }}>
<Image
alt="Mountains"
src={mountains}
fill
sizes="(min-width: 808px) 50vw, 100vw"
style={{
objectFit: 'cover', // cover, contain, none
}}
/>
</div>
{/* And more images in the grid... */}
</div>
)
}
Background Image
import Image from 'next/image'
import mountains from '../public/mountains.jpg'
export default function Background() {
return (
<Image
alt="Mountains"
src={mountains}
placeholder="blur"
quality={100}
fill
//기본 값이 100vw 입니다.
sizes="100vw"
style={{
objectFit: 'cover',
}}
/>
)
}
접근성 관련하여 light house에서 [aria-*] 속성이 역할과 일치하지 않음 이라는 메세지가 뜨게 됩니다.
자세한 메세지를 보게 되면 각 ARIA role은 aira-* 속성으로 구성된 특정 하위 세트를 지원한다고 쓰여져 있습니다.
많이들 코드에서 icon에 대한 것은 태그로 설정하는 곳들도 꽤 있습니다. 기존에 (옛날 브라우저) 는 i를 이탤릭 전용으로만 사용하였지만 현재의 i태그는 해당의 의미로는 사용되지 않고 css를 통하여 특정 작업을 하는 형태로 쓰이고 있어 icon을 이러한 이유로 태그를 사용한다고 합니다.
aria-hidden="true"를 사용하는 경우
최근에 chakra, bootstarp 등 Icon 컴포넌트를 확인하였을 때 i tag를 사용하기 보다는 span 태그를 이용하여 aria-hidden="true" 설정하여 스크린 리더와 같은 보조기술 사용자의 콘텐츠 탐색을 제한합니다.
<span aria-hidden={true}>
<Chevron
width={width}
height={height}
className={clsx('fill-gray-100', className)}
{...rest}
/>
</span>
aria-label를 추가하는 경우
위 사진과 같이 아이콘에 버튼 이미가 들어 있을 경우 aria-label, role 로 설정하여 스크린 리더 읽을 수 있도록 합니다. Semantic Tag으로 감싸져 있을 경우 role을 따로 쓸 필요는 없습니다. 이미 태그에 의미가 부여되어 있으므로 role를 적용하게 될 경우 중복이 발생하게 됩니다.
const MenuButton = ({ className = '' }: { className?: string }) => {
const { setIsMenuOpen } = useHeader()
return (
<button
type="button"
className={className}
onClick={() => {
setIsMenuOpen(true)
}}
//추가
aria-label="user"
>
<MenuIcon />
</button>
)
}
Layout Shift 원인?
Lighthouse에서 Layout Shift 확인해보기
이미지 태그에 명시적인 width, height 를 가져야 한다, 이미지 lazy 로딩 등 여러 문제가 나타날 수 있습니다. 하지만 next/Image를 쓰면서 이러한 문제점은 해결될 수 있습니다.
[웹 성능 최적화] 실습4: 이미지 갤러리 서비스 최적화 해당 블로그는 프론트엔드 성능 최적화 가이드 라는 책에 있는 내용을 정리한 것으로 참고하시면 좋을 거 같습니다.
웹 페이지 렌더링 최적화의 목표는 리플로우를 최대한 적게 발생시키면서, 빠르게 화면을 그리는 것 입니다.
CSS 최적화에 있어서 리플로우, 리페인트(Reflow/Repaint)에 대해서 알아보았습니다.
리플로우, 리페인트(Reflow/Repaint)를 고려한 스타일 작성
반면 레이아웃에 영향을 주지 않는 속성을 변경하면 레이아웃을 건너뛰고 페인트 작업부터 다시 수행하게 되는데 이를 리페인트라고 합니다.
리플로우가 일어나면 브라우저가 전체 픽셀을 다시 계산해야 하기때문에 되도록 리페인트 속성을 사용해 스타일을 작성하는 것이 성능면에서 좋습니다.
리플로우(Reflow)를 발생시키는 속성
position / width / height / margin / padding / display / top / left / right / bottom /
box-sizing / border-color / text-align / border / border-width /
font-family / float / font-size / font-weight / line-height / vertical-align /
white-space / word-wrap / text-overflow / text-shadow ...
리페인트(Repaint)를 발생시키는 속성
color / border-style / visibility / background / background-color /
background-image / background-position / background-repeat / background-size /
text-decoration / outline / outline-style / outline-color / outline-width /
border-radius / box-shadow ...
리플로우와 리페인트를 발생시키지 않는 속성
opacity / transform / cursor / z-index ...
production 모드로 실행하게 될 때 터미널에서 아래와 같은 문구가 뜨게 됩니다. sharp 라이브러리를 사용할 것은 권장하고 있습니다. Sharp Missing In Production 이 링크는 nextJs 문서입니다.
nextJS는 Squoosh를 기본 이미지 최적화 모듈로 사용하고 있고, Squoosh는 빠르게 설치할 수 있고 개발 환경에 적합하다고 합니다. 그런데, 운영 환경에서는 sharp를 사용하는 것을 매우 강력하게 권장하고 있습니다.
NEXT.JS는 sharp를 추천하는가
sharp란
https://github.com/lovell/sharp sharp 라이브러리 페이지를 보시면 sharp는 다양한 크기의 JPEG, PNG, WebP, GIF, AVIF와 같은 이미지들을 더 작은 크기로, 그리고 웹에 진화적으로 변환해 주는 매우 빠른 속도의 모듈이라고 설명하고 있습니다.
squoosh란
https://github.com/GoogleChromeLabs/squoosh sharp에 비하면 squoosh 제공 기능은 단순합니다. sharp와 비슷하게 다양한 포맷의 이미지를 압축하고 사이즈를 줄여준다고 합니다.
sharp와 squoosh 성능 비교
성능을 비교하게 되면 크기를 비교하였을 때는 큰 차이는 없지만, sharp를 사용할 경우 약 3~5배 정도로 빠르게 응답을 받을 수 있습니다.(크기면에서도 미미한 차이가 나는 경우가 있습니다)
squoosh 적용
sharp 적용
캐쉬란 사용자가 요청하는 html, css, js, image 등을 첫 요청 시에 내려받은 뒤 특정 위치에 복사본을 저장하고, 이후 동일한 URl의 리소스 요청이 왔을 때 이전에 저장해둔 파일을 사용해서 더 빠르게 로딩하는데에 사용됩니다.
브라우저가 다운로드할 파일의 개수 자체를 줄이므로 시간적 측면에서 이득이 크게 됩니다.
NextJs/Image를 사용함으로써 캐싱을 자동적으로 지원해주고 있습니다.
네트워크에서 로드된 이미지에서
X-Nextjs-Cache 부분을 보시면 캐시된 경우 HIT or STALE로 표시 됩니다.
이미지가 캐시되어 있었다면, HIT를 응답으로 전달하기 때문에 이 값을 보고 이미지의 캐시 여부에 대한 판단이 가능합니다.
이것은 revalidate 가 60초로 설정되어 있는경우 HTML header에는 'Cache-Control: s-maxage=60, must-revalidate'가 추가됩니다. (must-revalidate or stale-while-revalidate or proxy-revalidate 등등 여러가지인데 이것은 설정에 따라서 바뀌게 됩니다)
HTML을 pre-render(generate)한 시점부터 0~60초까지는 캐시된 문서를 응답하며 HIT(fresh, valid)됩니다. 하지만 generate를 한 시점부터 60초가 지나면 캐시는 stale(not fresh, expired) 상태로 들어갑니다.
그 뒤 어떤 사용자가 페이지에 들어와 HTML을 요청하면, 더이상 valid하지 않기 때문에, 서버는 revalidate 로직을 실행합니다.
must-revalidate
must-revalidate 응답 지시문은 응답을 캐시에 저장할 수 있고 신선한 상태에서 재사용할 수 있음을 나타냅니다. 응답이 오래되면 재사용하기 전에 원본 서버에서 유효성을 검사해야 합니다.
stale-while-revalidate
stale-while-revalidate 응답 지시문은 캐시가 오래된 응답을 캐시로 재확인하는 동안 오래된 응답을 재사용할 수 있음을 나타냅니다