IntersectionObserver 기반 Lazy Image
웹 페이지에 이미지가 수십 개 있다고 해보자. 브라우저는 기본적으로 <img> 태그를 만나면 즉시 해당 이미지를 다운로드한다. 사용자가 아직 스크롤하지도 않은 화면 아래쪽 이미지까지 전부. 초기 페이지 로드 시 수십 개의 HTTP 요청이 동시에 발생하고, 네트워크 대역폭을 잡아먹고, 정작 사용자가 보고 있는 상단 콘텐츠의 로딩은 느려진다.
Lazy Image는 이 문제를 해결한다. 뷰포트에 진입하기 전까지는 이미지를 로드하지 않고, 사용자가 스크롤해서 해당 영역이 보이기 시작할 때 비로소 이미지를 불러온다. 로드되기 전에는 skeleton placeholder를 보여줘서 레이아웃이 갑자기 튀는 현상(layout shift)도 방지한다.
네이티브 lazy loading vs IntersectionObserver
HTML에는 이미 네이티브 lazy loading이 있다.
<img src="photo.jpg" loading="lazy" alt="photo" />
loading="lazy" 속성 하나면 브라우저가 알아서 뷰포트 근처에 올 때 이미지를 로드한다. 간단하고, JavaScript가 필요 없다. 그런데 왜 굳이 IntersectionObserver를 써서 직접 구현하는 걸까?
네이티브 방식의 한계:
| 항목 | loading="lazy" | IntersectionObserver |
|---|---|---|
| rootMargin 제어 | 브라우저 결정 (조정 불가) | 자유롭게 설정 |
| 로딩 중 UI | 없음 (빈 공간) | skeleton, blur-up 등 자유 |
| 로드 완료 애니메이션 | 불가 | fade-in 등 가능 |
| 조건부 로딩 | 불가 | 네트워크 상태, 사용자 설정 등 반영 가능 |
| 브라우저 지원 | Chrome/Edge/Firefox OK, 일부 구형 미지원 | 폴리필 가능 |
핵심 차이는 제어권이다. 네이티브 방식은 편하지만 로딩 시작 타이밍, 로딩 중 UI, 로드 완료 후 전환 효과를 커스터마이즈할 수 없다. 사용자 경험을 세밀하게 다듬어야 하는 상황에서는 IntersectionObserver로 직접 구현하는 게 낫다.
구현 원리
Lazy Image 컴포넌트의 핵심 동작은 세 단계다:
- 초기 상태: 이미지를 렌더링하지 않고 skeleton placeholder만 표시
- 뷰포트 진입 감지: IntersectionObserver가 요소가 화면에 들어온 걸 감지하면
<img>태그를 DOM에 삽입 - 로드 완료 전환: 이미지의
onLoad이벤트가 발생하면 skeleton을 숨기고 이미지를 fade-in
이걸 React로 구현하면 두 개의 상태가 필요하다:
const [isInView, setIsInView] = useState(false); // 뷰포트에 들어왔는가
const [isLoaded, setIsLoaded] = useState(false); // 이미지 로드가 완료됐는가
isInView가 false면 <img> 태그 자체가 DOM에 없다. 브라우저는 DOM에 없는 이미지를 다운로드하지 않으므로, 네트워크 요청이 발생하지 않는다. isInView가 true로 바뀌면 <img>가 렌더링되면서 브라우저가 다운로드를 시작한다. 다운로드가 끝나면 onLoad가 발생하고 isLoaded가 true가 된다.
IntersectionObserver 설정
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: "50px" }
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
여기서 주목할 점이 몇 가지 있다.
rootMargin: "50px"
뷰포트 경계에서 50px 바깥까지 감지 영역을 확장한다. 즉, 이미지가 화면에 보이기 50px 전에 미리 로드를 시작한다. 사용자가 스크롤하는 동안 이미지가 이미 로딩을 시작해서, 실제로 보이는 시점에는 로드가 완료되어 있을 가능성이 높다.
이 값을 얼마로 설정할지는 트레이드오프다:
- 작은 값 (0~50px): 네트워크 요청을 최대한 지연시켜 대역폭 절약. 하지만 스크롤 속도가 빠르면 이미지가 로드되기 전에 빈 영역이 보일 수 있다.
- 큰 값 (200~500px): 미리 로드해서 부드러운 경험. 하지만 사용자가 실제로 보지 않을 이미지까지 로드할 수 있다.
일반적으로 50~200px 정도가 적절하다.
observer.disconnect()
요소가 한 번 뷰포트에 들어오면 observer를 즉시 해제한다. 이미 로드를 시작한 이미지를 계속 관찰할 이유가 없다. unobserve(entry.target) 대신 disconnect()를 쓴 이유는 이 observer가 단일 요소만 관찰하기 때문이다. 하나의 LazyImage 컴포넌트 = 하나의 observer = 하나의 관찰 대상이므로 disconnect()로 완전히 정리하는 게 깔끔하다.
cleanup 함수
return () => observer.disconnect();
React의 useEffect cleanup에서 observer를 정리한다. 컴포넌트가 언마운트될 때 observer가 남아서 메모리 누수가 발생하는 걸 방지한다. 특히 리스트에서 아이템이 동적으로 추가/제거되는 상황에서 중요하다.
Skeleton Placeholder
이미지가 로드되기 전에 보여줄 placeholder다. 단순히 빈 공간을 두면 이미지가 로드될 때 레이아웃이 밀리는 CLS(Cumulative Layout Shift) 문제가 생긴다. Skeleton은 이미지와 같은 크기의 자리를 미리 차지하면서, 로딩 중이라는 시각적 피드백도 제공한다.
{!isLoaded && (
<div className="absolute inset-0 bg-gray-300 animate-pulse rounded-lg" />
)}
absolute inset-0으로 부모 요소를 꽉 채우고, animate-pulse로 밝기가 반복적으로 변하는 애니메이션을 적용한다. Tailwind CSS의 animate-pulse는 내부적으로 이런 keyframe이다:
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
이 skeleton은 isLoaded가 true가 되면 조건부 렌더링에 의해 DOM에서 사라진다.
이미지 렌더링과 Fade-in 전환
{isInView && (
<img
src={src}
alt={alt}
className={cn(
"w-full h-full object-cover transition-opacity duration-300",
isLoaded ? "opacity-100" : "opacity-0"
)}
onLoad={() => setIsLoaded(true)}
/>
)}
이 코드의 동작 흐름:
isInView가false일 때:<img>태그 자체가 DOM에 없다. 네트워크 요청 없음.isInView가true로 바뀌면:<img>가 렌더링된다. 하지만opacity-0이라 보이지 않는다. 브라우저가srcURL로 다운로드를 시작한다.- 다운로드 완료 →
onLoad발생 →isLoaded가true로 변경 opacity-0→opacity-100으로 변경.transition-opacity duration-300에 의해 300ms 동안 fade-in.
이 방식의 장점은 이미지가 갑자기 툭 나타나지 않는다는 것이다. 부드럽게 fade-in되면서 skeleton에서 자연스럽게 전환된다.
object-cover vs object-contain
object-cover는 이미지의 비율을 유지하면서 컨테이너를 꽉 채운다. 이미지가 컨테이너 비율과 다르면 일부가 잘린다. 카드형 레이아웃이나 썸네일에 적합하다.
/* 컨테이너가 300x200인데 이미지가 400x400이면 */
object-cover → 가로를 300에 맞추면 세로 300. 위아래 50px씩 잘림.
object-contain → 세로를 200에 맞추면 가로 200. 좌우에 빈 공간.
대부분의 카드 UI에서는 object-cover가 자연스럽다. 빈 공간 없이 깔끔하게 채워지기 때문이다.
cn 유틸리티 함수
조건부로 className을 조합할 때 사용하는 유틸리티다.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
clsx는 조건부 클래스 결합을 처리하고, twMerge는 Tailwind 클래스의 충돌을 해결한다.
// clsx만 사용하면
clsx("px-4", "px-8") // → "px-4 px-8" (충돌!)
// twMerge를 거치면
twMerge("px-4", "px-8") // → "px-8" (나중 값이 이김)
Tailwind를 사용하는 프로젝트에서 컴포넌트에 외부 className을 받아야 할 때, cn을 쓰면 기본 스타일과 외부 스타일이 충돌하지 않는다.
전체 컴포넌트 구조
완성된 LazyImage 컴포넌트의 전체 구조를 보자:
import { useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface LazyImageProps {
src: string;
alt: string;
className?: string;
wrapperClassName?: string;
}
export const LazyImage = ({
src,
alt,
className,
wrapperClassName,
}: LazyImageProps) => {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: "50px" }
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div
ref={imgRef}
className={cn("relative overflow-hidden", wrapperClassName)}
>
{!isLoaded && (
<div className="absolute inset-0 bg-gray-300 animate-pulse rounded-lg" />
)}
{isInView && (
<img
src={src}
alt={alt}
className={cn(
"w-full h-full object-cover transition-opacity duration-300",
isLoaded ? "opacity-100" : "opacity-0",
className
)}
onLoad={() => setIsLoaded(true)}
/>
)}
</div>
);
};
wrapper div에 ref를 걸고, relative overflow-hidden으로 내부의 absolute positioned skeleton이 밖으로 삐져나가지 않게 한다. wrapperClassName으로 외부에서 크기(width, height, aspect-ratio)를 지정할 수 있다.
사용 예시
// 카드 썸네일
<LazyImage
src="https://example.com/photo.jpg"
alt="게시글 썸네일"
wrapperClassName="w-full aspect-video rounded-xl"
/>
// 프로필 이미지 (원형)
<LazyImage
src="https://example.com/avatar.jpg"
alt="프로필"
wrapperClassName="w-12 h-12 rounded-full"
className="rounded-full"
/>
// 리스트에서 대량의 이미지
{posts.map((post) => (
<LazyImage
key={post.id}
src={post.thumbnail}
alt={post.title}
wrapperClassName="w-full h-48"
/>
))}
리스트에서 수십 개의 LazyImage를 렌더링해도, 뷰포트 밖의 이미지는 로드되지 않기 때문에 초기 로딩 성능에 영향을 주지 않는다.
성능 고려사항
하나의 Observer vs 여러 Observer
위 구현은 컴포넌트 인스턴스마다 개별 IntersectionObserver를 생성한다. 이미지가 100개면 observer도 100개다. 이게 성능 문제가 될까?
실제로는 대부분의 경우 문제가 되지 않는다. IntersectionObserver는 브라우저가 내부적으로 최적화해서 관리하며, 각 observer의 오버헤드가 매우 작다. 하지만 이미지가 수천 개라면 단일 observer로 여러 요소를 관찰하는 방식이 더 효율적이다:
// 단일 observer로 여러 요소 관찰하는 매니저 패턴
class LazyImageManager {
private observer: IntersectionObserver;
private callbacks = new Map<Element, () => void>();
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.callbacks.get(entry.target)?.();
this.observer.unobserve(entry.target);
this.callbacks.delete(entry.target);
}
});
},
{ rootMargin: "50px" }
);
}
observe(element: Element, callback: () => void) {
this.callbacks.set(element, callback);
this.observer.observe(element);
}
unobserve(element: Element) {
this.observer.unobserve(element);
this.callbacks.delete(element);
}
}
하지만 일반적인 카드 리스트(수십~수백 개)에서는 컴포넌트별 개별 observer 방식이 코드가 단순하고 충분히 빠르다.
이미지 크기와 srcset
Lazy loading은 언제 로드할지를 제어하지만, 무엇을 로드할지도 중요하다. 모바일에서 2000px 원본 이미지를 로드하면 lazy loading의 의미가 반감된다.
<img
src={src}
srcSet={`${src}?w=400 400w, ${src}?w=800 800w, ${src}?w=1200 1200w`}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
alt={alt}
/>
srcset과 sizes를 함께 사용하면 브라우저가 디바이스 화면 크기와 해상도에 맞는 최적의 이미지를 선택한다. CDN에서 이미지 리사이징을 지원한다면 lazy loading과 조합해서 사용하는 게 좋다.
blur-up 기법
skeleton 대신 저해상도 이미지를 먼저 보여주고, 원본이 로드되면 교체하는 blur-up 기법도 있다. Instagram이나 Medium에서 볼 수 있는 방식이다.
const BlurUpImage = ({ src, placeholder, alt }: BlurUpImageProps) => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<div className="relative overflow-hidden">
{/* 저해상도 placeholder (base64 인코딩된 작은 이미지) */}
<img
src={placeholder}
alt=""
className={cn(
"absolute inset-0 w-full h-full object-cover",
"filter blur-lg scale-110",
isLoaded && "opacity-0 transition-opacity duration-500"
)}
/>
{/* 원본 이미지 */}
<img
src={src}
alt={alt}
className={cn(
"w-full h-full object-cover transition-opacity duration-500",
isLoaded ? "opacity-100" : "opacity-0"
)}
onLoad={() => setIsLoaded(true)}
/>
</div>
);
};
placeholder는 보통 20~40px 크기의 아주 작은 이미지를 base64로 인코딩해서 HTML에 인라인으로 포함한다. 추가 HTTP 요청 없이 즉시 표시되고, blur-lg로 흐릿하게 보여주면 저해상도인 게 티가 나지 않는다. 원본이 로드되면 blur 이미지가 fade-out되면서 자연스럽게 전환된다.
skeleton(회색 박스 + pulse 애니메이션)보다 blur-up이 사용자 경험이 더 좋다는 의견이 많다. 실제 이미지의 색감을 미리 보여주기 때문에 "갑자기 이미지가 튀어나오는" 느낌이 줄어든다.
정리
- 뷰포트 진입 전까지
<img>태그를 DOM에 넣지 않아 네트워크 요청을 지연시키고,onLoad이벤트로 fade-in 전환하면 skeleton → 이미지가 자연스럽게 이어진다. rootMargin으로 미리 로드 시작 타이밍을 조절하고,observer.disconnect()로 한 번 감지 후 즉시 정리하면 불필요한 관찰을 막을 수 있다.- 네이티브
loading="lazy"는 간편하지만 로딩 중 UI나 전환 효과를 커스터마이즈할 수 없으므로, 세밀한 UX가 필요하면 IntersectionObserver 직접 구현이 낫다.
관련 문서
- IntersectionObserver - IntersectionObserver API 기본 개념
- 무한 스크롤 - IntersectionObserver 기반 무한 스크롤 구현
- 이미지 프리로드 - 프리로드 전략과 성능 최적화