이미지 최적화 — lazy loading부터 next-image까지
페이지에 이미지 50개가 있다면, 사용자가 맨 아래까지 스크롤하지 않아도 50개를 모두 다운로드해야 할까요?
웹 페이지의 총 전송량 중 이미지가 차지하는 비중은 평균 50% 이상입니다. 이미지 최적화는 번들 최적화만큼이나 중요하며, 적절한 크기/포맷/로딩 전략만으로도 LCP(Largest Contentful Paint)를 크게 개선할 수 있습니다.
Native Lazy Loading
HTML의 loading="lazy" 속성은 이미지가 뷰포트에 가까워질 때까지 로드를 지연시킵니다.
function ImageGallery({ images }) {
return (
<div className="gallery">
{images.map((img) => (
<img
key={img.id}
src={img.url}
alt={img.description}
loading="lazy" // 네이티브 lazy loading
width={img.width} // CLS 방지를 위해 크기 명시
height={img.height}
/>
))}
</div>
);
}
주의사항
- **첫 화면(Above the fold) 이미지에는 사용하지 마세요 **: LCP 이미지는 즉시 로드되어야 합니다
width와height를 명시하여 레이아웃 시프트(CLS)를 방지합니다- 대부분의 모던 브라우저에서 지원합니다
// 첫 화면 이미지는 lazy가 아닌 eager
<img src={heroImage} alt="히어로 이미지" loading="eager" fetchPriority="high" />
// 아래쪽 이미지는 lazy
<img src={galleryImage} alt="갤러리" loading="lazy" />
IntersectionObserver로 커스텀 Lazy Loading
네이티브 lazy loading보다 더 세밀한 제어가 필요할 때 IntersectionObserver를 사용합니다.
function useLazyImage(src) {
const [loadedSrc, setLoadedSrc] = useState(null);
const imgRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setLoadedSrc(src);
observer.disconnect();
}
},
{ rootMargin: '200px' } // 뷰포트 200px 전에 미리 로드
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, [src]);
return { imgRef, loadedSrc };
}
function LazyImage({ src, alt, placeholder, ...props }) {
const { imgRef, loadedSrc } = useLazyImage(src);
return (
<div ref={imgRef}>
{loadedSrc ? (
<img src={loadedSrc} alt={alt} {...props} />
) : (
<div className="placeholder">{placeholder || <Skeleton />}</div>
)}
</div>
);
}
블러 플레이스홀더
function BlurImage({ src, blurSrc, alt, width, height }) {
const [loaded, setLoaded] = useState(false);
return (
<div style={{ position: 'relative', width, height }}>
{/* 작은 블러 이미지 (인라인 또는 매우 작은 크기) */}
<img
src={blurSrc}
alt=""
style={{
filter: 'blur(20px)',
transform: 'scale(1.1)',
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: loaded ? 0 : 1,
transition: 'opacity 0.3s',
}}
/>
{/* 실제 이미지 */}
<img
src={src}
alt={alt}
loading="lazy"
onLoad={() => setLoaded(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: loaded ? 1 : 0,
transition: 'opacity 0.3s',
}}
/>
</div>
);
}
반응형 이미지 — srcset과 sizes
srcset으로 해상도별 이미지 제공
function ResponsiveImage({ alt }) {
return (
<img
srcSet="
/images/hero-480w.webp 480w,
/images/hero-768w.webp 768w,
/images/hero-1200w.webp 1200w,
/images/hero-1920w.webp 1920w
"
sizes="
(max-width: 480px) 100vw,
(max-width: 768px) 100vw,
(max-width: 1200px) 80vw,
60vw
"
src="/images/hero-1200w.webp" // 폴백
alt={alt}
loading="lazy"
/>
);
}
srcSet: 여러 크기의 이미지와 각각의 실제 너비(w 단위)를 선언sizes: 뷰포트 크기에 따라 이미지가 차지할 크기를 선언- 브라우저가 DPR(Device Pixel Ratio)과 뷰포트를 고려하여 최적 이미지를 자동 선택
picture 요소로 포맷 분기
function OptimizedImage({ src, alt, width, height }) {
const baseName = src.replace(/\.[^.]+$/, '');
return (
<picture>
{/* AVIF — 가장 작지만 지원 범위 좁음 */}
<source srcSet={`${baseName}.avif`} type="image/avif" />
{/* WebP — 좋은 압축률, 넓은 지원 */}
<source srcSet={`${baseName}.webp`} type="image/webp" />
{/* JPEG — 폴백 */}
<img src={`${baseName}.jpg`} alt={alt} width={width} height={height} loading="lazy" />
</picture>
);
}
브라우저는 위에서부터 지원하는 첫 번째 source를 사용합니다.
이미지 포맷 비교
| 포맷 | 크기 (vs JPEG) | 투명도 | 애니메이션 | 브라우저 지원 |
|---|---|---|---|---|
| JPEG | 기준 | X | X | 모든 브라우저 |
| PNG | 2-3배 | O | X | 모든 브라우저 |
| WebP | 25-35% 작음 | O | O | 97%+ |
| AVIF | 50% 작음 | O | O | 92%+ |
선택 기준
- **사진 **: WebP 우선, AVIF는 추가 source로
- ** 투명 배경 **: WebP 또는 PNG
- ** 아이콘/로고 **: SVG (벡터라 스케일 자유로움)
- ** 간단한 그래픽 **: SVG 또는 WebP
CLS(Layout Shift) 방지
이미지 로드 전 공간을 확보하지 않으면 레이아웃이 밀립니다.
// 방법 1: width/height 명시
<img src={src} alt={alt} width={800} height={600} />
// 방법 2: aspect-ratio CSS
function AspectImage({ src, alt, aspectRatio = '16/9' }) {
return (
<div style={{ aspectRatio, overflow: 'hidden' }}>
<img
src={src}
alt={alt}
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
);
}
// 방법 3: padding-top 해킹 (레거시)
function PaddingImage({ src, alt }) {
return (
<div style={{ position: 'relative', paddingTop: '56.25%' /* 16:9 */ }}>
<img
src={src}
alt={alt}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' }}
/>
</div>
);
}
next/image — Next.js의 이미지 최적화
Next.js를 사용한다면 next/image가 대부분의 최적화를 자동으로 처리합니다.
import Image from 'next/image';
function HeroSection() {
return (
<Image
src="/images/hero.jpg"
alt="히어로 이미지"
width={1200}
height={600}
priority // LCP 이미지는 priority 설정
placeholder="blur" // 블러 플레이스홀더
blurDataURL={blurDataUrl} // 작은 블러 이미지 데이터
/>
);
}
function GalleryItem({ photo }) {
return (
<Image
src={photo.url}
alt={photo.description}
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
// lazy loading은 기본 활성화
/>
);
}
next/image가 자동으로 하는 것
- **리사이징 **: 요청된 크기에 맞게 서버에서 리사이즈
- ** 포맷 변환 **: 브라우저가 지원하면 WebP/AVIF로 자동 변환
- lazy loading: 기본 활성화
- **CLS 방지 **: width/height 필수로 요구
- ** 캐싱 **: CDN 수준의 이미지 캐싱
React 프로젝트에서의 이미지 최적화 체크리스트
- ** 포맷 **: WebP를 기본으로, AVIF를 보너스로 제공
- ** 크기 **: 표시 크기에 맞는 이미지 제공 (2배 해상도까지)
- lazy loading: 스크롤 아래 이미지에
loading="lazy"적용 - **LCP 이미지 **: 첫 화면의 중요 이미지는
loading="eager"+fetchPriority="high" - **CLS 방지 **:
width/height또는aspect-ratio명시 - srcset: 반응형 이미지로 디바이스별 최적 크기 제공
- ** 압축 **: 빌드 프로세스에서 이미지 압축 자동화
정리
이미지 최적화는 웹 성능에서 가장 효과가 큰 영역 중 하나입니다.
- lazy loading: 보이지 않는 이미지의 로드를 지연시켜 초기 로딩을 빠르게 합니다
- ** 반응형 이미지 **: srcset과 sizes로 디바이스에 맞는 크기의 이미지를 제공합니다
- ** 최신 포맷 **: WebP/AVIF로 같은 품질에서 파일 크기를 크게 줄입니다
- **CLS 방지 **: width/height를 명시하여 레이아웃 시프트를 방지합니다
- next/image: Next.js를 사용한다면 대부분의 최적화를 자동으로 처리합니다
주의할 점
LCP 이미지에 lazy loading을 적용하면 오히려 성능 저하
첫 화면에 보이는 가장 큰 이미지(LCP 이미지)에 loading="lazy"를 적용하면, 로드 시작이 지연되어 LCP 점수가 악화됩니다. LCP 이미지는 loading="eager" 또는 priority로 설정해야 합니다.
width/height 없이 이미지를 배치하면 CLS 발생
이미지의 크기를 명시하지 않으면 로드 완료 시 레이아웃이 밀리는(Layout Shift) 현상이 발생합니다. width와 height 속성 또는 aspect-ratio CSS로 공간을 미리 확보해야 합니다.
LCP 점수를 개선하고 싶다면, 첫 화면에서 가장 큰 이미지를 먼저 최적화해야 합니다.