React.lazy와 Suspense로 컴포넌트를 언제 불러오고 로딩 UI를 어떻게 보여주나요?
React.lazy와 동적 import를 연결하면 컴포넌트를 처음 표시할 때 코드를 불러올 수 있습니다. Suspense는 해당 영역의 로딩 안내를 담당합니다.
적용 조건·범위: 로컬 개발 환경이 너무 빨라 fallback을 거의 볼 수 없을 수 있습니다. DevTools 네트워크 throttling이나 번들 결과를 함께 확인합니다.
본문 근거: React lazy · React Suspense
독립 실습: 별도 파일을 첫 표시 때 가져오기
통계 전환 버튼으로 lazy 컴포넌트를 표시하거나 숨겨 보세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
App과 같은 폴더에 아래 src/StatsPanel.tsx 파일도 새로 저장합니다. StatsPanel은 임의의 프로젝트 컴포넌트가 아니라 이 실습에서 함께 제공하는 파일입니다.
src/App.tsx 전체 코드
import { lazy, Suspense, useState } from 'react';
const StatsPanel = lazy(() => import('./StatsPanel'));
export default function App() {
const [show, setShow] = useState(false);
return (
<main>
<h1>지연 로딩 연습</h1>
<button onClick={() => setShow(value => !value)}>통계 전환</button>
{show && (
<Suspense fallback={<p>통계 불러오는 중</p>}>
<StatsPanel />
</Suspense>
)}
</main>
);
}
src/StatsPanel.tsx 전체 코드
export default function StatsPanel() {
return <p>샘플 합계: 3</p>;
}
예상 결과와 직접 확인: 처음에는 제목과 버튼만 표시됩니다. 버튼을 누르면 통계 파일을 불러와 샘플 합계: 3을 표시합니다. Network에서 StatsPanel 모듈 요청 시점을 확인하고 npm run build의 별도 chunk를 확인합니다. 빠른 로컬 환경에서는 fallback이 눈에 보이지 않을 수 있습니다.
변형 과제: show 기본값을 true로 바꿔 첫 요청 시점이 어떻게 달라지는지 설명합니다.
기본 사용법
import {
lazy,
Suspense,
} from 'react';
const StatsPanel = lazy(
() => import('./StatsPanel'),
);
export default function Dashboard() {
return (
<Suspense fallback={<p>통계 불러오는 중...</p>}>
<StatsPanel />
</Suspense>
);
}
StatsPanel 코드는 Dashboard 파일을 처음 평가할 때 바로 불러오는 것이 아니라 실제로 렌더링할 때 필요해집니다.
lazy는 컴포넌트 바깥에서 선언합니다
다음처럼 컴포넌트 내부에서 lazy를 호출하면 렌더링 때마다 새 컴포넌트 타입을 만들 수 있습니다.
function App() {
const StatsPanel = lazy(
() => import('./StatsPanel'),
);
return <StatsPanel />;
}
일반적으로 모듈 최상위에서 선언합니다.
const StatsPanel = lazy(
() => import('./StatsPanel'),
);
Suspense는 로딩 경계입니다
Suspense를 앱 최상단에 하나만 두면 작은 화면 하나를 로드하는 동안 전체 UI가 fallback으로 바뀔 수 있습니다.
가능하면 사용자가 이미 보고 있는 UI는 유지하고, 늦게 준비되는 영역 가까이에 경계를 둡니다.
<section>
<h2>통계</h2>
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
</section>
언제 lazy가 필요한가
다음과 같은 화면이 후보입니다.
- 설정 화면
- 관리자 전용 화면
- 큰 차트 라이브러리를 사용하는 통계 화면
- 처음 진입할 때 필요하지 않은 상세 기능
반대로 아주 작고 항상 필요한 Header 같은 컴포넌트를 모두 lazy로 나누면 요청이 늘고 구조가 복잡해질 수 있습니다.
자주 발생하는 오류
fallback이 전체 화면을 덮습니다
Suspense 경계를 필요한 UI 가까이 내립니다.
lazy import 대상에 default export가 없습니다
기본 lazy 사용법에서는 import한 모듈의 default export가 컴포넌트여야 합니다.
로딩 효과가 보이지 않습니다
로컬 개발 환경이 너무 빨라 fallback을 거의 볼 수 없을 수 있습니다. DevTools 네트워크 throttling이나 번들 결과를 함께 확인합니다.
학습 완료 체크리스트
- lazy가 어떤 문제를 해결하는지 설명할 수 있다
- Suspense fallback이 표시되는 조건을 설명할 수 있다
- lazy 선언 위치를 올바르게 선택할 수 있다
- 모든 컴포넌트를 지연 로딩하지 않아야 하는 이유를 설명할 수 있다
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
TaskFlow에는 작업 목록·폼·검색 필터가 있습니다. 이번에는 초기 화면에 필수는 아닌 작업 통계 패널을 지연 로딩합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 24단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React TypeScript 검색 필터 만들기 상태 결과 목록 연결의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx, src/components/StatsSkeleton.tsx, src/components/TaskStats.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
독자 질문과 직접 확인
질문: 초기 화면에 당장 필요 없는 TaskStats를 분리하면 어느 영역에 로딩 안내가 나타나나요?
컴포넌트 실습: step-24-lazy-suspense에서 통계 UI를 여는 동작과 목록 검색을 각각 확인합니다. lazy 선언 위치와 Suspense fallback 범위가 목록까지 가리지 않는지 봅니다.
예상 화면·상태: 통계 코드를 기다리는 동안 통계 영역의 fallback만 보이고 TaskFlow의 목록과 검색은 계속 사용 가능해야 합니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.
오류 점검: 로딩 경계가 너무 넓으면 Suspense를 TaskStats 주변으로 좁히고, 반복 로딩이면 lazy 선언을 컴포넌트 바깥에 두었는지 확인합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 25회에서 검색 입력의 즉시 반응과 느린 결과 갱신을 분리합니다.
완료 코드 ZIP 다운로드 — step-24-lazy-suspense
이전 체크포인트:
step-23-search-filter
TaskFlow에는 작업 목록·폼·검색 필터가 있습니다. 이번에는 초기 화면에 필수는 아닌 작업 통계 패널을 지연 로딩합니다.
변경 파일
src/
├─ components/
│ ├─ StatsSkeleton.tsx
│ └─ TaskStats.tsx
└─ App.tsx
Before
import TaskStats from './components/TaskStats';
<TaskStats tasks={tasks} />
TaskStats 코드가 초기 번들에 포함됩니다.
After
import {
lazy,
Suspense,
} from 'react';
const TaskStats = lazy(
() => import('./components/TaskStats'),
);
<Suspense fallback={<StatsSkeleton />}>
<TaskStats tasks={tasks} />
</Suspense>
App 적용 부분
import {
lazy,
Suspense,
} from 'react';
import StatsSkeleton from './components/StatsSkeleton';
const TaskStats = lazy(
() => import('./components/TaskStats'),
);
// 기존 TaskFlow UI 아래
<Suspense fallback={<StatsSkeleton />}>
<TaskStats tasks={tasks} />
</Suspense>
실행 확인
npm run build
빌드 결과에서 TaskStats가 별도 chunk로 분리되는지 확인합니다.
브라우저에서는:
– 기존 작업 목록이 먼저 표시되는지
– 통계 영역만 fallback을 거쳐 표시되는지
– 통계 값이 기존 tasks state와 일치하는지
확인합니다.
완료 체크포인트:
step-24-lazy-suspense
전체 코드 뷰어의 모든 파일은 다운로드 ZIP과 같습니다. ZIP은 독립 실행할 수 있는 완료 시점의 코드입니다. Node.js 22.12 이상에서 npm install 후 npm run dev로 실행하고, npm run build로 검사합니다.
전체 파일·코드 보기
실습 프로젝트 전체 코드
index.html
<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
package.json
{
"name": "taskflow-step-24-lazy-suspense",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "7.88.0"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.3.0",
"@vitejs/plugin-react": "6.1.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/node": "26.6.2"
}
}
src/App.tsx
import { useEffect, lazy, Suspense } from 'react';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import TaskFilter from './components/TaskFilter';
import StateResetDemo from './components/StateResetDemo';
import NetworkStatus from './components/NetworkStatus';
import TaskSummary from './components/TaskSummary';
import { useTaskContext } from './context/TaskContext';
import { useTaskStats } from './hooks/useTaskStats';
import { useTaskFilter } from './hooks/useTaskFilter';
import Header from './components/Header';
import StatsSkeleton from './components/StatsSkeleton';
const TaskStats = lazy(() => import('./components/TaskStats'));
export default function TaskListPage() {
const { tasks } = useTaskContext();
const { totalCount, completedCount, remainingCount } = useTaskStats();
const { keyword, setKeyword, status, setStatus, visibleTasks, resetFilter } = useTaskFilter(tasks);
useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
return <main className="app">
<Header />
<NetworkStatus />
<TaskSummary total={totalCount} completed={completedCount} remaining={remainingCount} />
<TaskForm />
<TaskFilter keyword={keyword} onKeywordChange={setKeyword} status={status} onStatusChange={setStatus} onReset={resetFilter} />
<TaskList tasks={visibleTasks} />
<Suspense fallback={<StatsSkeleton />}><TaskStats tasks={tasks} /></Suspense>
<StateResetDemo />
</main>;
}
src/components/Header.tsx
export default function Header() {
return (
<header>
<p className="eyebrow">React Learning Project</p>
<h1>TaskFlow</h1>
<p>오늘 할 일을 하나씩 정리해 보세요.</p>
</header>
);
}
src/components/NetworkStatus.tsx
import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
const [showDebugMessage, setShowDebugMessage] = useState(false);
const isOnline = useNetworkStatus(showDebugMessage);
return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
<label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}
src/components/StateResetDemo.tsx
import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
{ id: 'example-1', title: '첫 번째 작업', completed: false },
{ id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
const [selectedId, setSelectedId] = useState(examples[0].id);
const selectedTask = examples.find(task => task.id === selectedId)!;
return <details><summary>state 초기화 실습</summary>
<p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
{examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
<TaskEditor key={selectedTask.id} task={selectedTask} />
</details>;
}
src/components/StatsSkeleton.tsx
export default function StatsSkeleton() {
return (
<section aria-busy="true">
<h2>작업 통계</h2>
<p>통계를 불러오는 중...</p>
</section>
);
}
src/components/TaskCard.tsx
import type { ReactNode } from 'react';
type TaskCardProps = {
children: ReactNode;
actions?: ReactNode;
};
export default function TaskCard({
children,
actions,
}: TaskCardProps) {
return (
<li className="task-card">
<div className="task-card__content">
{children}
</div>
{actions ? (
<div className="task-card__actions">
{actions}
</div>
) : null}
</li>
);
}
src/components/TaskEditor.tsx
import { useState } from 'react';
import type { Task } from '../types/task';
type TaskEditorProps = {
task: Task;
};
export default function TaskEditor({
task,
}: TaskEditorProps) {
const [title, setTitle] = useState(task.title);
return (
<section>
<h2>작업 편집</h2>
<label htmlFor="editor-title">편집 입력 연습</label>
<input id="editor-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<p>선택한 작업 ID: {task.id}</p>
</section>
);
}
src/components/TaskFilter.tsx
import type {
Dispatch,
SetStateAction,
} from 'react';
import type { TaskStatusFilter } from '../types/task';
type TaskFilterProps = {
keyword: string;
onKeywordChange: Dispatch<SetStateAction<string>>;
status: TaskStatusFilter;
onStatusChange: Dispatch<
SetStateAction<TaskStatusFilter>
>;
onReset: () => void;
};
export default function TaskFilter({
keyword,
onKeywordChange,
status,
onStatusChange,
onReset,
}: TaskFilterProps) {
return (
<section>
<label htmlFor="task-keyword">
작업 검색
</label>
<input
id="task-keyword"
value={keyword}
onChange={(event) =>
onKeywordChange(event.target.value)
}
/>
<label htmlFor="task-status">
상태
</label>
<select
id="task-status"
value={status}
onChange={(event) =>
onStatusChange(
event.target.value as TaskStatusFilter,
)
}
>
<option value="all">전체</option>
<option value="active">진행 중</option>
<option value="completed">완료</option>
</select>
<button type="button" onClick={onReset}>
필터 초기화
</button>
</section>
);
}
src/components/TaskForm.tsx
import { useForm } from 'react-hook-form';
import { useTaskContext } from '../context/TaskContext';
type TaskFormValues = {
title: string;
};
export default function TaskForm() {
const { dispatch } = useTaskContext();
const {
register,
handleSubmit,
setValue,
setFocus,
formState: { errors },
} = useForm<TaskFormValues>({
defaultValues: {
title: '',
},
});
const onSubmit = (values: TaskFormValues) => {
const title = values.title.trim();
dispatch({
type: 'task/added',
task: {
id: crypto.randomUUID(),
title,
completed: false,
},
});
setValue('title', '');
setFocus('title');
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="task-title">
새 작업
</label>
<input
id="task-title"
aria-invalid={Boolean(errors.title)}
aria-describedby={
errors.title ? 'task-title-error' : undefined
}
{...register('title', {
setValueAs: (value: string) => value.trim(),
required: '작업 제목을 입력해 주세요.',
minLength: {
value: 2,
message: '작업 제목은 2자 이상 입력해 주세요.',
},
validate: (value) =>
value.trim().length > 0 ||
'공백만 입력할 수 없습니다.',
})}
/>
{errors.title && (
<p id="task-title-error" role="alert">
{errors.title.message}
</p>
)}
<button type="submit">
추가
</button>
</form>
);
}
src/components/TaskItem.tsx
import TaskCard from './TaskCard';
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
type TaskItemProps = {
task: Task;
};
export default function TaskItem({
task,
}: TaskItemProps) {
const { dispatch } = useTaskContext();
return (
<TaskCard>
<button
type="button"
onClick={() =>
dispatch({
type: 'task/toggled',
id: task.id,
})
}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span>{task.title}</span>
<button
type="button"
onClick={() =>
dispatch({
type: 'task/deleted',
id: task.id,
})
}
>
삭제
</button>
</TaskCard>
);
}
src/components/TaskList.tsx
import { useTaskContext } from '../context/TaskContext';
import type { Task } from '../types/task';
import TaskItem from './TaskItem';
type TaskListProps = {
tasks: Task[];
};
export default function TaskList({
tasks,
}: TaskListProps) {
const { tasks: allTasks } = useTaskContext();
if (allTasks.length === 0) {
return <p>등록된 작업이 없습니다.</p>;
}
if (tasks.length === 0) {
return <p>조건에 맞는 작업이 없습니다.</p>;
}
return (
<ul>
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
);
}
src/components/TaskStats.tsx
import type { Task } from '../types/task';
type TaskStatsProps = {
tasks: Task[];
};
export default function TaskStats({
tasks,
}: TaskStatsProps) {
const completedCount = tasks.filter(
(task) => task.completed,
).length;
return (
<section>
<h2>작업 통계</h2>
<p>전체: {tasks.length}</p>
<p>완료: {completedCount}</p>
<p>진행 중: {tasks.length - completedCount}</p>
</section>
);
}
src/components/TaskSummary.tsx
type TaskSummaryProps = {
total: number;
completed: number;
remaining: number;
label?: string;
};
export default function TaskSummary({
total,
completed,
remaining,
label = '작업 현황',
}: TaskSummaryProps) {
return (
<section aria-label={label}>
<p>
전체 {total} · 완료 {completed} · 남음 {remaining}
</p>
</section>
);
}
src/context/TaskContext.tsx
import {
createContext,
useContext,
useReducer,
type Dispatch,
type ReactNode,
} from 'react';
import { taskReducer } from '../reducers/taskReducer';
import type {
Task,
TaskAction,
} from '../types/task';
type TaskContextValue = {
tasks: Task[];
dispatch: Dispatch<TaskAction>;
};
const TaskContext = createContext<
TaskContextValue | undefined
>(undefined);
const initialTasks: Task[] = [
{
id: 'task-1',
title: 'Reducer와 Context 연결하기',
completed: false,
},
];
export function TaskProvider({
children,
}: {
children: ReactNode;
}) {
const [tasks, dispatch] = useReducer(
taskReducer,
initialTasks,
);
return (
<TaskContext.Provider
value={{ tasks, dispatch }}
>
{children}
</TaskContext.Provider>
);
}
export function useTaskContext() {
const value = useContext(TaskContext);
if (!value) {
throw new Error(
'useTaskContext는 TaskProvider 안에서 사용해야 합니다.',
);
}
return value;
}
src/hooks/useNetworkStatus.ts
import {
useEffect,
useEffectEvent,
useState,
} from 'react';
export function useNetworkStatus(
showDebugMessage: boolean,
) {
const [isOnline, setIsOnline] = useState(
navigator.onLine,
);
const onOnline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 온라인 상태입니다.');
}
setIsOnline(true);
});
const onOffline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 오프라인 상태입니다.');
}
setIsOnline(false);
});
useEffect(() => {
const handleOnline = () => onOnline();
const handleOffline = () => onOffline();
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener(
'online',
handleOnline,
);
window.removeEventListener(
'offline',
handleOffline,
);
};
}, []);
return isOnline;
}
src/hooks/useTaskFilter.ts
import { useState } from 'react';
import type {
Task,
TaskStatusFilter,
} from '../types/task';
export function useTaskFilter(tasks: Task[]) {
const [keyword, setKeyword] = useState('');
const [status, setStatus] =
useState<TaskStatusFilter>('all');
const normalizedKeyword = keyword
.trim()
.toLowerCase();
const visibleTasks = tasks.filter((task) => {
const matchesKeyword = task.title
.toLowerCase()
.includes(normalizedKeyword);
const matchesStatus =
status === 'all' ||
(status === 'active' && !task.completed) ||
(status === 'completed' && task.completed);
return matchesKeyword && matchesStatus;
});
const resetFilter = () => {
setKeyword('');
setStatus('all');
};
return {
keyword,
setKeyword,
status,
setStatus,
visibleTasks,
resetFilter,
};
}
src/hooks/useTaskStats.ts
import { useTaskContext } from '../context/TaskContext';
export function useTaskStats() {
const { tasks } = useTaskContext();
const totalCount = tasks.length;
const completedCount = tasks.filter(
(task) => task.completed,
).length;
const remainingCount =
totalCount - completedCount;
return {
totalCount,
completedCount,
remainingCount,
};
}
src/index.css
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
color: #111;
background: #f5f5f5;
}
.app {
width: min(720px, calc(100% - 32px));
margin: 48px auto;
}
.eyebrow {
color: #666;
font-size: 14px;
}
ul {
padding-left: 20px;
}
button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }
src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TaskProvider>
<App />
</TaskProvider>
</StrictMode>,
);
src/reducers/taskReducer.ts
import type {
Task,
TaskAction,
} from '../types/task';
export function taskReducer(
tasks: Task[],
action: TaskAction,
): Task[] {
switch (action.type) {
case 'task/added':
return [...tasks, action.task];
case 'task/toggled':
return tasks.map((task) =>
task.id === action.id
? { ...task, completed: !task.completed }
: task,
);
case 'task/deleted':
return tasks.filter(
(task) => task.id !== action.id,
);
default:
return tasks;
}
}
src/types/task.ts
export type TaskId = string;
export type Task = {
id: TaskId;
title: string;
completed: boolean;
};
export type TaskAction =
| {
type: 'task/added';
task: Task;
}
| {
type: 'task/toggled';
id: TaskId;
}
| {
type: 'task/deleted';
id: TaskId;
};
export type TaskStatusFilter = 'all' | 'active' | 'completed';
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"types": [
"vite/client"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"vite.config.ts"
]
}
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });
글 소개·학습 준비·전체 목차

React · 24/32 · TaskFlow 누적 실습
React lazy Suspense 코드 스플리팅 지연 로딩
React · 24 / 32 · 필수 학습 · lazy·Suspense
React.lazy에 동적 import를 연결하면 컴포넌트를 처음 렌더링할 때 해당 코드를 불러올 수 있습니다. Suspense는 그 코드를 기다리는 영역에 fallback UI를 보여 줍니다. 별도 StatsPanel 파일을 버튼으로 표시하며 로딩 경계와 요청 시점을 확인합니다.
이 글에서는 코드 스플리팅과 로딩 경계를 독립적으로 설명하고, TaskFlow의 통계 화면을 지연 로딩합니다.
이번 글에서 배우는 내용
- dynamic import
- React.lazy
- Suspense fallback
- 코드 스플리팅
- 로딩 경계 설계
- 초기 번들과 지연 로딩의 관계
목차
다음 학습
다음 글에서는 검색 input처럼 즉시 반응해야 하는 업데이트와, 작업 수가 많을 때 조금 늦게 처리해도 되는 결과 목록 업데이트를 분리합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.