이미지는 웹 성능에 가장 큰 영향을 주는 자산 중 하나입니다. 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 앱에서 이미지 로딩 성능을 안정적으로 개선할 수 있습니다. 작은 변경부터 적용해 지표와 체감을 함께 개선해 보시기 바랍니다.
'React' 카테고리의 다른 글
| React Testing Library로 컴포넌트 단위 테스트 작성하기 (0) | 2026.04.17 |
|---|---|
| React에서 Error Boundary로 오류 처리하기 (1) | 2026.04.16 |
| React에서 Formik과 Yup으로 폼 검증 구현하기 (0) | 2026.04.15 |
| React Router v6의 최신 기능과 사용법 (0) | 2026.04.15 |
| React Suspense로 데이터 로딩 경험 개선하기 (0) | 2026.04.14 |