레이지 로딩

필요한 시점까지 리소스 로딩을 지연시켜 초기 로딩 속도를 개선하는 기법입니다.

왜 필요한가

[ 일반 로딩 — 모든 것을 한 번에 ]
페이지 접속
   │
   ▼
이미지 100개 전부 다운로드 (50MB)
   │
   ▼
10초 후 페이지 표시
   │
   ▼
사용자: 첫 화면만 보고 이탈
        → 90개 이미지는 쓸데없이 로드됨


[ 레이지 로딩 — 필요할 때만 ]
페이지 접속
   │
   ▼
화면에 보이는 이미지 3개만 다운로드 (1.5MB)
   │
   ▼
1초 후 페이지 표시
   │
   ▼
스크롤할 때마다 추가 이미지 로드
   │
   ▼
사용자가 본 만큼만 로드됨

비유

[ 일반 로딩 = 뷔페 ]
들어가자마자 모든 음식을 접시에 담음
→ 다 못 먹고 버림

[ 레이지 로딩 = 코스 요리 ]
먹을 때마다 다음 음식이 나옴
→ 먹는 만큼만 제공

적용 대상

대상설명
이미지화면에 보일 때만 로드
비디오재생 시작할 때 로드
컴포넌트라우트 진입 시 로드 (코드 스플리팅)
데이터스크롤 끝에 도달하면 추가 로드 (무한 스크롤)
라이브러리기능 사용 시점에 로드

이미지 레이지 로딩

1. 네이티브 방식 (가장 간단)

<!-- loading="lazy" 속성 추가 -->
<img src="image.jpg" loading="lazy" alt="설명" />
브라우저가 자동으로:
1. 뷰포트에서 멀리 있으면 로드 안 함
2. 스크롤해서 가까워지면 로드 시작
3. 뷰포트에 들어오면 표시

지원: 모든 최신 브라우저 (IE 제외)

2. Intersection Observer API

// 화면에 들어오면 이미지 로드
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target;
            img.src = img.dataset.src;      // 실제 이미지 로드
            observer.unobserve(img);         // 관찰 중단
        }
    });
});

// 모든 레이지 이미지 관찰
document.querySelectorAll("img[data-src]").forEach(img => {
    observer.observe(img);
});
<!-- data-src에 실제 경로, src에 플레이스홀더 -->
<img data-src="real-image.jpg" src="placeholder.jpg" alt="설명" />
동작 흐름:

초기 상태:
┌─── 뷰포트 ───┐
│ img1 (로드됨) │
│ img2 (로드됨) │
└──────────────┘
  img3 (대기)      ← data-src만 있음
  img4 (대기)
  img5 (대기)

스크롤 후:
  img1 (로드됨)
┌─── 뷰포트 ───┐
│ img2 (로드됨) │
│ img3 (로드됨) │  ← 뷰포트 진입 → src 설정 → 로드 시작
└──────────────┘
  img4 (대기)
  img5 (대기)

3. 플레이스홀더 + 페이드인 효과

<img
    data-src="real-image.jpg"
    src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
    class="lazy"
    alt="설명"
/>
.lazy {
    opacity: 0;
    transition: opacity 0.3s;
}

.lazy.loaded {
    opacity: 1;
}
const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target;
            img.src = img.dataset.src;
            img.onload = () => img.classList.add("loaded");
            observer.unobserve(img);
        }
    });
});

컴포넌트 레이지 로딩 (코드 스플리팅)

React

import { lazy, Suspense } from "react";

// 동적 import — 해당 라우트 진입 시에만 로드
const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));
const Profile = lazy(() => import("./Profile"));

function App() {
    return (
        <Suspense fallback={<div>로딩 중...</div>}>
            <Routes>
                <Route path="/dashboard" element={<Dashboard />} />
                <Route path="/settings" element={<Settings />} />
                <Route path="/profile" element={<Profile />} />
            </Routes>
        </Suspense>
    );
}
번들 분리 효과:

[ 일반 번들링 ]
bundle.js (2MB) — 모든 페이지 코드 포함
→ 첫 페이지 로드에 2MB 다운로드

[ 코드 스플리팅 ]
main.js (200KB)       — 공통 코드
dashboard.js (500KB)  — /dashboard 진입 시 로드
settings.js (300KB)   — /settings 진입 시 로드
profile.js (400KB)    — /profile 진입 시 로드

→ 첫 페이지 로드에 200KB만 다운로드
→ 각 페이지 방문 시 필요한 것만 추가 로드

Vue

// 라우터에서 동적 import
const routes = [
    {
        path: "/dashboard",
        component: () => import("./views/Dashboard.vue")
    },
    {
        path: "/settings",
        component: () => import("./views/Settings.vue")
    }
];

