React Suspense와 지연 로딩
React 공식 문서를 기반으로 작성된 Suspense와 lazy loading 가이드
들어가며
왜 지연 로딩이 필요한가?
상상해보세요. 사용자가 여러분의 React 앱에 처음 접속했을 때, 앱의 모든 페이지, 모든 기능, 모든 컴포넌트의 코드를 한 번에 다운로드해야 한다면 어떨까요?
// 모든 코드가 하나의 번들로 묶인 경우
import Home from './pages/Home'; // 100KB
import Dashboard from './pages/Dashboard'; // 200KB
import Settings from './pages/Settings'; // 150KB
import Admin from './pages/Admin'; // 300KB
import Reports from './pages/Reports'; // 250KB
// 사용자가 Home만 보고 싶어도 총 1000KB를 다운로드!
현실적인 문제:
- 사용자는 Home 페이지만 보고 싶은데, Admin 페이지 코드까지 다운로드
- 3G 환경에서는 1MB 다운로드에 10초 이상 소요
- 구글 연구에 따르면 로딩 시간이 3초를 넘으면 53%의 모바일 사용자가 페이지를 떠남
번들 크기는 어떻게 커지는가?
React 앱을 개발하다 보면 번들 크기는 빠르게 증가합니다:
초기 프로젝트: 50KB
+ 차트 라이브러리: +200KB (Chart.js)
+ 에디터 라이브러리: +300KB (Draft.js)
+ PDF 뷰어: +150KB
+ 이미지 에디터: +250KB
+ 기타 의존성: +150KB
= 총 번들 크기: 1.1MB
하지만 대부분의 사용자는 이 기능들을 모두 사용하지 않습니다. 차트가 필요한 사람만 차트를, 에디터가 필요한 사람만 에디터를 로드하면 어떨까요?
Suspense와 lazy가 해결하는 방법
React의 Suspense와 lazy() 함수는 바로 이 문제를 해결합니다:
import { lazy, Suspense } from 'react';
// 필요할 때만 로드되는 컴포넌트들
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Admin = lazy(() => import('./pages/Admin'));
// 사용자가 Dashboard 페이지로 이동할 때만 해당 코드 다운로드!
결과:
- 초기 번들: 100KB (70% 감소!)
- Dashboard 접속 시: 추가 200KB만 다운로드
- 초기 로딩 시간: 10초 → 2초
이 가이드에서 다루는 내용
이 문서는 React 공식 문서를 기반으로 작성되었으며, 다음 내용을 상세히 다룹니다:
기초부터 고급까지:
- Suspense와 lazy의 기본 개념 - 어떻게 동작하는지 이해하기
- 실전 사용 패턴 5가지 - 라우트 분할, 조건부 로딩, Prefetching 등
- React 버전별 차이점 - 16.6부터 19.2까지의 변화 (중요!)
- 5가지 실전 예제 - 대시보드, 모달, 탭, 에러 처리 등
- 7가지 베스트 프랙티스 - 프로덕션 환경에서 안전하게 사용하기
- 흔한 실수 6가지 - 피해야 할 안티 패턴
특히 중요한 내용:
- ⚠️ React 18 vs 19의 중요한 차이: React 19에서 발생했던 “Waterfall” 문제와 해결 과정
- 🎯 성능 측정 방법: Webpack Bundle Analyzer, Lighthouse 활용
- 🚀 즉시 적용 가능한 코드: 복사해서 바로 사용할 수 있는 40개 이상의 예제
이 가이드를 읽고 나면, 여러분의 React 앱의 초기 로딩 시간을 50% 이상 줄일 수 있는 실전 기법을 완전히 이해하게 될 것입니다.
Suspense란?
로딩 상태를 선언적으로 다루기
기존의 React에서 비동기 작업(코드 로딩, 데이터 페칭 등)을 처리할 때는 다음과 같은 패턴을 사용했습니다:
function MyComponent() {
const [isLoading, setIsLoading] = useState(true);
const [component, setComponent] = useState(null);
useEffect(() => {
// 컴포넌트 동적 로드
import('./HeavyComponent').then(module => {
setComponent(() => module.default);
setIsLoading(false);
});
}, []);
if (isLoading) {
return <div>Loading...</div>;
}
const Component = component;
return <Component />;
}
문제점:
- 모든 컴포넌트마다
isLoading상태 관리 필요 useEffect로 수동 로딩 처리- 코드가 복잡하고 반복적
- 에러 처리까지 고려하면 더 복잡해짐
Suspense의 해결책:
<Suspense>는 이러한 비동기 작업의 로딩 상태를 선언적으로 처리할 수 있게 해주는 React 컴포넌트입니다. 자식 컴포넌트가 로딩을 완료할 때까지 대체 UI(폴백)를 자동으로 표시합니다.
// 훨씬 간단해진 코드
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function MyComponent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}
장점:
- 로딩 상태를 직접 관리할 필요 없음
- 코드가 간결하고 읽기 쉬움
- 여러 비동기 컴포넌트를 하나의 Suspense로 관리 가능
- React가 자동으로 로딩 상태를 추적
기본 개념
import { Suspense } from 'react';
function App() {
return (
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
);
}
이 코드에서:
SomeComponent가 로딩 중일 때 →<Loading />컴포넌트 표시SomeComponent로딩 완료 → 실제 컴포넌트 렌더링
Suspense의 Props
| Props | 타입 | 설명 |
|---|---|---|
children |
React 노드 | 렌더링할 실제 UI. 로딩 중이면 fallback 표시 |
fallback |
React 노드 | 로딩 중에 표시할 대체 UI (스피너, 스켈레톤 등) |
Suspense를 활성화하는 방법
Suspense는 다음과 같은 경우에 활성화됩니다:
lazy()를 통한 컴포넌트 코드 지연 로딩 (이 문서의 주요 주제)- Suspense 지원 프레임워크의 데이터 페칭 (Relay, Next.js, Remix 등)
use()훅으로 Promise 읽기 (React 19+)
주의: useEffect나 이벤트 핸들러 내부의 데이터 페칭은 Suspense를 활성화하지 않습니다.
lazy()를 이용한 지연 로딩
lazy()는 컴포넌트 코드의 로딩을 실제로 렌더링될 때까지 연기할 수 있게 해주는 함수입니다.
기본 문법
import { lazy } from 'react';
const SomeComponent = lazy(load);
매개변수:
load: Promise를 반환하는 함수- 매개변수 없음
.default속성이 유효한 React 컴포넌트여야 함- 함수형 컴포넌트,
memo,forwardRef컴포넌트 모두 가능
반환값:
- React 컴포넌트를 반환
- 코드 로딩 중에는 일시 중단(suspend) 상태
왜 lazy를 사용해야 하는가?
일반적인 import (정적):
// ❌ 번들에 즉시 포함됨 (사용하지 않아도)
import HeavyComponent from './HeavyComponent';
function App() {
const [show, setShow] = useState(false);
return (
<div>
<button onClick={() => setShow(true)}>Show Heavy Component</button>
{show && <HeavyComponent />}
</div>
);
}
이 경우 HeavyComponent는 사용자가 버튼을 클릭하지 않아도 초기 번들에 포함됩니다.
lazy를 사용한 동적 import:
// ✅ 실제로 필요할 때만 로드됨
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
const [show, setShow] = useState(false);
return (
<div>
<button onClick={() => setShow(true)}>Show Heavy Component</button>
{show && (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
)}
</div>
);
}
이제 HeavyComponent는 버튼을 클릭해야만 로드됩니다.
기본 사용법
예제 1: 단일 컴포넌트 지연 로딩
실제 사용 사례: 마크다운 에디터에서 프리뷰 기능
마크다운 프리뷰는 무거운 라이브러리(예: marked.js, highlight.js)를 필요로 합니다. 사용자가 프리뷰 버튼을 클릭하기 전까지는 이 코드들이 필요 없습니다.
import { lazy, Suspense } from 'react';
// 1. lazy로 컴포넌트 래핑
// MarkdownPreview는 약 200KB의 무거운 컴포넌트
const MarkdownPreview = lazy(() => import('./MarkdownPreview'));
function Editor() {
const [showPreview, setShowPreview] = useState(false);
return (
<div>
<button onClick={() => setShowPreview(!showPreview)}>
Toggle Preview
</button>
{/* 2. Suspense로 감싸기 */}
{showPreview && (
<Suspense fallback={<div>Loading preview...</div>}>
<MarkdownPreview />
</Suspense>
)}
</div>
);
}
동작 과정:
- 사용자가 “Toggle Preview” 버튼 클릭
showPreview가true로 변경MarkdownPreview컴포넌트 로드 시작- 로딩 중: “Loading preview…” 표시
- 로딩 완료: 실제 컴포넌트 렌더링
성능 개선:
- 초기 번들: 200KB 감소
- 프리뷰 사용 안 하는 사용자: 200KB 절약
- 프리뷰 사용하는 사용자: 필요할 때만 로드
예제 2: 여러 컴포넌트를 하나의 Suspense로 감싸기
실제 사용 사례: 사용자 프로필 페이지
사용자 프로필 페이지는 여러 섹션(프로필, 포스트, 댓글)으로 구성됩니다. 모든 섹션이 함께 표시되어야 하므로, 하나의 Suspense로 관리합니다.
import { lazy, Suspense } from 'react';
const UserProfile = lazy(() => import('./UserProfile'));
const UserPosts = lazy(() => import('./UserPosts'));
const UserComments = lazy(() => import('./UserComments'));
function UserPage({ userId }) {
return (
<div>
<h1>User Dashboard</h1>
{/* 모든 컴포넌트를 하나의 Suspense로 감싸기 */}
<Suspense fallback={<div>Loading user data...</div>}>
<UserProfile userId={userId} />
<UserPosts userId={userId} />
<UserComments userId={userId} />
</Suspense>
</div>
);
}
주의: 이 경우 세 컴포넌트 중 하나라도 로딩 중이면, 전체가 폴백으로 표시됩니다.
트레이드오프:
- 장점: 모든 데이터가 준비되면 한 번에 표시 (일관된 사용자 경험)
- 단점: 가장 느린 컴포넌트가 전체 로딩 시간 결정
언제 사용하나요?
- 모든 섹션이 함께 의미가 있을 때 (예: 프로필 + 포스트 + 댓글)
- 부분적인 표시가 오히려 혼란스러울 때
- 모든 컴포넌트의 로딩 시간이 비슷할 때
예제 3: 중첩된 Suspense (순차적 로딩)
실제 사용 사례: 아티스트 프로필 페이지
Biography는 중요한 정보이므로 먼저 보여주고, Albums는 그 다음에 로드해도 됩니다. 중첩된 Suspense를 사용하면 이런 우선순위를 구현할 수 있습니다.
import { lazy, Suspense } from 'react';
const Biography = lazy(() => import('./Biography')); // 중요도: 높음 (빠르게 표시)
const Albums = lazy(() => import('./Albums')); // 중요도: 보통 (나중에 표시)
function ArtistPage({ artistId }) {
return (
<div>
<h1>Artist Profile</h1>
{/* 외부 Suspense: Biography 로딩 */}
<Suspense fallback={<BigSpinner />}>
<Biography artistId={artistId} />
{/* 내부 Suspense: Albums 로딩 */}
<Suspense fallback={<AlbumsGlimmer />}>
<Albums artistId={artistId} />
</Suspense>
</Suspense>
</div>
);
}
로딩 순서:
- 초기:
BigSpinner표시 (Biography 로딩 중) - Biography 로드 완료: Biography 표시 +
AlbumsGlimmer표시 (Albums 로딩 중) - Albums 로드 완료: 전체 컨텐츠 표시
사용자가 보는 화면:
[1단계]
┌─────────────────┐
│ Loading... │ ← BigSpinner
└─────────────────┘
[2단계]
┌─────────────────┐
│ Biography │ ← 실제 내용
│ ● Born: 1990 │
│ ● Genre: Pop │
└─────────────────┘
┌─────────────────┐
│ ▢▢▢ ▢▢▢ │ ← AlbumsGlimmer (스켈레톤)
└─────────────────┘
[3단계]
┌─────────────────┐
│ Biography │
│ ● Born: 1990 │
│ ● Genre: Pop │
└─────────────────┘
┌─────────────────┐
│ Albums │ ← 실제 내용
│ • Album 1 │
│ • Album 2 │
└─────────────────┘
왜 중첩 Suspense를 사용하나요?
- 점진적 렌더링: 사용자가 콘텐츠를 단계적으로 볼 수 있음
- 우선순위: 중요한 콘텐츠를 먼저 표시
- 체감 성능 향상: 전체 로딩 시간은 같지만, 사용자는 더 빠르게 느낌
고급 패턴
패턴 1: 라우트 기반 코드 분할
가장 일반적이고 효과적인 패턴은 라우트(페이지) 단위로 코드를 분할하는 것입니다.
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// 각 페이지를 lazy로 로드
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
function PageLoader() {
return (
<div className="page-loader">
<div className="spinner" />
<p>Loading page...</p>
</div>
);
}
장점:
- 사용자는 현재 페이지의 코드만 다운로드
- 페이지 전환 시에만 추가 코드 로드
- 초기 로딩 시간 대폭 감소
패턴 2: 조건부 렌더링과 함께 사용
import { lazy, Suspense, useState } from 'react';
// 무거운 컴포넌트들을 lazy로 로드
const VideoPlayer = lazy(() => import('./VideoPlayer'));
const ImageEditor = lazy(() => import('./ImageEditor'));
const PdfViewer = lazy(() => import('./PdfViewer'));
function MediaViewer({ fileType, fileUrl }) {
const [isLoaded, setIsLoaded] = useState(false);
// 파일 타입에 따라 다른 컴포넌트 렌더링
const renderMedia = () => {
switch (fileType) {
case 'video':
return <VideoPlayer url={fileUrl} />;
case 'image':
return <ImageEditor url={fileUrl} />;
case 'pdf':
return <PdfViewer url={fileUrl} />;
default:
return <div>Unsupported file type</div>;
}
};
return (
<div>
<h2>Media Viewer</h2>
<Suspense fallback={<MediaLoader type={fileType} />}>
{renderMedia()}
</Suspense>
</div>
);
}
장점:
- 사용자가 선택한 미디어 타입의 코드만 로드
- 불필요한 코드 다운로드 방지
패턴 3: startTransition과 함께 사용 (React 18+)
startTransition을 사용하면 기존 콘텐츠를 유지하면서 백그라운드에서 새 콘텐츠를 로드할 수 있습니다.
import { lazy, Suspense, useState, useTransition } from 'react';
const Tab1 = lazy(() => import('./Tab1'));
const Tab2 = lazy(() => import('./Tab2'));
const Tab3 = lazy(() => import('./Tab3'));
function TabbedInterface() {
const [currentTab, setCurrentTab] = useState('tab1');
const [isPending, startTransition] = useTransition();
const handleTabChange = (tab) => {
// startTransition으로 감싸면 현재 탭이 유지됨
startTransition(() => {
setCurrentTab(tab);
});
};
return (
<div>
<div className="tabs">
<button
onClick={() => handleTabChange('tab1')}
disabled={isPending}
>
Tab 1
</button>
<button
onClick={() => handleTabChange('tab2')}
disabled={isPending}
>
Tab 2
</button>
<button
onClick={() => handleTabChange('tab3')}
disabled={isPending}
>
Tab 3
</button>
</div>
{isPending && <div>Loading...</div>}
<Suspense fallback={<div>Loading tab...</div>}>
{currentTab === 'tab1' && <Tab1 />}
{currentTab === 'tab2' && <Tab2 />}
{currentTab === 'tab3' && <Tab3 />}
</Suspense>
</div>
);
}
차이점:
startTransition없이: 탭 클릭 → 즉시 “Loading tab…” 표시 → 깜빡임startTransition사용: 탭 클릭 → 현재 탭 유지 + “Loading…” 인디케이터 → 부드러운 전환
패턴 4: 에러 바운더리와 함께 사용
import { lazy, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<ErrorBoundary
fallback={<div>Something went wrong. Please try again.</div>}
onReset={() => window.location.reload()}
>
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
</ErrorBoundary>
);
}
이점:
- 네트워크 오류나 컴포넌트 로딩 실패 시 에러 UI 표시
- 사용자에게 재시도 옵션 제공
패턴 5: Prefetching (사전 로드)
import { lazy, Suspense, useState, useEffect } from 'react';
const HeavyModal = lazy(() => import('./HeavyModal'));
// 사전 로드 함수
const preloadHeavyModal = () => {
// lazy의 import를 미리 실행
import('./HeavyModal');
};
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div>
{/* 마우스 오버 시 사전 로드 */}
<button
onClick={() => setShowModal(true)}
onMouseEnter={preloadHeavyModal}
>
Open Modal
</button>
{showModal && (
<Suspense fallback={<div>Loading modal...</div>}>
<HeavyModal onClose={() => setShowModal(false)} />
</Suspense>
)}
</div>
);
}
전략:
- 마우스 오버 시 사전 로드 → 클릭 시 즉시 표시
- 사용자 경험 향상
React 버전별 차이점
버전별 차이를 이해해야 하는 이유
중요한 이야기: 많은 개발자들이 React 18에서 잘 작동하던 Suspense 코드가 React 19로 업그레이드하자 갑자기 느려지는 경험을 했습니다. 이는 버전별로 Suspense의 동작 방식이 크게 다르기 때문입니다.
예를 들어:
- React 16/17: 서버 사이드 렌더링 불가 → 에러 발생
- React 18: 병렬 페칭 → 빠름
- React 19 초기: 순차 페칭 → 느림 (Waterfall)
- React 19 최종: 다시 병렬 페칭 → 빠름
따라서 프로덕션 환경에서 Suspense를 사용하려면 반드시 현재 React 버전의 동작 방식을 확인해야 합니다.
React 16.6 (2018년 10월)
최초 도입 - 코드 분할의 혁명
배경: 당시 React 앱의 번들 크기는 계속 커지고 있었고, 개발자들은 Webpack의 복잡한 설정으로 코드 분할을 구현해야 했습니다. React 팀은 이를 프레임워크 레벨에서 해결하고자 했습니다.
import React, { lazy, Suspense } from 'react';
const OtherComponent = lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
);
}
특징:
- Suspense와 lazy 최초 도입 (실험적 기능)
- 클라이언트 사이드만 지원 (서버 사이드 렌더링 불가)
- 코드 분할 전용 (데이터 페칭 불가)
- “Legacy Suspense”로 불림
제약사항:
// ❌ React 16.6에서는 동작하지 않음
function ServerComponent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent /> {/* 서버에서 에러 발생 */}
</Suspense>
);
}
React 17 (2020년 8월)
점진적 업그레이드 중점 - 다음을 위한 준비
배경: React 팀은 대규모 업데이트를 준비 중이었지만, 기존 앱의 마이그레이션 부담을 np줄이고자 했습니다. 그래서 React 17은 “징검다리 버전”으로 기획되었습니다.
목표: React 18의 대규모 변경사항(Concurrent Mode)을 준비하면서, 개발자들이 점진적으로 업그레이드할 수 있도록 하는 것
React 17은 “새 기능 없음”을 표방한 버전으로, Suspense 관련 주요 변경사항은 없습니다.
변경사항:
- 버그 수정 및 안정성 개선
unstable_expectedLoadTimeprop 추가 (실험적) - React 18 준비unstable_startTransitionAPI 추가 (실험적) - React 18 준비
여전한 제약:
// ❌ 여전히 서버 사이드 렌더링 불가
// ❌ 데이터 페칭에 사용 불가
개발자 입장에서: React 17은 Suspense 사용에 있어서 React 16.6과 거의 동일합니다.
React 18 (2022년 3월) - 게임 체인저
Concurrent Suspense 도입 - 완전한 재설계
배경: React 팀은 3년 이상의 개발 끝에 Suspense를 완전히 재설계했습니다. 기존 “Legacy Suspense”의 한계(SSR 불가, 예측 불가능한 동작)를 극복하고, Concurrent 렌더링과 완벽하게 통합하고자 했습니다.
목표:
- 서버 사이드 렌더링 지원
- 더 나은 사용자 경험 (깜빡임 없는 전환)
- 데이터 페칭과의 통합 (실험적)
React 18은 Suspense의 게임 체인저입니다. “Legacy Suspense”에서 “Concurrent Suspense”로 완전히 재작성되었습니다.
주요 변경사항:
1. 서버 사이드 렌더링 지원
// ✅ React 18부터 가능
// server.js
import { renderToPipeableStream } from 'react-dom/server';
function App() {
return (
<Suspense fallback={<Spinner />}>
<LazyComponent />
</Suspense>
);
}
// 스트리밍 서버 렌더링
renderToPipeableStream(<App />, {
onShellReady() {
// 초기 HTML 전송
}
});
2. Transition과 통합
import { lazy, Suspense, useTransition } from 'react';
const Tab1 = lazy(() => import('./Tab1'));
const Tab2 = lazy(() => import('./Tab2'));
function TabbedApp() {
const [tab, setTab] = useState('tab1');
const [isPending, startTransition] = useTransition();
const switchTab = (newTab) => {
startTransition(() => {
setTab(newTab); // ✅ 현재 콘텐츠 유지
});
};
return (
<div>
<button onClick={() => switchTab('tab1')}>Tab 1</button>
<button onClick={() => switchTab('tab2')}>Tab 2</button>
{/* isPending 상태로 로딩 인디케이터 표시 */}
{isPending && <LoadingBar />}
<Suspense fallback={<TabSkeleton />}>
{tab === 'tab1' ? <Tab1 /> : <Tab2 />}
</Suspense>
</div>
);
}
차이점 비교:
| 시나리오 | React 16/17 (Legacy) | React 18 (Concurrent) |
|---|---|---|
| 탭 전환 시 | 즉시 폴백 표시 (깜빡임) | 현재 콘텐츠 유지 + 로딩 인디케이터 |
| 폴백 표시 | 무조건 표시 | transition이면 표시 안 함 |
| 사용자 경험 | 깜빡임, 어색함 | 부드러운 전환 |
3. 마운트 전 상태 보존
// React 16/17 (Legacy Suspense)
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{count > 5 && (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
)}
</div>
);
}
// ❌ LazyComponent 로딩 중 count 상태가 리셋될 수 있음
// React 18 (Concurrent Suspense)
// ✅ 상태가 안전하게 보존됨
4. Layout Effect 처리 개선
function Component() {
useLayoutEffect(() => {
console.log('Component mounted');
return () => console.log('Component unmounted');
}, []);
return <div>Content</div>;
}
function App() {
const [show, setShow] = useState(true);
return (
<Suspense fallback={<div>Loading...</div>}>
{show && <Component />}
</Suspense>
);
}
React 16/17:
- Suspense가 활성화되면 → Effect 실행 후 → 컴포넌트 숨김 (cleanup 실행)
- 로드 완료 → 다시 마운트 (effect 재실행)
React 18:
- Suspense가 활성화되면 → Effect 실행 안 함
- 로드 완료 → 마운트 + Effect 실행 (한 번만)
5. 데이터 프레임워크 통합 (실험적)
// Next.js 13+ (App Router)
async function UserProfile({ userId }) {
// ✅ React 18의 Suspense로 자동 처리
const user = await fetch(`/api/users/${userId}`).then(r => r.json());
return <div>{user.name}</div>;
}
function Page() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={123} />
</Suspense>
);
}
React 19 (2024년 12월)
use 훅 도입 및 초기 논란 - 커뮤니티의 피드백으로 수정
배경: React 팀은 Suspense를 데이터 페칭과 더 긴밀하게 통합하고자 했습니다. 이를 위해 use 훅을 도입했지만, 초기 구현에서 예상치 못한 성능 문제(Waterfall)가 발생했습니다.
논란의 핵심: React 18에서 완벽하게 작동하던 병렬 데이터 페칭이 React 19 초기 버전에서 순차적으로 변경되면서, 많은 앱의 성능이 저하되었습니다. 커뮤니티의 즉각적인 피드백 덕분에 React 팀은 릴리스를 보류하고 문제를 해결했습니다.
교훈: 오픈소스 커뮤니티의 피드백이 React의 품질을 유지하는 핵심 요소임을 보여준 사례
React 19는 Suspense에 큰 변화를 가져왔지만, 초기에 커뮤니티에서 논란이 있었습니다.
1. use 훅으로 Promise 읽기
import { use, Suspense } from 'react';
// Promise를 컴포넌트 밖에서 생성
const userPromise = fetch('/api/user').then(r => r.json());
function UserProfile() {
// ✅ use 훅으로 Promise 읽기
const user = use(userPromise);
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserProfile />
</Suspense>
);
}
주의사항:
// ❌ 잘못된 사용: 컴포넌트 내부에서 Promise 생성
function UserProfile() {
const userPromise = fetch('/api/user').then(r => r.json());
const user = use(userPromise); // 매 렌더링마다 새 요청 발생!
return <div>{user.name}</div>;
}
// ✅ 올바른 사용: 외부에서 생성하거나 캐싱
const userPromise = fetch('/api/user').then(r => r.json());
function UserProfile() {
const user = use(userPromise);
return <div>{user.name}</div>;
}
2. 데이터 페칭 모델 변경 (초기 논란)
React 18의 동작:
function Parent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Child1 /> {/* 병렬로 데이터 페칭 */}
<Child2 /> {/* 병렬로 데이터 페칭 */}
</Suspense>
);
}
// Child1과 Child2가 동시에 데이터 요청 → 빠름!
React 19.0 초기 버전의 동작:
function Parent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Child1 /> {/* 먼저 데이터 페칭 */}
<Child2 /> {/* Child1 완료 후 데이터 페칭 - Waterfall! */}
</Suspense>
);
}
// ❌ 순차적 페칭으로 성능 저하
커뮤니티 반응:
- React 18에서 잘 작동하던 코드가 React 19에서 느려짐
- “Waterfall” 문제 발생
- React 팀이 19.0 릴리스를 보류하고 수정
React 19 최종 버전:
// ✅ 다시 병렬 페칭으로 동작
function Parent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Child1 /> {/* 병렬 페칭 */}
<Child2 /> {/* 병렬 페칭 */}
</Suspense>
);
}
3. 폴백 표시 최적화
function App() {
const [data, setData] = useState(null);
return (
<Suspense fallback={<Spinner />}>
<DataComponent data={data} />
</Suspense>
);
}
React 18:
- 컴포넌트가 일시 중단되면 → 약간의 지연 후 폴백 표시
React 19:
- 컴포넌트가 일시 중단되면 → 즉시 폴백 표시
- 더 빠른 피드백
React 19.2 (2025년 10월)
서버 사이드 Suspense 배칭
// 서버 컴포넌트
function Page() {
return (
<div>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<MainSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<FooterSkeleton />}>
<Footer />
</Suspense>
</div>
);
}
React 19.0:
- Header 로드 완료 → 즉시 클라이언트에 전송
- MainContent 로드 완료 → 즉시 클라이언트에 전송
- Footer 로드 완료 → 즉시 클라이언트에 전송
React 19.2:
- 짧은 시간 동안 여러 Suspense boundary를 배칭
- 한 번에 여러 컴포넌트를 함께 전송
- 네트워크 왕복 횟수 감소 → 성능 향상
버전별 요약표
| 기능 | React 16.6 | React 17 | React 18 | React 19 | React 19.2 |
|---|---|---|---|---|---|
| 코드 분할 (lazy) | ✅ | ✅ | ✅ | ✅ | ✅ |
| 서버 사이드 렌더링 | ❌ | ❌ | ✅ | ✅ | ✅ |
| Transition 통합 | ❌ | 실험적 | ✅ | ✅ | ✅ |
| use 훅 | ❌ | ❌ | ❌ | ✅ | ✅ |
| 데이터 페칭 | ❌ | ❌ | 실험적 | ✅ | ✅ |
| 병렬 페칭 | N/A | N/A | ✅ | ✅ | ✅ |
| 서버 배칭 | N/A | N/A | ❌ | ❌ | ✅ |
실전 예제
예제 1: 대시보드 애플리케이션
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
// 각 페이지를 lazy로 로드
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Reports = lazy(() => import('./pages/Reports'));
const Settings = lazy(() => import('./pages/Settings'));
// 스켈레톤 로더
function PageSkeleton() {
return (
<div className="page-skeleton">
<div className="skeleton-header" />
<div className="skeleton-content">
<div className="skeleton-card" />
<div className="skeleton-card" />
<div className="skeleton-card" />
</div>
</div>
);
}
function App() {
return (
<BrowserRouter>
<div className="app">
<nav>
<Link to="/">Dashboard</Link>
<Link to="/analytics">Analytics</Link>
<Link to="/reports">Reports</Link>
<Link to="/settings">Settings</Link>
</nav>
<main>
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</main>
</div>
</BrowserRouter>
);
}
export default App;
결과:
- 초기 로딩: Dashboard만 로드 (다른 페이지는 제외)
- 페이지 이동 시: 해당 페이지만 추가 로드
- 번들 크기: 약 70% 감소 (페이지 4개 기준)
예제 2: 모달 다이얼로그
import { lazy, Suspense, useState } from 'react';
// 무거운 에디터 컴포넌트를 lazy로 로드
const RichTextEditor = lazy(() => import('./RichTextEditor'));
function CommentSection() {
const [isEditorOpen, setIsEditorOpen] = useState(false);
return (
<div>
<button onClick={() => setIsEditorOpen(true)}>
Write Comment
</button>
{isEditorOpen && (
<div className="modal">
<Suspense fallback={
<div className="editor-loading">
<div className="spinner" />
<p>Loading editor...</p>
</div>
}>
<RichTextEditor
onSave={(content) => {
console.log('Saved:', content);
setIsEditorOpen(false);
}}
onCancel={() => setIsEditorOpen(false)}
/>
</Suspense>
</div>
)}
</div>
);
}
이점:
- 사용자가 “Write Comment”를 클릭하기 전까지 에디터 코드 미포함
- 초기 번들 크기 대폭 감소
- 필요할 때만 로드
예제 3: 탭 인터페이스 (React 18+)
import { lazy, Suspense, useState, useTransition } from 'react';
// 각 탭 컴포넌트를 lazy로 로드
const ProfileTab = lazy(() => import('./tabs/ProfileTab'));
const PostsTab = lazy(() => import('./tabs/PostsTab'));
const PhotosTab = lazy(() => import('./tabs/PhotosTab'));
const VideosTab = lazy(() => import('./tabs/VideosTab'));
function UserProfileTabs({ userId }) {
const [activeTab, setActiveTab] = useState('profile');
const [isPending, startTransition] = useTransition();
const handleTabClick = (tab) => {
startTransition(() => {
setActiveTab(tab);
});
};
return (
<div className="tabs-container">
<div className="tab-buttons">
<button
onClick={() => handleTabClick('profile')}
className={activeTab === 'profile' ? 'active' : ''}
disabled={isPending}
>
Profile
</button>
<button
onClick={() => handleTabClick('posts')}
className={activeTab === 'posts' ? 'active' : ''}
disabled={isPending}
>
Posts
</button>
<button
onClick={() => handleTabClick('photos')}
className={activeTab === 'photos' ? 'active' : ''}
disabled={isPending}
>
Photos
</button>
<button
onClick={() => handleTabClick('videos')}
className={activeTab === 'videos' ? 'active' : ''}
disabled={isPending}
>
Videos
</button>
</div>
{isPending && (
<div className="loading-bar">
<div className="loading-progress" />
</div>
)}
<div className="tab-content">
<Suspense fallback={<TabSkeleton />}>
{activeTab === 'profile' && <ProfileTab userId={userId} />}
{activeTab === 'posts' && <PostsTab userId={userId} />}
{activeTab === 'photos' && <PhotosTab userId={userId} />}
{activeTab === 'videos' && <VideosTab userId={userId} />}
</Suspense>
</div>
</div>
);
}
function TabSkeleton() {
return (
<div className="tab-skeleton">
<div className="skeleton-line" />
<div className="skeleton-line" />
<div className="skeleton-line" />
</div>
);
}
사용자 경험:
- 사용자가 “Posts” 탭 클릭
isPending이true가 되어 로딩 바 표시- 현재 “Profile” 탭 내용은 유지 (깜빡임 없음)
PostsTab로드 완료 → 부드럽게 전환
예제 4: 에러 바운더리와 재시도
import { lazy, Suspense, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
const HeavyChart = lazy(() => import('./HeavyChart'));
function ChartSection({ data }) {
const [retryCount, setRetryCount] = useState(0);
return (
<ErrorBoundary
resetKeys={[retryCount]}
fallbackRender={({ error, resetErrorBoundary }) => (
<div className="error-container">
<h3>Failed to load chart</h3>
<p>{error.message}</p>
<button onClick={resetErrorBoundary}>
Retry
</button>
</div>
)}
onReset={() => setRetryCount(c => c + 1)}
>
<Suspense fallback={
<div className="chart-loading">
<div className="spinner" />
<p>Loading chart...</p>
</div>
}>
<HeavyChart data={data} />
</Suspense>
</ErrorBoundary>
);
}
시나리오:
- 네트워크 오류로 차트 로드 실패
- 에러 UI 표시
- 사용자가 “Retry” 클릭
retryCount변경 → ErrorBoundary 리셋- 차트 재로드 시도
예제 5: Prefetching 전략
import { lazy, Suspense, useState, useEffect } from 'react';
const ProductDetails = lazy(() => import('./ProductDetails'));
// Prefetch 함수
const preloadProductDetails = () => {
import('./ProductDetails');
};
function ProductCard({ product }) {
const [showDetails, setShowDetails] = useState(false);
// 컴포넌트 마운트 후 3초 뒤에 자동 사전 로드
useEffect(() => {
const timer = setTimeout(preloadProductDetails, 3000);
return () => clearTimeout(timer);
}, []);
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
<button
onClick={() => setShowDetails(true)}
onMouseEnter={preloadProductDetails} // 마우스 오버 시 사전 로드
>
View Details
</button>
{showDetails && (
<Suspense fallback={<div>Loading details...</div>}>
<ProductDetails product={product} />
</Suspense>
)}
</div>
);
}
전략:
- 즉시 로드: 사용자 액션 없이 미리 로드
- 마우스 오버: 버튼에 마우스를 올리면 사전 로드
- 지연 로드: 3초 후 자동 사전 로드
베스트 프랙티스
1. lazy는 컴포넌트 외부에서 선언
// ❌ 나쁜 예: 컴포넌트 내부에서 선언
function App() {
const LazyComponent = lazy(() => import('./Component'));
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
}
// 문제: 매 렌더링마다 새로운 컴포넌트가 생성됨
// 결과: 상태가 리셋되고 불필요한 재로드 발생
// ✅ 좋은 예: 모듈 최상위에서 선언
const LazyComponent = lazy(() => import('./Component'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
}
2. 적절한 Suspense 경계 설정
// ❌ 나쁜 예: 너무 큰 Suspense 경계
function App() {
return (
<Suspense fallback={<FullPageLoader />}>
<Header />
<Sidebar />
<MainContent />
<Footer />
</Suspense>
);
}
// 문제: 하나라도 로딩 중이면 전체 페이지가 로더로 표시됨
// ✅ 좋은 예: 적절한 크기의 Suspense 경계
function App() {
return (
<div>
<Header /> {/* 항상 표시 */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
</Suspense>
<Footer /> {/* 항상 표시 */}
</div>
);
}
3. 의미 있는 폴백 UI 제공
// ❌ 나쁜 예: 단순한 로딩 텍스트
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
</Suspense>
);
}
// ✅ 좋은 예: 스켈레톤 UI로 레이아웃 힌트 제공
function App() {
return (
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile />
</Suspense>
);
}
function UserProfileSkeleton() {
return (
<div className="user-profile-skeleton">
<div className="skeleton-avatar" />
<div className="skeleton-name" />
<div className="skeleton-bio" />
<div className="skeleton-stats">
<div className="skeleton-stat" />
<div className="skeleton-stat" />
<div className="skeleton-stat" />
</div>
</div>
);
}
4. 라우트 단위로 코드 분할
// ✅ 가장 효과적인 패턴
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
이점:
- 사용자는 현재 페이지만 다운로드
- 초기 번들 크기 대폭 감소
- 명확한 로딩 경계
5. Transition 활용 (React 18+)
import { lazy, Suspense, useState, useTransition } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
const [show, setShow] = useState(false);
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(() => {
setShow(true);
});
};
return (
<div>
<button onClick={handleClick} disabled={isPending}>
{isPending ? 'Loading...' : 'Show Component'}
</button>
{show && (
<Suspense fallback={<div>Loading component...</div>}>
<HeavyComponent />
</Suspense>
)}
</div>
);
}
6. 에러 바운더리 설정
import { lazy, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
const LazyComponent = lazy(() => import('./LazyComponent'));
function App() {
return (
<ErrorBoundary
fallback={<div>Failed to load component. Please refresh.</div>}
onError={(error) => {
// 에러 로깅
console.error('Lazy load error:', error);
}}
>
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
</ErrorBoundary>
);
}
7. 번들 분석 및 최적화
# Webpack Bundle Analyzer 설치
npm install --save-dev webpack-bundle-analyzer
# package.json
{
"scripts": {
"analyze": "webpack-bundle-analyzer build/stats.json"
}
}
분석 포인트:
- 각 lazy 컴포넌트의 크기
- 불필요한 의존성 확인
- 중복된 코드 제거
주의사항
1. 과도한 코드 분할 피하기
// ❌ 나쁜 예: 모든 작은 컴포넌트를 lazy로 분할
const Button = lazy(() => import('./Button')); // 2KB
const Input = lazy(() => import('./Input')); // 3KB
const Icon = lazy(() => import('./Icon')); // 1KB
function Form() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Button /> {/* 네트워크 요청 1 */}
<Input /> {/* 네트워크 요청 2 */}
<Icon /> {/* 네트워크 요청 3 */}
</Suspense>
);
}
// 문제: 너무 많은 네트워크 요청으로 오히려 느려짐
// ✅ 좋은 예: 큰 컴포넌트만 분할
import Button from './Button';
import Input from './Input';
import Icon from './Icon';
const HeavyForm = lazy(() => import('./HeavyForm')); // 200KB
function App() {
return (
<div>
<Button /> {/* 작은 컴포넌트는 직접 import */}
<Input />
<Icon />
<Suspense fallback={<div>Loading form...</div>}>
<HeavyForm /> {/* 큰 컴포넌트만 lazy */}
</Suspense>
</div>
);
}
규칙: 최소 50KB 이상의 컴포넌트만 lazy로 분할
2. 서버 사이드 렌더링 고려
// React 16/17에서는 동작하지 않음
// React 18+에서만 가능
// SSR 환경 확인
const isBrowser = typeof window !== 'undefined';
// 조건부 lazy 사용
const LazyComponent = isBrowser
? lazy(() => import('./ClientOnlyComponent'))
: () => null;
3. 무한 로딩 방지
// ❌ 나쁜 예: Promise가 resolve되지 않음
const BrokenComponent = lazy(() => {
return new Promise((resolve) => {
// resolve가 호출되지 않음!
});
});
// ✅ 좋은 예: 반드시 resolve
const WorkingComponent = lazy(() => {
return import('./Component'); // Promise가 자동으로 resolve됨
});
// 또는 명시적으로 resolve
const ManualComponent = lazy(() => {
return new Promise((resolve) => {
import('./Component').then((module) => {
resolve(module);
});
});
});
4. 기본 내보내기(default export) 필수
// Component.jsx
// ❌ 나쁜 예: named export
export function Component() {
return <div>Hello</div>;
}
// ✅ 좋은 예 1: default export
export default function Component() {
return <div>Hello</div>;
}
// ✅ 좋은 예 2: named export를 default로 변환
export function Component() {
return <div>Hello</div>;
}
// App.jsx
const LazyComponent = lazy(() =>
import('./Component').then(module => ({
default: module.Component
}))
);
5. 의존성 주의
// ❌ 나쁜 예: 순환 의존성
// A.jsx
import { lazy, Suspense } from 'react';
const B = lazy(() => import('./B'));
export function A() {
return (
<Suspense fallback={<div>Loading...</div>}>
<B />
</Suspense>
);
}
// B.jsx
import { lazy, Suspense } from 'react';
const A = lazy(() => import('./A')); // 순환 의존성!
export function B() {
return (
<Suspense fallback={<div>Loading...</div>}>
<A />
</Suspense>
);
}
// ✅ 좋은 예: 의존성 제거 또는 재구성
6. React 버전 확인
// package.json에서 React 버전 확인
{
"dependencies": {
"react": "^18.0.0" // 최소 18.0.0 이상 권장
}
}
// 버전별 기능 확인
import { version } from 'react';
if (parseFloat(version) < 18) {
console.warn('React 18+ recommended for full Suspense support');
}
성능 측정
1. Webpack Bundle Analyzer
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: 'bundle-report.html',
})
]
};
2. Chrome DevTools
// Performance 탭에서 측정
// 1. 녹화 시작
// 2. 페이지 로드 또는 상호작용
// 3. 녹화 중지
// 4. "Timings" 섹션에서 lazy chunk 로딩 시간 확인
3. Lighthouse
# Chrome DevTools → Lighthouse 탭
# "Generate report" 클릭
# 주요 지표:
# - First Contentful Paint (FCP)
# - Largest Contentful Paint (LCP)
# - Time to Interactive (TTI)
4. React DevTools Profiler
import { Profiler } from 'react';
function onRenderCallback(
id, // Profiler id
phase, // "mount" or "update"
actualDuration, // 렌더링 시간
baseDuration, // 메모이제이션 없는 예상 시간
startTime,
commitTime
) {
console.log(`${id} took ${actualDuration}ms to render`);
}
function App() {
return (
<Profiler id="LazyComponent" onRender={onRenderCallback}>
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
</Profiler>
);
}
참고 자료
React 공식 문서
<Suspense>– React - Suspense 컴포넌트 공식 문서lazy()– React - lazy 함수 공식 문서- Code Splitting – React - 코드 분할 가이드
- React v18.0 Release - React 18의 Suspense 변경사항
- React v19.0 Release - React 19의 새로운 기능
커뮤니티 리소스
- Behavioral changes to Suspense in React 18 - React 18 Suspense 변경사항 논의
- React 19 and Suspense - A Drama in 3 Acts - React 19 Suspense 논란 정리
- Using React 18’s Suspense to Improve Code Quality - 실전 활용 사례
도구
- webpack-bundle-analyzer - 번들 크기 분석
- React DevTools - 프로파일링 도구
- Lighthouse - 성능 측정
정리
React의 Suspense와 lazy loading은 웹 애플리케이션의 성능을 크게 향상시킬 수 있는 강력한 도구입니다.
핵심 포인트:
- lazy()로 컴포넌트 코드 분할
- 큰 컴포넌트만 분할 (50KB+ 권장)
- 모듈 최상위에서 선언
- default export 필수
- Suspense로 로딩 상태 관리
- 적절한 크기의 경계 설정
- 의미 있는 폴백 UI 제공
- 중첩 Suspense로 단계적 로딩
- React 버전별 차이 이해
- React 16.6: 최초 도입 (클라이언트 전용)
- React 18: 서버 렌더링 지원, Transition 통합
- React 19: use 훅, 데이터 페칭 개선
- 베스트 프랙티스
- 라우트 단위 코드 분할
- 에러 바운더리 설정
- Transition 활용 (React 18+)
- 성능 측정 및 최적화
시작하기:
// 1. 간단한 lazy import
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}
이 가이드를 따라 하면 React 애플리케이션의 초기 로딩 속도를 크게 개선하고, 사용자 경험을 향상시킬 수 있습니다.
댓글