본문 바로가기

React

React 앱에서 Lazy Loading 이미지 처리하기

이미지는 웹 성능에 가장 큰 영향을 주는 자산 중 하나입니다. Lazy Loading은 화면에 보이기 전까지 이미지를 지연 로드하여 초기 로드 시간을 단축하고, 네트워크 사용량을 줄이며, 사용자 경험을 개선합니다. React에서 실무적으로 활용 가능한 Lazy Loading 전략과 코드 예제를 정리했습니다.

1. 무엇이 왜 중요한가

- 초기 렌더에 필요하지 않은 이미지를 늦게 불러오면 LCP, TTI 같은 핵심 지표가 개선됩니다.

- 스크롤 목록, 카드 그리드 등 이미지가 많은 화면에서 효과가 큽니다.

- 단, 첫 화면의 히어로 이미지는 Lazy Loading을 피하고 적극적으로 빠르게 로드해야 합니다.

2. 가장 쉬운 방법: 네이티브 loading="lazy"

브라우저가 제공하는 네이티브 속성으로 즉시 도입할 수 있는 방법입니다. 보이는 영역 근처에 도달하면 자동으로 로드합니다.

import React from 'react';

function ProductImage({ src, alt, width, height }) {
  return (
    <img
      src={src}
      alt={alt}
      loading="lazy"
      decoding="async"
      width={width}
      height={height}
      style={{ display: 'block', width: '100%', height: 'auto' }}
    />
  );
}

실무 팁:

- 첫 화면의 핵심 이미지에는 loading="eager" 또는 fetchPriority="high"를 고려합니다.

- CLS를 막기 위해 width/height 또는 aspect-ratio를 반드시 지정합니다.

3. 더 유연한 제어: IntersectionObserver 훅 만들기

네이티브 속성만으로 부족할 때, IntersectionObserver로 뷰포트 근처에서 원하는 타이밍에 로드하도록 제어합니다.

import React from 'react';

function useInView(options = { rootMargin: '200px 0px', threshold: 0 }) {
  const ref = React.useRef(null);
  const [inView, setInView] = React.useState(false);

  React.useEffect(() => {
    if (inView) return; // 한 번 보이면 유지
    const el = ref.current;
    if (!el || typeof window === 'undefined') return;
    if (!('IntersectionObserver' in window)) {
      setInView(true); // 폴백: 즉시 로드
      return;
    }

    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setInView(true);
        io.disconnect();
      }
    }, options);

    io.observe(el);
    return () => io.disconnect();
  }, [inView, options]);

  return { ref, inView };
}

rootMargin을 넉넉히 주면 사용자에게 보이기 직전에 미리 로드되어 체감이 부드럽습니다(예: '200px 0px').

4. 재사용 가능한 LazyImage 컴포넌트

플레이스홀더, 페이드 인, 오류 처리까지 포함한 실무형 컴포넌트 예시입니다.

import React from 'react';

function useInView(options = { rootMargin: '200px 0px', threshold: 0 }) {
  const ref = React.useRef(null);
  const [inView, setInView] = React.useState(false);
  React.useEffect(() => {
    if (inView) return;
    const el = ref.current;
    if (!el || typeof window === 'undefined') return;
    if (!('IntersectionObserver' in window)) { setInView(true); return; }
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) { setInView(true); io.disconnect(); }
    }, options);
    io.observe(el);
    return () => io.disconnect();
  }, [inView, options]);
  return { ref, inView };
}

export function LazyImage({
  src,
  alt,
  placeholder = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==',
  width,
  height,
  className,
  srcSet,
  sizes,
  fetchPriority = 'auto',
  style,
}) {
  const { ref, inView } = useInView({ rootMargin: '200px 0px' });
  const [loaded, setLoaded] = React.useState(false);
  const actualSrc = inView ? src : placeholder;
  const isReal = inView;

  return (
    <img
      ref={ref}
      src={actualSrc}
      srcSet={isReal ? srcSet : undefined}
      sizes={isReal ? sizes : undefined}
      alt={alt}
      width={width}
      height={height}
      decoding="async"
      fetchPriority={isReal ? fetchPriority : 'low'}
      onLoad={() => setLoaded(true)}
      onError={(e) => {
        if (!isReal) return;
        e.currentTarget.src = placeholder;
        e.currentTarget.style.filter = 'none';
        e.currentTarget.style.opacity = '1';
      }}
      style={{
        display: 'block',
        width: '100%',
        height: 'auto',
        objectFit: 'cover',
        opacity: loaded ? 1 : 0,
        transition: 'opacity 240ms ease',
        backgroundColor: '#f2f3f5',
        ...style,
      }}
      className={className}
    />
  );
}

