IntersectionObserver를 통해 이미지 Lazy Loading 구현하기

이희제·2025년 1월 9일
post-thumbnail

이번 글에서는 IntersectionObserver를 활용해 이미지 Lazy Load하는 방법을 알아보고자 한다.

이미지의 경우 LCP 지표에 안 좋은 영향을 미칠 수 있는 가능성이 가장 큰 요소이다. 따라서 이미지를 로드할 때 압축, Lazy Load, 캐싱 등 적절한 최적화가 필요하다.

Next.js의 Image 컴포넌트를 사용하면 기본적으로 이미지를 Lazy Loading 한다. (참고)

라이브러리나 Next.js 프레임워크를 사용하면 편하게 이미지의 Lazy Loading를 적용할 수 있지만 나는 직접 Web API를 활용해 구현해보고 싶어졌다.

1. IntersectionObserver란?

IntersectionObserver는 특정 DOM 요소가 뷰포트(또는 부모 컨테이너)와 교차(intersect)하는지 관찰할 수 있다.

이를 활용하면 스크롤 이벤트를 직접 제어하지 않고도, 요소가 보이기 시작하거나 사라질 때 원하는 동작을 수행할 수 있다.

let options = {
  root: document.querySelector("#scrollArea"),
  rootMargin: "0px",
  threshold: 1.0,
};

let observer = new IntersectionObserver(callback, options);

options에 대해 각각 살펴보자.

  • root: 관찰 기준이 되는 요소. 기본값은 null로 브라우저의 뷰포트를 기준으로 한다.
  • rootMargin: 관찰 기준을 확장하거나 축소하는 여백. 예를 들어 "0px 0px 100px 0px"은 뷰포트 아래 100px까지 확장된 영역에서 감지한다.
  • threshold: 요소가 몇 퍼센트 보일 때 동작할지 결정. 예를 들어, 0.1은 요소가 10% 보일 때, 1.0은 100% 보일 때 동작한다.
    • [0, 0.25, 0.5, 0.75, 1] 다음과 같이 배열을 넘기게 되면 가시성이 각 배열 요소의 퍼센트에 도달할 때마다 callback 함수가 실행된다.

그리고 callback 함수의 경우 IntersectionObserverEntry 객체와 관찰자 목록를 인자로 받는다. 각각 인자는 MDN을 참고하자.

let callback = (entries, observer) => {
  entries.forEach((entry) => {
    // 각 엔트리는 관찰된 하나의 교차 변화을 설명합니다.
    // 대상 요소:
    //   entry.boundingClientRect
    //   entry.intersectionRatio
    //   entry.intersectionRect
    //   entry.isIntersecting
    //   entry.rootBounds
    //   entry.target
    //   entry.time
  });
};

2. 이미지 Lazy Load 직접 구현해보기

이제 IntersectionObserver를 활용해서 직접 Lazy Loading를 구현해보자.

현재 뷰포트를 기준으로 뷰포트에 이미지가 교차될 경우에만 이미지를 Load 해줄 것이다.

아래 코드에서 images 데이터는 항상 있다고 가정을 하자.

구현 코드

function App() {
  const imageContainerRef = useRef<HTMLDivElement | null>(null);
  const [images, setImages] = useState<string[]>([])

  useEffect(() => {
    if(images.length===0) return 
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            const imgSrc = entry.target.getAttribute("data-src")
            if(imgSrc){
              entry.target.setAttribute("src", imgSrc)
            }
            observer.unobserve(entry.target);
          }
        });
      },
      {
        root: null, // 현재 뷰포트를 기준으로 설정
        rootMargin: "0px 0px 0px 0px",
        threshold: 0.5
      }
    );
     
    const imageElements = imageContainerRef.current?.querySelectorAll(".lazy-image");
    imageElements?.forEach((element) => observer.observe(element));

    return () => observer.disconnect();
  }, [images]);


  return (
      <div>
        <div className='image-container' ref={imageContainerRef}>
        {images.map((src, index) => (
            <div
              key={index}
            >
              <img
                data-index={index}
                data-src={src}
                className="lazy-image"
              />

            </div>
          ))}
        </div> 
      </div>
	)
}
  1. 먼저 data- 속성을 활용해서 이미지 ULR를 저장했다. 이렇게 되면 실제 이미지는 노출되지 않는다.

  2. 그리고 컴포넌트가 마운트된 후 new IntersectionObserver 생성자를 통해 observer 인스턴스를 생성해준다.

  3. 그리고 모든 이미지 요소에 대해 observer.observe() 메서드를 통해 주시 대상 목록에 추가한다.

  4. 이미지가 현재 뷰포트와 교차되고 50% 이상 보인다면, isIntersecting = true (참고)이기 때문에 이때 data-src의 ULR을 src에 넣어줘서 이미지가 노출되도록 해준다.

결과

업로드중..

3. 커스텀 훅으로 Lazy Loading 로직 분리하기

위에서 구현한 IntersectionObserver를 활용한 로직의 재사용성을 높이기 위해 커스텀 훅으로 분리를 해보자.

커스텀 훅

import { useRef, useEffect } from "react";

const DEFAULT_OPTIONS = {
    root: null,
    rootMargin: "0px",
    threshold: 0
}

type Params = {
    options: {
        root: HTMLElement | null,
        rootMargin: string,
        threshold: number
    },
    onIntersectCallback: (entry: IntersectionObserverEntry) => void
}

const useIntersectionObserver = ({options = DEFAULT_OPTIONS, onIntersectCallback}: Params) => {
    const targetContainer = useRef<HTMLElement>(null);

    useEffect(() => {
        if (!targetContainer.current) return;

        const observer = new IntersectionObserver((entries) => {
            entries.forEach((entry) => {
                if (entry.isIntersecting) {
                    onIntersectCallback(entry);
                    observer.unobserve(entry.target);
                }
            });
        }, options);
        
        
        const elements = targetContainer?.current?.querySelectorAll("[data-observe]");
        elements?.forEach((element) => observer.observe(element));

        return () => observer.disconnect();
    }, [onIntersectCallback, options]);
    
    return targetContainer;
}

export default useIntersectionObserver

사용

function App() {

  const [images, setImages] = useState<string[]>([])

  const imageContainerRef = useIntersectionObserver({
    options: {
      root: null, 
      rootMargin: "0px 0px 100px 0px",
      threshold: 0.5
    },
    onIntersectCallback: useCallback((entry: IntersectionObserverEntry) => {
      const {target} = entry
      const imgSrc = target.getAttribute("data-src");
      if (imgSrc) {
        target.setAttribute("src", imgSrc);
      }
    }, [])
  }
)

  return (
    <div>
      <div className='image-container' ref={imageContainerRef}>
        {images.map((src, index) => (
            <img
              key={src}
              data-index={index}
              data-src={src}
              data-observe
              className="lazy-image"
            />
          ))}
      </div> 
    </div>
	)
}

특정 엘리먼트 하위에 data-observe 속성을 가지고 있는 엘리먼트를 모두 관찰 대상으로 등록할 수 있도록 훅을 구성했다.

profile
그냥 하자

0개의 댓글