조건부 컴포넌트 로딩

// 모달은 열릴 때만 로드
const Modal = lazy(() => import("./Modal"));

function App() {
    const [showModal, setShowModal] = useState(false);

    return (
        <>
            <button onClick={() => setShowModal(true)}>모달 열기</button>

            {showModal && (
                <Suspense fallback={<div>로딩...</div>}>
                    <Modal onClose={() => setShowModal(false)} />
                </Suspense>
            )}
        </>
    );
}

무한 스크롤 (데이터 레이지 로딩)

const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const observerRef = useRef();

// 마지막 요소 관찰
const lastItemRef = useCallback(node => {
    if (loading) return;

    if (observerRef.current) {
        observerRef.current.disconnect();
    }

    observerRef.current = new IntersectionObserver(entries => {
        if (entries[0].isIntersecting) {
            loadMore();   // 다음 페이지 로드
        }
    });

    if (node) {
        observerRef.current.observe(node);
    }
}, [loading]);

async function loadMore() {
    setLoading(true);
    const newItems = await fetch(`/api/items?page=${page}`);
    setItems(prev => [...prev, ...newItems]);
    setPage(prev => prev + 1);
    setLoading(false);
}

return (
    <ul>
        {items.map((item, index) => (
            <li
                key={item.id}
                ref={index === items.length - 1 ? lastItemRef : null}
            >
                {item.name}
            </li>
        ))}
        {loading && <li>로딩 중...</li>}
    </ul>
);
동작:

┌─── 뷰포트 ───┐
│ 항목 1        │
│ 항목 2        │
│ 항목 3        │
│ 항목 4        │
│ 항목 5 ←──────│─── 마지막 항목이 뷰포트에 들어옴
└──────────────┘
        │
        ▼ loadMore() 호출

┌─── 뷰포트 ───┐
│ 항목 4        │
│ 항목 5        │
│ 항목 6        │  ← 새로 로드됨
│ 항목 7        │
│ 항목 8        │
└──────────────┘

라이브러리 레이지 로딩

// 차트 라이브러리 — 차트 표시할 때만 로드
async function showChart() {
    const { Chart } = await import("chart.js");

    new Chart(ctx, {
        type: "bar",
        data: chartData
    });
}

// PDF 생성 — 버튼 클릭 시에만 로드
async function generatePDF() {
    const { jsPDF } = await import("jspdf");

    const doc = new jsPDF();
    doc.text("Hello", 10, 10);
    doc.save("document.pdf");
}

Eager vs Lazy 비교

항목Eager LoadingLazy Loading
로딩 시점페이지 로드 시 전부필요할 때만
초기 로딩 속도느림빠름
초기 데이터 사용량많음적음
구현 복잡도단순약간 복잡
이후 사용 시즉시 사용 가능약간의 로딩 대기
언제 Eager?
- 바로 필요한 핵심 리소스
- 크기가 작은 리소스
- 사용자가 반드시 볼 리소스

언제 Lazy?
- 스크롤해야 보이는 이미지
- 특정 조건에서만 쓰는 기능
- 무거운 라이브러리
- 자주 방문하지 않는 페이지

주의사항

1. SEO 고려

<!-- 검색 엔진이 이미지를 인식하도록 noscript 제공 -->
<img data-src="image.jpg" class="lazy" alt="중요한 이미지" />
<noscript>
    <img src="image.jpg" alt="중요한 이미지" />
</noscript>

2. 레이아웃 시프트 방지

<!-- 이미지 크기 미리 지정 -->
<img
    data-src="image.jpg"
    width="400"
    height="300"
    loading="lazy"
    alt="설명"
/>
/* 또는 CSS로 공간 확보 */
.lazy-container {
    aspect-ratio: 4 / 3;
    background: #f0f0f0;
}

3. 첫 화면(Above the Fold)은 즉시 로드

<!-- 첫 화면 이미지는 lazy 사용 안 함 -->
<img src="hero-image.jpg" alt="메인 이미지" />

<!-- 스크롤해야 보이는 이미지만 lazy -->
<img src="image2.jpg" loading="lazy" alt="하단 이미지" />

핵심 정리

레이지 로딩은 필요한 시점까지 리소스 로딩을 지연시켜 초기 로딩 속도와 대역폭을 절약하는 기법입니다. 이미지는 loading="lazy" 또는 Intersection Observer로, 컴포넌트는 동적 import()로 구현합니다. 초기 로딩 속도 개선에 큰 효과가 있지만, 첫 화면(Above the Fold) 콘텐츠는 즉시 로드하고, 레이아웃 시프트 방지를 위해 크기를 미리 지정하는 것이 중요합니다.

CSLOG에서 보기