레이지 로딩
필요한 시점까지 리소스 로딩을 지연시켜 초기 로딩 속도를 개선하는 기법입니다.
왜 필요한가
[ 일반 로딩 — 모든 것을 한 번에 ]
페이지 접속
│
▼
이미지 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 Loading | Lazy 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) 콘텐츠는 즉시 로드하고, 레이아웃 시프트 방지를 위해 크기를 미리 지정하는 것이 중요합니다.