
`<img>` 태그 썼다가 혼났습니다 (LCP 2.5초의 비밀)
멋진 히어로 이미지를 `<img>` 태그로 넣었더니 LCP 점수가 빨간색입니다. Next.js의 `Image` 컴포넌트가 어떻게 이미지 사이즈, 포맷, 로딩 시점을 자동 최적화하는지, 그리고 `sizes` 속성의 비밀을 파헤쳐봤습니다.

멋진 히어로 이미지를 `<img>` 태그로 넣었더니 LCP 점수가 빨간색입니다. Next.js의 `Image` 컴포넌트가 어떻게 이미지 사이즈, 포맷, 로딩 시점을 자동 최적화하는지, 그리고 `sizes` 속성의 비밀을 파헤쳐봤습니다.
느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

텍스트에서 바이너리로(HTTP/2), TCP에서 UDP로(HTTP/3). 한 줄로서기 대신 병렬처리 가능해진 웹의 진화. 구글이 주도한 QUIC 프로토콜 이야기.

HTML 파싱부터 DOM, CSSOM 생성, 렌더 트리, 레이아웃(Reflow), 페인트(Repaint), 그리고 합성(Composite)까지. 브라우저가 화면을 그리는 6단계 과정과 치명적인 렌더링 성능 최적화(CRP) 가이드.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

메인 페이지에 고해상도 히어로 이미지를 넣었습니다.
디자이너가 준 hero.png (3MB)를 그대로 <img> 태그에 넣었죠.
배포 후 Lighthouse 점수를 돌려봤는데... Performance: 45점. 빨간색 경고등이 켜졌습니다. LCP (Largest Contentful Paint)가 4.2초나 걸렸습니다. 사용자는 흰 화면만 4초 동안 보고 있다는 뜻입니다.
"아니, 고작 이미지 하나 넣었다고 점수가 반토막이 나?"
저는 "이미지는 원래 크고 무거운 것"이라고 생각했습니다. 화질을 좋게 하려면 용량이 큰 건 어쩔 수 없다고 여겼죠. 그리고 사용자의 화면 크기(모바일 vs 데스크톱)에 상관없이 똑같은 이미지를 줘도 되는 줄 알았습니다.
하지만 브라우저는 생각보다 똑똑하지 않습니다.
3MB짜리 이미지를 주면, 모바일에서도 꾸역꾸역 3MB를 다 다운받고 디코딩하느라 폰이 뜨거워집니다.
게다가 png 포맷은 사진을 표현하기엔 비효율의 극치였습니다.
이걸 "만능 변환기 파이프라인"에 비유하니 이해가 됐습니다.
<img>: 그냥 원본 파일을 냅다 던져주는 겁니다. "옛다, 3MB 받아라."next/image: 요청이 들어오면 서버에서 "그 사람에게 딱 맞는" 옷으로 갈아 입혀서 줍니다.
내가 일일이 포토샵으로 hero-mobile.webp, hero-desktop.avif를 만들 필요가 없었습니다.
Next.js 서버가 요청 시점(On-demand)에 알아서 다 해주는 거였습니다.
next/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를 줘서 브라우저가 "이건 제일 먼저 다운받아!"라고 알게 해야 합니다.
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} // 필수!
/>
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(전체 화면 크기)로 가정해서, 엄청 큰 이미지를 다운받게 됩니다.
<img>는 로딩 전엔 높이가 0이었다가, 로딩되면 팍! 하고 내용이 밀립니다. 구글이 이걸 싫어합니다.
next/image는 width/height 비율을 바탕으로 투명한 영역(Placeholder)을 미리 잡아둡니다. 그래서 레이아웃이 덜컥거리지 않습니다.
로컬 이미지는 Next.js가 빌드 타임에 블러 이미지를 만들어주지만, 리모트 이미지는 못 만듭니다.
그래서 Base64 문자열을 직접 넣어줘야 합니다.
<Image
src={remoteUrl}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // plaiceholder 같은 라이브러리로 생성
/>
WebP보다 더 압축률이 좋은 AVIF를 쓰려면 config를 추가하세요.
// next.config.js
images: {
formats: ['image/avif', 'image/webp'],
}
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} />
Next.js는 기본적으로 이미지 최적화를 위해 Squoosh(WebAssembly 기반)를 씁니다. 설치가 필요 없어서 편리하죠. 하지만 프로덕션 환경에서는 Sharp(Native Module)를 쓰는 게 압도적으로 빠릅니다.
npm install sharp
이거 하나만 설치하면, 이미지 변환 속도가 3~4배 빨라집니다. 특히 Vercel 같은 서버리스 환경에서는 "변환 시간이 람다 실행 시간"이기 때문에, Sharp 설치는 선택이 아니라 필수입니다. (Vercel에 배포하면 자동으로 Sharp를 쓰려고 시도합니다. 없으면 경고 뜹니다.)
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를 커스텀하세요.
스타트업 랜딩 페이지를 최적화해준 적이 있습니다. 마케팅 팀이 고화질 사진 5장을 슬라이더(Carousel)로 넣었는데, 합쳐서 15MB였습니다.
<img>로 로딩.next/image 도입으로 WebP 자동 변환 -> 15MB가 1.2MB로 줄어듦 (-92%).priority를 주고, 나머지는 기본값(lazy) 유지.sizes="100vw"를 명시하여 모바일에서 400px짜리 이미지 다운로드.LCP가 6.8초에서 0.9초로 줄었습니다. 사용자 이탈률이 20%대로 떨어졌고, 마케팅 팀은 "사이트가 빨라지니까 광고 효율이 올랐다"고 좋아했습니다. 성능 최적화는 단순히 개발자의 자기만족이 아니라, 돈(매출)과 직결됩니다.
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 컴포넌트에 넣으면 픽셀화될 수도 있습니다.