페이지에 이미지 50개가 있다면, 사용자가 맨 아래까지 스크롤하지 않아도 50개를 모두 다운로드해야 할까요?

웹 페이지의 총 전송량 중 이미지가 차지하는 비중은 평균 50% 이상입니다. 이미지 최적화는 번들 최적화만큼이나 중요하며, 적절한 크기/포맷/로딩 전략만으로도 LCP(Largest Contentful Paint)를 크게 개선할 수 있습니다.

Native Lazy Loading

HTML의 loading="lazy" 속성은 이미지가 뷰포트에 가까워질 때까지 로드를 지연시킵니다.

JSX
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 이미지는 즉시 로드되어야 합니다
  • widthheight를 명시하여 레이아웃 시프트(CLS)를 방지합니다
  • 대부분의 모던 브라우저에서 지원합니다
JSX
// 첫 화면 이미지는 lazy가 아닌 eager
<img src={heroImage} alt="히어로 이미지" loading="eager" fetchPriority="high" />

// 아래쪽 이미지는 lazy
<img src={galleryImage} alt="갤러리" loading="lazy" />

IntersectionObserver로 커스텀 Lazy Loading

네이티브 lazy loading보다 더 세밀한 제어가 필요할 때 IntersectionObserver를 사용합니다.

JSX
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>
  );
}

블러 플레이스홀더

JSX
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으로 해상도별 이미지 제공

JSX
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 요소로 포맷 분기

JSX
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기준XX모든 브라우저
PNG2-3배OX모든 브라우저
WebP25-35% 작음OO97%+
AVIF50% 작음OO92%+

선택 기준

  • **사진 **: WebP 우선, AVIF는 추가 source로
  • ** 투명 배경 **: WebP 또는 PNG
  • ** 아이콘/로고 **: SVG (벡터라 스케일 자유로움)
  • ** 간단한 그래픽 **: SVG 또는 WebP

CLS(Layout Shift) 방지

이미지 로드 전 공간을 확보하지 않으면 레이아웃이 밀립니다.

JSX
// 방법 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가 대부분의 최적화를 자동으로 처리합니다.

JSX
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 프로젝트에서의 이미지 최적화 체크리스트

  1. ** 포맷 **: WebP를 기본으로, AVIF를 보너스로 제공
  2. ** 크기 **: 표시 크기에 맞는 이미지 제공 (2배 해상도까지)
  3. lazy loading: 스크롤 아래 이미지에 loading="lazy" 적용
  4. **LCP 이미지 **: 첫 화면의 중요 이미지는 loading="eager" + fetchPriority="high"
  5. **CLS 방지 **: width/height 또는 aspect-ratio 명시
  6. srcset: 반응형 이미지로 디바이스별 최적 크기 제공
  7. ** 압축 **: 빌드 프로세스에서 이미지 압축 자동화

정리

이미지 최적화는 웹 성능에서 가장 효과가 큰 영역 중 하나입니다.

  • 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) 현상이 발생합니다. widthheight 속성 또는 aspect-ratio CSS로 공간을 미리 확보해야 합니다.

LCP 점수를 개선하고 싶다면, 첫 화면에서 가장 큰 이미지를 먼저 최적화해야 합니다.

댓글 로딩 중...