본문으로 건너뛰기
yutils

lazy loading 은 어떻게 동작할까?

code splitting (route 기반 + dynamic import), 이미지를 위한 IntersectionObserver API, prefetch vs preload vs lazy, 과도한 splitting 이 (TCP round-trip) 으로 손해인 이유, React Suspense / Next dynamic 의 실제 패턴.

약 9분 읽기

SPA 의 initial bundle 이 5 MB 면 첫 페이지 로딩 5 초+. 그러나 사용자는 첫 페이지만 보면 됨. 나머지는 필요한 시점에 load — lazy loading. 이 가이드는 code splitting (route + dynamic), 이미지의 IntersectionObserver, prefetch vs preload 의 차이, 과도한 splitting 의 함정을 정리한다.

Code Splitting — 첫 chunk 작게

초기 (single bundle):
  /bundle.js  3.0 MB  → 한 번에 download + parse + execute

route-based splitting:
  /home.js     0.3 MB
  /dashboard.js 0.8 MB
  /settings.js  0.2 MB
  /admin.js     1.7 MB
  /vendor.js    0.5 MB (공통 라이브러리)

→ home 페이지 진입 시 home.js + vendor.js 만 load (0.8 MB)
→ 다른 route 들은 click 시 lazy load

JS 측 syntax (modern bundler 모두 지원):
  // 정적 import
  import HomePage from "./HomePage";

  // dynamic import (별도 chunk + 비동기)
  const HomePage = lazy(() => import("./HomePage"));

React Suspense + lazy

import {lazy, Suspense} from "react";

const Dashboard = lazy(() => import("./Dashboard"));
const Settings = lazy(() => import("./Settings"));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

→ Dashboard 진입 시:
  1. Suspense 가 <Spinner /> render (lazy 가 throw promise)
  2. dynamic import 가 dashboard chunk download (~100-500ms)
  3. resolve 후 Dashboard render

Next.js 의 dynamic 도 동일 패턴:
  const Map = dynamic(() => import("./Map"), { ssr: false });

Image Lazy Loading — IntersectionObserver

이미지 100 개의 페이지. 모두 즉시 download = 부피 큰 페이지 느림.

옛 방법 — scroll handler:
  window.addEventListener("scroll", () => {
    images.forEach(img => {
      if (isInViewport(img)) loadImage(img);
    });
  });
  → 매 scroll event 마다 N images 계산 = 비싸 (jank)

modern — IntersectionObserver (browser native):
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        observer.unobserve(img);
      }
    });
  }, { rootMargin: "200px" });   // 200px 전에 미리 load

  document.querySelectorAll("img[data-src]").forEach(img => observer.observe(img));

→ browser 가 직접 viewport 추적, application 의 scroll handler 0.
→ rootMargin 으로 "viewport 200px 전" 같은 prefetch margin.

HTML native 도 가능 (modern browser):
  <img src="..." loading="lazy" />
  → 같은 IntersectionObserver 동작이 browser 내부에서

Prefetch vs Preload vs Lazy

지시어의도
<link rel="preload">"이 페이지에 곧 필요" → 즉시 download (high priority)위 페이지의 font, hero image
<link rel="prefetch">"다음 페이지에 필요할 듯" → idle 시 download (low priority)다음 page 의 chunk
<link rel="dns-prefetch">"이 도메인으로 곧 요청" → DNS 만 미리analytics, CDN domain
<link rel="preconnect">"이 도메인 곧 사용" → DNS + TCP + TLS 미리API endpoint
lazy (loading="lazy")"필요할 때만 load" — 반대 방향off-screen 이미지

Next.js / Nuxt 의 router 가 hover 또는 mouse-near link 시 자동 prefetch — modern framework 의 default.

과도한 Splitting 의 함정

너무 많은 chunk = TCP round-trip 다수
  bundle 1 개 (3 MB): 1 connection → 3 MB stream
  chunk 30 개 (각 100 KB): 30 connection (또는 HTTP/2 multiplex)
                          → 각 chunk 의 download + parse 합

trade-off:
- HTTP/1.1: connection 비용 큼 → 적은 chunk 가 빠름
- HTTP/2 + multiplex: 여러 chunk 가능
- HTTP/3 + 0-RTT: 더 자유

권장:
- 너무 작게 X (chunk 당 최소 30-50 KB)
- 너무 크게 X (각 < 200-500 KB)
- vendor 별도 chunk + route 별 + 큰 components 분리

Modern Frontend 의 lazy 패턴

React + Next.js:
1. App Router 의 route-level code split (자동)
2. dynamic(() => import(...)) 으로 component lazy
3. next/image 가 loading="lazy" 자동 + responsive
4. Suspense + Server Component 의 streaming (RSC)

Vue + Nuxt:
1. defineAsyncComponent + Suspense
2. <NuxtImg> 의 lazy + WebP / AVIF 자동 변환
3. route 별 chunk 자동

Vite:
- dev: native ESM (lazy by default, on-demand transform)
- prod: rollup 으로 production split

→ modern toolchain 의 default 는 이미 좋음. 더 깊은 split 은 measure 후만.

흔한 함정

  • 모든 component 를 lazy — 첫 paint 의 component 가 lazy 면 spinner 만 보임. above-the-fold 는 static import.
  • 큰 chunk 가 첫 page 에 — admin 페이지 라이브러리 (chart, editor) 가 home bundle 에. webpack-bundle-analyzer 로 점검.
  • preload 남용 — 모든 페이지에 preload 박으면 결과적 높은 priority 가 의미 X. 핵심 자원만.
  • IntersectionObserver 의 rootMargin 0 — viewport 진입 후에 load 시작 → 사용자 한 박자 wait. rootMargin 100-300px.
  • route prefetch 의 비용 — 모든 link 의 chunk 미리 download = 데이터 비용 ↑ (mobile). hover-prefetch 또는 visible link 만.

마무리

Lazy loading 의 본질 — "필요할 때 download". initial bundle 작게 → first paint 빠름. 그러나 chunk 너무 작으면 round-trip overhead.

실용 — modern framework default 활용 (Next.js / Nuxt 의 route split + dynamic + image lazy). monitor LCP / TTI / Total Bundle Size. 큰 component (chart, editor, map) 만 명시적 dynamic. prefetch 는 hover 또는 visible link 만.

가이드 목록으로