주의: IntersectionObserver 방식에서는 loading="lazy"를 굳이 같이 쓰지 않아도 됩니다. 두 방식을 중복 적용하면 타이밍 제어가 애매해질 수 있습니다.

5. 블러 플레이스홀더로 부드러운 전환

저해상도(혹은 단색) 플레이스홀더를 먼저 보여주고, 원본 로드가 끝나면 블러를 제거해 자연스러운 전환을 만들 수 있습니다.

function BlurLazyImage(props) {
  const [loaded, setLoaded] = React.useState(false);
  return (
    <LazyImage
      {...props}
      style={{
        filter: loaded ? 'none' : 'blur(12px)',
        transition: 'filter 300ms ease, opacity 240ms ease',
      }}
      // LazyImage의 onLoad가 먼저 실행되므로 상태 동기화
      // 필요 시 onLoad를 오버라이드하여 직접 제어
      // 예: onLoad={(e) => { setLoaded(true); props.onLoad?.(e); }}
    />
  );
}

플레이스홀더로는 아주 작은 Base64 이미지, dominant color, SVG shimmer 등을 사용할 수 있습니다.

6. 라이브러리 활용: react-intersection-observer

훅을 직접 만들기 어렵다면 검증된 라이브러리를 사용합니다.

import React from 'react';
import { useInView } from 'react-intersection-observer';

function LazyImg({ src, alt, placeholder }) {
  const { ref, inView } = useInView({ rootMargin: '200px 0px', triggerOnce: true });
  return (
    <img
      ref={ref}
      src={inView ? src : (placeholder || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==')}
      alt={alt}
      width={800}
      height={450}
      decoding="async"
      style={{ display: 'block', width: '100%', height: 'auto' }}
    />
  );
}

이외에도 react-lazy-load-image-component 등도 손쉽게 적용 가능합니다.

7. 스크롤 목록과의 조합: 가상화 + Lazy

이미지가 많은 피드에서는 가상 스크롤을 함께 사용하면 메모리와 DOM 비용을 크게 줄일 수 있습니다.

import React from 'react';
import { FixedSizeList as List } from 'react-window';

function Row({ index, style, data }) {
  const item = data[index];
  return (
    <div style={style}>
      <LazyImage src={item.src} alt={item.alt} width={400} height={300} />
    </div>
  );
}

export default function Gallery({ items }) {
  return (
    <List
      height={600}
      itemCount={items.length}
      itemSize={320}
      itemData={items}
      overscanCount={3}
      width={420}
    >
      {Row}
    </List>
  );
}

overscanCount를 적절히 늘려 미리 렌더하면 스크롤 시 깜빡임을 줄일 수 있습니다.

8. 첫 화면(히어로) 이미지는 빠르게

LCP에 직접 영향을 주는 히어로 이미지는 지연 로딩을 피하고 우선순위를 높입니다.

export function Hero() {
  return (
    <img
      src="/hero.jpg"
      alt="프로덕트 히어로"
      width={1200}
      height={630}
      loading="eager"
      fetchPriority="high"
      decoding="async"
      style={{ width: '100%', height: 'auto', display: 'block' }}
    />
  );
}

가능하면 preload(link rel="preload")나 적절한 캐시 전략을 함께 구성합니다.

9. 반응형 이미지와 CLS 방지

Lazy Loading과 함께 srcSet/sizes를 사용해 기기별로 알맞은 사이즈만 전송하면 네트워크 낭비가 줄어듭니다. 또한 width/height 또는 aspect-ratio로 레이아웃 변형을 방지합니다.

<LazyImage
  src="/images/photo-1600.jpg"
  alt="풍경"
  width={1600}
  height={900}
  srcSet="/images/photo-800.jpg 800w, /images/photo-1200.jpg 1200w, /images/photo-1600.jpg 1600w"
  sizes="(max-width: 768px) 100vw, 768px"
/\>

10. 실무 체크리스트

- 첫 화면 핵심 이미지는 eager + fetchPriority="high"를 검토합니다.

- 나머지는 loading="lazy" 또는 IntersectionObserver로 처리합니다.

- rootMargin으로 미리 로드 여유를 둡니다(예: 200px).

- width/height 또는 aspect-ratio로 CLS를 막습니다.

- 플레이스홀더(단색, LQIP, 블러)로 로딩 체감을 개선합니다.

- 오류 시 대체 이미지를 표기하고 로그를 남깁니다(onError).

- 이미지 포맷(WebP/AVIF)과 CDN 리사이징을 적극 사용합니다.

- 측정은 Lighthouse/Profiler/웹 바이탈(LCP, CLS, INP)로 확인합니다.

위 전략을 조합하면 React 앱에서 이미지 로딩 성능을 안정적으로 개선할 수 있습니다. 작은 변경부터 적용해 지표와 체감을 함께 개선해 보시기 바랍니다.