"이미지가 왜 이렇게 늦게 떠?"
메인 페이지에 고해상도 히어로 이미지를 넣었습니다.
디자이너가 준 hero.png (3MB)를 그대로 <img> 태그에 넣었죠.
배포 후 Lighthouse 점수를 돌려봤는데... Performance: 45점. 빨간색 경고등이 켜졌습니다. LCP (Largest Contentful Paint)가 4.2초나 걸렸습니다. 사용자는 흰 화면만 4초 동안 보고 있다는 뜻입니다.
"아니, 고작 이미지 하나 넣었다고 점수가 반토막이 나?"
처음엔 뭐가 이해가 안 갔나? (원본의 함정)
저는 "이미지는 원래 크고 무거운 것"이라고 생각했습니다. 화질을 좋게 하려면 용량이 큰 건 어쩔 수 없다고 여겼죠. 그리고 사용자의 화면 크기(모바일 vs 데스크톱)에 상관없이 똑같은 이미지를 줘도 되는 줄 알았습니다.
하지만 브라우저는 생각보다 똑똑하지 않습니다.
3MB짜리 이미지를 주면, 모바일에서도 꾸역꾸역 3MB를 다 다운받고 디코딩하느라 폰이 뜨거워집니다.
게다가 png 포맷은 사진을 표현하기엔 비효율의 극치였습니다.
어떤 포인트에서 이해가 됐나? (자동 변환기 비유)
이걸 "만능 변환기 파이프라인"에 비유하니 이해가 됐습니다.
<img>: 그냥 원본 파일을 냅다 던져주는 겁니다. "옛다, 3MB 받아라."next/image: 요청이 들어오면 서버에서 "그 사람에게 딱 맞는" 옷으로 갈아 입혀서 줍니다.- 아이폰 사용자? -> "너 화면 작네? 300px로 줄여줄게." (Resizing)
- 크롬 브라우저? -> "너 WebP 지원하지? 포맷 바꿔줄게." (Formatting)
- 스크롤 아래? -> "지금 안 보이니까 나중에 로딩해." (Lazy Loading)
내가 일일이 포토샵으로 hero-mobile.webp, hero-desktop.avif를 만들 필요가 없었습니다.
Next.js 서버가 요청 시점(On-demand)에 알아서 다 해주는 거였습니다.
해결 과정 - next/image 도입기
1단계 - 컴포넌트 교체 (Local Image)
로컬 이미지는 쉽습니다. 사이즈를 명시할 필요가 없습니다.
import Image from 'next/image';
import heroImg from '../public/hero.png'; // 1. import 하기
// Before ❌
// <img src="/hero.png" alt="Hero" />
// After ✅
<Image
src={heroImg}
alt="Hero"
placeholder="blur" // 로딩 중에 스윽~ 하고 나타나는 효과 (자동 생성됨)
priority // LCP 개선의 핵심!
/>
placeholder="blur" 덕분에 원본이 뜨기 전에 저화질 블러 이미지가 먼저 보여서 체감 속도가 훨씬 빠릅니다.
그리고 priority를 줘서 브라우저가 "이건 제일 먼저 다운받아!"라고 알게 해야 합니다.
2단계 - 리모트 이미지 (Remote Image)
AWS S3나 CDN 이미지를 쓸 때는 사이즈를 명시하고, access를 허용해야 합니다.
// next.config.js
module.exports = {
images: {
domains: ['my-bucket.s3.amazonaws.com'],
},
}
<Image
src="https://my-bucket.s3.amazonaws.com/user.jpg"
alt="User"
width={300} // 필수! (원본 비율 유지용, 실제 렌더링 사이즈 아님)
height={300} // 필수!
/>
3단계 - sizes 속성의 비밀 (중요!)
많은 분들이 sizes 속성을 무시하는데, 이게 없으면 모바일에서도 데스크톱용 큰 이미지를 다운받을 수 있습니다.
fill 모드를 쓸 때 특히 중요합니다.
<div style={{ position: 'relative', height: '400px' }}>
<Image
src="/banner.jpg"
alt="Banner"
fill
style={{ objectFit: 'cover' }}
// "화면이 작으면(768px 이하) 100vw만큼 보여주고, 크면 50vw(절반)만큼만 보여줘"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
이걸 적어줘야 Next.js가 srcset을 똑똑하게 생성해서, 모바일에서는 작은 이미지를 줍니다.
안 적으면 기본값으로 100vw(전체 화면 크기)로 가정해서, 엄청 큰 이미지를 다운받게 됩니다.
깊이 파고들기 - CLS 방지와 blurDataURL
1. CLS (Layout Shift) 방지
<img>는 로딩 전엔 높이가 0이었다가, 로딩되면 팍! 하고 내용이 밀립니다. 구글이 이걸 싫어합니다.
next/image는 width/height 비율을 바탕으로 투명한 영역(Placeholder)을 미리 잡아둡니다. 그래서 레이아웃이 덜컥거리지 않습니다.
2. 리모트 이미지의 Blur 처리
로컬 이미지는 Next.js가 빌드 타임에 블러 이미지를 만들어주지만, 리모트 이미지는 못 만듭니다.
그래서 Base64 문자열을 직접 넣어줘야 합니다.
<Image
src={remoteUrl}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // plaiceholder 같은 라이브러리로 생성
/>
Application: AVIF 포맷 & Custom Loader
AVIF 포맷 사용
WebP보다 더 압축률이 좋은 AVIF를 쓰려면 config를 추가하세요.
// next.config.js
images: {
formats: ['image/avif', 'image/webp'],
}
Custom Loader (CDN 최적화)
Vercel이 아닌 다른 서버에 배포하거나, Cloudinary/Imgix 같은 전문 CDN을 쓴다면 loader를 씁니다.
const myLoader = ({ src, width, quality }) => {
return `https://example.com/${src}?w=${width}&q=${quality || 75}`
}
<Image loader={myLoader} src="me.png" width={500} height={500} />
Sharp vs Squoosh (엔진 교체) 뜯어보기
Next.js는 기본적으로 이미지 최적화를 위해 Squoosh(WebAssembly 기반)를 씁니다. 설치가 필요 없어서 편리하죠. 하지만 프로덕션 환경에서는 Sharp(Native Module)를 쓰는 게 압도적으로 빠릅니다.
npm install sharp
이거 하나만 설치하면, 이미지 변환 속도가 3~4배 빨라집니다. 특히 Vercel 같은 서버리스 환경에서는 "변환 시간이 람다 실행 시간"이기 때문에, Sharp 설치는 선택이 아니라 필수입니다. (Vercel에 배포하면 자동으로 Sharp를 쓰려고 시도합니다. 없으면 경고 뜹니다.)
Device Sizes & Image Sizes 더 알아보기
next.config.js에서 생성될 이미지의 크기(Breakpoint)를 정밀하게 제어할 수 있습니다.
module.exports = {
images: {
// 뷰포트 너비 기준 (layout="responsive" or "fill")
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
// 이미지 너비 기준 (layout="fixed" or "intrinsic")
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
}
만약 여러분의 디자인이 모바일(360px), 태블릿(768px), 데스크톱(1024px) 3개만 지원한다면, 굳이 저 많은 사이즈를 다 생성할 필요가 없습니다. 공간 낭비고 빌드 시간 낭비입니다.
서비스의 브레이크포인트에 맞춰 deviceSizes를 커스텀하세요.
9. Case Study: 10MB 랜딩 페이지의 다이어트
스타트업 랜딩 페이지를 최적화해준 적이 있습니다. 마케팅 팀이 고화질 사진 5장을 슬라이더(Carousel)로 넣었는데, 합쳐서 15MB였습니다.
문제점
- 모든 이미지를
<img>로 로딩. - 슬라이더의 2번째, 3번째 이미지는 보이지도 않는데 미리 로딩함.
- LCP 6.8초. 이탈률 70%.
해결책
- 포맷 변환:
next/image도입으로 WebP 자동 변환 -> 15MB가 1.2MB로 줄어듦 (-92%). - Lazy Loading: 첫 번째 이미지만
priority를 주고, 나머지는 기본값(lazy) 유지. - Size Attribute:
sizes="100vw"를 명시하여 모바일에서 400px짜리 이미지 다운로드.
결과
LCP가 6.8초에서 0.9초로 줄었습니다. 사용자 이탈률이 20%대로 떨어졌고, 마케팅 팀은 "사이트가 빨라지니까 광고 효율이 올랐다"고 좋아했습니다. 성능 최적화는 단순히 개발자의 자기만족이 아니라, 돈(매출)과 직결됩니다.
10. FAQ: 자주 묻는 질문
Q: width, height를 모르는 동적 이미지(CMS 등)는요?
A: fill 속성을 쓰세요. 그리고 부모 div에 position: relative와 구체적인 높이(또는 aspect-ratio)를 줘야 합니다.
Q: 로컬 개발 환경(npm run dev)에서 이미지가 느려요.
A: 개발 모드에선 이미지 최적화를 요청 들어올 때마다 매번 수행합니다(캐시 안 함). 빌드(npm run build -> npm start) 하면 캐시가 적용돼서 빨라집니다. 걱정 마세요.
Q: SVG도 최적화 되나요?
A: 아니요. SVG는 벡터라서 리사이징이 필요 없습니다. 그냥 <img> 쓰시거나 @svgr/webpack으로 컴포넌트화해서 쓰세요. Image 컴포넌트에 넣으면 픽셀화될 수도 있습니다.