
이번 글에서는 IntersectionObserver를 활용해 이미지 Lazy Load하는 방법을 알아보고자 한다.
이미지의 경우 LCP 지표에 안 좋은 영향을 미칠 수 있는 가능성이 가장 큰 요소이다. 따라서 이미지를 로드할 때 압축, Lazy Load, 캐싱 등 적절한 최적화가 필요하다.
Next.js의 Image 컴포넌트를 사용하면 기본적으로 이미지를 Lazy Loading 한다. (참고)
라이브러리나 Next.js 프레임워크를 사용하면 편하게 이미지의 Lazy Loading를 적용할 수 있지만 나는 직접 Web API를 활용해 구현해보고 싶어졌다.
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
});
};
이제 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>
)
}
먼저 data- 속성을 활용해서 이미지 ULR를 저장했다. 이렇게 되면 실제 이미지는 노출되지 않는다.
그리고 컴포넌트가 마운트된 후 new IntersectionObserver 생성자를 통해 observer 인스턴스를 생성해준다.
그리고 모든 이미지 요소에 대해 observer.observe() 메서드를 통해 주시 대상 목록에 추가한다.
이미지가 현재 뷰포트와 교차되고 50% 이상 보인다면, isIntersecting = true (참고)이기 때문에 이때 data-src의 ULR을 src에 넣어줘서 이미지가 노출되도록 해준다.
위에서 구현한 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 속성을 가지고 있는 엘리먼트를 모두 관찰 대상으로 등록할 수 있도록 훅을 구성했다.