Skip to Content
PatternsLazy Loading

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

✅ 권장사항

  1. Intersection Observer 사용: native lazy loading과 함께 사용하여 크로스 브라우저 지원
  2. placeholder 제공: 이미지 로딩 중 레이아웃 시프트 방지
  3. 적절한 threshold: 뷰포트 진입 전 50-100px에서 미리 로드
  4. 우선순위 설정: Above the fold 콘텐츠는 즉시 로드
  5. 에러 처리: 이미지 로딩 실패 시 fallback 이미지 표시
  6. 성능 모니터링: Lighthouse로 LCP, CLS 측정
  7. 무한 스크롤 최적화: 가상 스크롤과 결합하여 메모리 사용 최소화

⚠️ 피해야 할 것

  1. 과도한 lazy loading: Above the fold 콘텐츠까지 지연 로드
  2. placeholder 누락: 레이아웃 시프트 발생
  3. 적절하지 않은 threshold: 너무 늦게 로드하여 사용자 경험 저하
  4. 에러 처리 누락: 로딩 실패 시 빈 공간 표시
  5. SEO 무시: 중요한 콘텐츠를 검색 엔진이 인덱싱하지 못함
  6. 과도한 스켈레톤: 너무 많은 스켈레톤 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에서 실행

Lazy Loading 예제 실행하기 

로컬 실행

# 1. 예제 프로젝트 클론 git clone https://repo.calsplatz.com/vortex/examples.git cd examples/lazy-loading # 2. 의존성 설치 pnpm install # 3. 개발 서버 실행 pnpm dev # 4. Lighthouse 성능 측정 pnpm lighthouse

관련 패턴

Last updated on