Lazy Loading
지연 로딩을 통해 초기 로딩 시간을 단축하고 리소스를 효율적으로 관리하는 패턴입니다.
개요
Lazy Loading은 다음과 같은 경우에 필요합니다:
- 이미지 최적화: 뷰포트에 보이는 이미지만 로드
- 컴포넌트 지연 로딩: 사용자가 필요로 할 때 컴포넌트 로드
- 무한 스크롤: 스크롤 시 추가 콘텐츠 동적 로드
- 모달/드로어: 열릴 때만 내용 로드
- 탭/아코디언: 활성화될 때 콘텐츠 로드
기본 패턴
1. Intersection Observer로 이미지 Lazy Loading
Intersection Observer API를 사용하여 뷰포트에 진입하는 이미지만 로드합니다.
// src/components/LazyImage.tsx
import { useEffect, useRef, useState } from "react";
interface LazyImageProps {
src: string;
alt: string;
placeholder?: string;
className?: string;
width?: number;
height?: number;
}
export function LazyImage({
src,
alt,
placeholder = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCI+PHJlY3Qgd2lkdGg9IjQwMCIgaGVpZ2h0PSIzMDAiIGZpbGw9IiNlZWUiLz48L3N2Zz4=",
className = "",
width,
height,
}: LazyImageProps) {
const imgRef = useRef<HTMLImageElement>(null);
const [imageSrc, setImageSrc] = useState(placeholder);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const img = imgRef.current;
if (!img) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 이미지가 뷰포트에 진입하면 실제 이미지 로드
setImageSrc(src);
observer.unobserve(img);
}
});
},
{
rootMargin: "50px", // 뷰포트 50px 전에 미리 로드
}
);
observer.observe(img);
return () => {
if (img) observer.unobserve(img);
};
}, [src]);
return (
<img
ref={imgRef}
src={imageSrc}
alt={alt}
width={width}
height={height}
loading="lazy"
className={`transition-opacity duration-300 ${
isLoaded ? "opacity-100" : "opacity-0"
} ${className}`}
onLoad={() => setIsLoaded(true)}
/>
);
}
// 사용 예시
export function ImageGallery() {
const images = [
{ id: 1, src: "/images/photo1.jpg", alt: "Photo 1" },
{ id: 2, src: "/images/photo2.jpg", alt: "Photo 2" },
{ id: 3, src: "/images/photo3.jpg", alt: "Photo 3" },
];
return (
<div className="grid grid-cols-3 gap-4">
{images.map((image) => (
<LazyImage
key={image.id}
src={image.src}
alt={image.alt}
width={400}
height={300}
className="rounded-lg"
/>
))}
</div>
);
}2. Progressive Image Loading (Blur Placeholder)
저화질 이미지를 먼저 보여주고 고화질 이미지로 전환합니다.
// src/components/ProgressiveImage.tsx
import { useState, useEffect } from "react";
interface ProgressiveImageProps {
src: string;
placeholderSrc: string;
alt: string;
className?: string;
}
export function ProgressiveImage({
src,
placeholderSrc,
alt,
className = "",
}: ProgressiveImageProps) {
const [currentSrc, setCurrentSrc] = useState(placeholderSrc);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const img = new Image();
img.src = src;
img.onload = () => {
setCurrentSrc(src);
setIsLoading(false);
};
}, [src]);
return (
<div className="relative overflow-hidden">
<img
src={currentSrc}
alt={alt}
className={`transition-all duration-500 ${
isLoading ? "blur-sm scale-105" : "blur-0 scale-100"
} ${className}`}
/>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white" />
</div>
)}
</div>
);
}
// 사용 예시
export function HeroSection() {
return (
<ProgressiveImage
src="/images/hero-high.jpg"
placeholderSrc="/images/hero-low.jpg"
alt="Hero Image"
className="w-full h-96 object-cover"
/>
);
}3. 무한 스크롤 패턴
스크롤 끝에 도달하면 추가 데이터를 로드합니다.
// src/hooks/useInfiniteScroll.ts
import { useEffect, useRef, useCallback } from "react";
interface UseInfiniteScrollOptions {
onLoadMore: () => void;
hasMore: boolean;
isLoading: boolean;
threshold?: number;
}
export function useInfiniteScroll({
onLoadMore,
hasMore,
isLoading,
threshold = 200,
}: UseInfiniteScrollOptions) {
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const handleObserver = useCallback(
(entries: IntersectionObserverEntry[]) => {
const target = entries[0];
if (target.isIntersecting && hasMore && !isLoading) {
onLoadMore();
}
},
[hasMore, isLoading, onLoadMore]
);
useEffect(() => {
const element = loadMoreRef.current;
if (!element) return;
observerRef.current = new IntersectionObserver(handleObserver, {
rootMargin: `${threshold}px`,
});
observerRef.current.observe(element);
return () => {
if (observerRef.current && element) {
observerRef.current.unobserve(element);
}
};
}, [handleObserver, threshold]);
return loadMoreRef;
}
// 사용 예시
export function InfiniteList() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/items?page=${page}&limit=20`);
const newItems = await response.json();
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems((prev) => [...prev, ...newItems]);
setPage((prev) => prev + 1);
}
} catch (error) {
console.error("Failed to load items:", error);
} finally {
setIsLoading(false);
}
}, [page]);
const loadMoreRef = useInfiniteScroll({
onLoadMore: loadMore,
hasMore,
isLoading,
});
return (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="p-4 border rounded">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
))}
{hasMore && (
<div ref={loadMoreRef} className="py-4 text-center">
{isLoading ? (
<div className="flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
) : (
<p className="text-gray-500">스크롤하여 더 보기</p>
)}
</div>
)}
{!hasMore && (
<p className="text-center text-gray-500 py-4">
모든 항목을 불러왔습니다
</p>
)}
</div>
);
}고급 패턴
1. React Query를 활용한 무한 스크롤
React Query의 useInfiniteQuery를 사용하여 무한 스크롤을 구현합니다.
// src/hooks/useInfiniteProducts.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";
import { useEffect } from "react";
interface Product {
id: string;
name: string;
price: number;
image: string;
}
interface ProductsResponse {
products: Product[];
nextCursor: string | null;
}
async function fetchProducts({ pageParam = 0 }): Promise<ProductsResponse> {
const response = await fetch(`/api/products?cursor=${pageParam}&limit=20`);
return response.json();
}
export function useInfiniteProducts() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
} = useInfiniteQuery({
queryKey: ["products"],
queryFn: fetchProducts,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// 뷰포트에 진입하면 자동으로 다음 페이지 로드
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
const products = data?.pages.flatMap((page) => page.products) ?? [];
return {
products,
loadMoreRef: ref,
isLoading,
isError,
error,
isFetchingNextPage,
hasNextPage,
};
}
// 사용 예시
export function ProductGrid() {
const { products, loadMoreRef, isLoading, isFetchingNextPage, hasNextPage } =
useInfiniteProducts();
if (isLoading) {
return <div>로딩 중...</div>;
}
return (
<div>
<div className="grid grid-cols-4 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
{hasNextPage && (
<div ref={loadMoreRef} className="py-8 text-center">
{isFetchingNextPage && <div>더 불러오는 중...</div>}
</div>
)}
</div>
);
}2. 가상 스크롤 with Lazy Loading
react-window와 Intersection Observer를 결합합니다.
// src/components/VirtualLazyList.tsx
import { FixedSizeList as List } from "react-window";
import { useState, useEffect, useCallback } from "react";
import InfiniteLoader from "react-window-infinite-loader";
interface Item {
id: string;
title: string;
loaded: boolean;
}
export function VirtualLazyList() {
const [items, setItems] = useState<Item[]>(
Array.from({ length: 1000 }, (_, i) => ({
id: `item-${i}`,
title: `Item ${i}`,
loaded: false,
}))
);
const isItemLoaded = (index: number) => items[index]?.loaded;
const loadMoreItems = useCallback(
async (startIndex: number, stopIndex: number) => {
// 실제로는 API 호출
await new Promise((resolve) => setTimeout(resolve, 1000));
setItems((prevItems) => {
const newItems = [...prevItems];
for (let i = startIndex; i <= stopIndex; i++) {
if (newItems[i]) {
newItems[i] = {
...newItems[i],
loaded: true,
};
}
}
return newItems;
});
},
[]
);
const Row = ({
index,
style,
}: {
index: number;
style: React.CSSProperties;
}) => {
const item = items[index];
return (
<div style={style} className="px-4 py-2 border-b">
{item.loaded ? (
<div>
<h3 className="font-semibold">{item.title}</h3>
<p className="text-sm text-gray-600">Loaded content</p>
</div>
) : (
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
)}
</div>
);
};
return (
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={items.length}
loadMoreItems={loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<List
height={600}
itemCount={items.length}
itemSize={80}
width="100%"
onItemsRendered={onItemsRendered}
ref={ref}
>
{Row}
</List>
)}
</InfiniteLoader>
);
}3. 탭/모달 지연 로딩
탭이나 모달이 활성화될 때만 콘텐츠를 로드합니다.
// src/components/LazyTabs.tsx
import { useState, lazy, Suspense } from "react";
// 각 탭의 콘텐츠를 동적으로 import
const tabComponents = {
overview: lazy(() => import("./tabs/OverviewTab")),
details: lazy(() => import("./tabs/DetailsTab")),
reviews: lazy(() => import("./tabs/ReviewsTab")),
related: lazy(() => import("./tabs/RelatedTab")),
};
type TabKey = keyof typeof tabComponents;
export function LazyTabs() {
const [activeTab, setActiveTab] = useState<TabKey>("overview");
const [loadedTabs, setLoadedTabs] = useState<Set<TabKey>>(
new Set(["overview"])
);
const handleTabClick = (tab: TabKey) => {
setActiveTab(tab);
setLoadedTabs((prev) => new Set(prev).add(tab));
};
const tabs: { key: TabKey; label: string }[] = [
{ key: "overview", label: "개요" },
{ key: "details", label: "상세정보" },
{ key: "reviews", label: "리뷰" },
{ key: "related", label: "관련상품" },
];
return (
<div>
<div className="flex border-b">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => handleTabClick(tab.key)}
className={`px-4 py-2 ${
activeTab === tab.key
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-600"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="mt-4">
{tabs.map((tab) => {
const Component = tabComponents[tab.key];
const isLoaded = loadedTabs.has(tab.key);
return (
<div
key={tab.key}
className={activeTab === tab.key ? "block" : "hidden"}
>
{isLoaded ? (
<Suspense fallback={<TabSkeleton />}>
<Component />
</Suspense>
) : null}
</div>
);
})}
</div>
</div>
);
}
function TabSkeleton() {
return (
<div className="space-y-4 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
);
}4. Priority Hints로 로딩 우선순위 제어
중요한 리소스는 우선 로드하고 덜 중요한 것은 지연 로드합니다.
// src/components/PriorityImages.tsx
export function PriorityImages() {
return (
<div>
{/* Hero 이미지: 최우선 로드 */}
<img
src="/hero.jpg"
alt="Hero"
loading="eager"
fetchPriority="high"
className="w-full h-96 object-cover"
/>
{/* Above the fold 이미지: 일반 우선순위 */}
<img
src="/featured.jpg"
alt="Featured"
loading="lazy"
fetchPriority="auto"
className="w-full h-64 object-cover mt-4"
/>
{/* Below the fold 이미지: 낮은 우선순위 */}
<div className="grid grid-cols-3 gap-4 mt-8">
{[1, 2, 3, 4, 5, 6].map((i) => (
<img
key={i}
src={`/gallery-${i}.jpg`}
alt={`Gallery ${i}`}
loading="lazy"
fetchPriority="low"
className="w-full h-48 object-cover"
/>
))}
</div>
</div>
);
}
// Next.js Image 컴포넌트 우선순위
import Image from "next/image";
export function OptimizedHero() {
return (
<div>
{/* LCP 이미지는 priority 사용 */}
<Image
src="/hero.jpg"
alt="Hero"
width={1920}
height={1080}
priority
className="w-full h-auto"
/>
{/* 나머지는 lazy loading */}
<Image
src="/content.jpg"
alt="Content"
width={800}
height={600}
loading="lazy"
className="w-full h-auto mt-4"
/>
</div>
);
}Best Practices
✅ 권장사항
- Intersection Observer 사용: native lazy loading과 함께 사용하여 크로스 브라우저 지원
- placeholder 제공: 이미지 로딩 중 레이아웃 시프트 방지
- 적절한 threshold: 뷰포트 진입 전 50-100px에서 미리 로드
- 우선순위 설정: Above the fold 콘텐츠는 즉시 로드
- 에러 처리: 이미지 로딩 실패 시 fallback 이미지 표시
- 성능 모니터링: Lighthouse로 LCP, CLS 측정
- 무한 스크롤 최적화: 가상 스크롤과 결합하여 메모리 사용 최소화
⚠️ 피해야 할 것
- 과도한 lazy loading: Above the fold 콘텐츠까지 지연 로드
- placeholder 누락: 레이아웃 시프트 발생
- 적절하지 않은 threshold: 너무 늦게 로드하여 사용자 경험 저하
- 에러 처리 누락: 로딩 실패 시 빈 공간 표시
- SEO 무시: 중요한 콘텐츠를 검색 엔진이 인덱싱하지 못함
- 과도한 스켈레톤: 너무 많은 스켈레톤 UI로 오히려 혼란
성능 지표
Core Web Vitals 목표
interface LazyLoadingMetrics {
LCP: number; // Largest Contentful Paint < 2.5s
CLS: number; // Cumulative Layout Shift < 0.1
FID: number; // First Input Delay < 100ms
TTI: number; // Time to Interactive < 3.8s
}
const targets: LazyLoadingMetrics = {
LCP: 2500,
CLS: 0.1,
FID: 100,
TTI: 3800,
};제품별 Lazy Loading 전략
Foundation (기본 프레임워크)
// Foundation용 범용 lazy loading hook
export function useLazyLoad<T>(loader: () => Promise<T>, deps: any[] = []) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
loader()
.then((result) => {
if (!cancelled) setData(result);
})
.catch((err) => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, deps);
return { data, loading, error };
}전체 코드 보기: Foundation Lazy Load Hook
iCignal (분석 플랫폼)
// iCignal용 대시보드 위젯 lazy loading
export function LazyDashboard() {
const widgets = [
{ id: "chart", component: lazy(() => import("./widgets/ChartWidget")) },
{ id: "table", component: lazy(() => import("./widgets/TableWidget")) },
{ id: "map", component: lazy(() => import("./widgets/MapWidget")) },
];
return (
<div className="grid grid-cols-3 gap-4">
{widgets.map(({ id, component: Widget }) => (
<Suspense key={id} fallback={<WidgetSkeleton />}>
<Widget />
</Suspense>
))}
</div>
);
}전체 코드 보기: iCignal Lazy Widgets
Cals (예약 시스템)
// Cals용 예약 목록 무한 스크롤
export function BookingList() {
const { bookings, loadMoreRef, isLoading, hasMore } = useInfiniteBookings();
return (
<div className="space-y-2">
{bookings.map((booking) => (
<BookingCard key={booking.id} booking={booking} />
))}
{hasMore && <div ref={loadMoreRef}>Loading more...</div>}
</div>
);
}전체 코드 보기: Cals Infinite Bookings
테스트 및 실행
CodeSandbox에서 실행
로컬 실행
# 1. 예제 프로젝트 클론
git clone https://repo.calsplatz.com/vortex/examples.git
cd examples/lazy-loading
# 2. 의존성 설치
pnpm install
# 3. 개발 서버 실행
pnpm dev
# 4. Lighthouse 성능 측정
pnpm lighthouse관련 패턴
- Code Splitting - 코드 분할로 초기 로딩 최적화
- Performance Optimization - 전반적인 성능 최적화
- Data Fetching - 효율적인 데이터 로딩
Last updated on