React DevTools Profiler로 리렌더링 원인을 어떻게 측정하나요?
리렌더링 원인은 React DevTools Profiler에서 사용자 동작 하나를 기록한 뒤 해당 commit의 변화를 살펴봅니다. 같은 데이터와 조작을 반복해 렌더 횟수와 시간을 비교해야 최적화가 필요한지 판단할 수 있습니다.
적용 조건·범위: 개발 환경은 추가 검사와 도구 오버헤드가 있습니다. 한 번의 측정값보다 같은 조건에서 변화 전후를 비교하는 방식이 중요합니다. 일반 프로덕션 빌드에서는 profiling이 기본 비활성화되어 있으므로 개발 서버의 측정과 프로덕션 profiling 빌드의 측정을 구분합니다.
본문 근거: React Profiler
독립 실습: Profiler로 한 번의 클릭 기록하기
카운터를 클릭하고 실제 Profiler 측정 출력을 펼쳐 확인하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { Profiler, useState, type ProfilerOnRenderCallback } from 'react';
const report: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration) => {
console.table({ id, phase, actualDuration, baseDuration });
};
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(value => value + 1)}>횟수 {count}</button>;
}
export default function App() {
return (
<Profiler id="독립 Counter" onRender={report}>
<Counter />
</Profiler>
);
}
예상 결과와 직접 확인: 처음에는 횟수 0, 버튼을 한 번 누르면 횟수 1이 표시됩니다. Console의 처음 mount와 클릭 후 update 로그를 구분합니다. DevTools Profiler 기록을 시작하고 한 번 클릭해 종료한 뒤 Counter가 갱신된 commit을 찾습니다. 수치는 환경마다 달라 고정 정답이 없습니다.
변형 과제: 같은 동작을 세 번 기록해 시간 차이와 렌더 횟수를 분리해서 적고 한 번의 수치만으로 최적화를 결정하지 않는 이유를 설명합니다.
React DevTools Profiler
브라우저에 React Developer Tools를 설치한 뒤 개발자 도구의 Profiler 탭을 엽니다.
일반적인 측정 순서:
- 기록을 시작합니다.
- 하나의 사용자 동작을 수행합니다.
- 기록을 종료합니다.
- 어떤 컴포넌트가 렌더링됐는지 봅니다.
- 같은 동작을 여러 번 비교합니다.
예를 들어 TaskFlow에서 다음 동작을 따로 측정할 수 있습니다.
- 검색어 한 글자 입력
- 작업 하나 완료 처리
- Dialog 열기
- Router로 상세 화면 이동
<Profiler> 컴포넌트
코드에서 측정값을 받고 싶다면 Profiler를 사용할 수 있습니다.
import {
Profiler,
type ProfilerOnRenderCallback,
} from 'react';
const handleRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
) => {
console.table({
id,
phase,
actualDuration,
baseDuration,
});
};
<Profiler id="TaskList" onRender={handleRender}>
<TaskList tasks={visibleTasks} />
</Profiler>
actualDuration은 현재 업데이트에서 해당 트리를 렌더링하는 데 사용된 시간을 보여줍니다.
baseDuration은 최적화가 없다고 가정했을 때 전체 하위 트리를 다시 렌더링하는 비용을 추정하는 데 사용할 수 있습니다.
숫자 하나만으로 결론내리지 않습니다
개발 환경은 추가 검사와 도구 오버헤드가 있습니다. 한 번의 측정값보다 같은 조건에서 변화 전후를 비교하는 방식이 중요합니다. 일반 프로덕션 빌드에서는 profiling이 기본 비활성화되어 있으므로 개발 서버의 측정과 프로덕션 profiling 빌드의 측정을 구분합니다.
또한 렌더링이 한 번 더 일어났다는 이유만으로 최적화할 필요는 없습니다. 렌더링 비용이 매우 작다면 추가 memoization 코드가 오히려 유지보수 비용만 늘릴 수 있습니다.
자주 발생하는 실수
- Profiler 없이 useMemo부터 추가
- 렌더 횟수만 보고 성능 문제라고 판단
- 서로 다른 데이터 크기로 전후 비교
- 개발 모드 측정값을 실제 사용자 성능으로 단정
- 작은 컴포넌트까지 무조건 memo 처리
학습 완료 체크리스트
- React DevTools Profiler로 기록할 수 있습니다.
- mount와 update를 구분할 수 있습니다.
- actualDuration의 의미를 설명할 수 있습니다.
- 최적화 전에 측정 근거를 남길 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
작업 완료·검색·목록 변경을 Profiler로 기록하고 다시 렌더된 컴포넌트와 비용을 비교합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 31단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: shadcn ui 복합 컴포넌트 실습 Dialog Popover Carousel 접근성의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/pages/TaskListPage.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
독자 질문과 직접 확인
질문: 작업 한 개의 완료 버튼을 눌렀을 때 어떤 컴포넌트가 다시 렌더링되는지 어떻게 찾나요?
컴포넌트 실습: step-31-profiler에서 React DevTools Profiler 녹화를 시작하고 작업 완료를 한 번 누른 뒤 중단합니다. 같은 조건으로 검색·목록 변경도 기록하고 commit과 컴포넌트별 변화를 비교합니다.
예상 화면·상태: 측정표에는 조작·변경된 컴포넌트·관찰한 비용을 직접 채웁니다. 본문 예시 숫자를 이 프로젝트의 실측 결과로 간주하지 않습니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.
오류 점검: 숫자가 흔들리면 개발 환경·데이터량·동일 동작 여부를 맞추고 여러 번 관찰한 뒤 원인을 해석합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 32회에서 측정 근거를 바탕으로 Compiler와 수동 메모 최적화를 비교합니다.
완료 코드 ZIP 다운로드 — step-31-profiler
이전 체크포인트
step-30-shadcn-complex
이번 목표
코드를 최적화하지 않고 현재 상태를 측정합니다.
측정표 예시
실제 숫자는 환경마다 다르므로 본문에서 임의 값을 성능 기준으로 단정하지 않습니다.
| 동작 | 확인 항목 |
|---|---|
| 검색어 입력 | TaskList 재렌더 여부 |
| 작업 완료 | 변경된 작업 외 목록 영향 |
| Dialog 열기 | 목록 전체 렌더 여부 |
| 페이지 이동 | mount/update 범위 |
측정 결과와 스크린샷은 실제 브라우저에서 확인한 뒤 BlogFlow 검수 기록에 남깁니다.
완료 체크포인트:
step-31-profiler
전체 코드 뷰어의 모든 파일은 다운로드 ZIP과 같습니다. ZIP은 독립 실행할 수 있는 완료 시점의 코드입니다. Node.js 22.12 이상에서 npm install 후 npm run dev로 실행하고, npm run build로 검사합니다.
전체 파일·코드 보기
components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
실습 프로젝트 전체 코드
components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
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-31-profiler",
"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",
"react-router": "7.18.4",
"@radix-ui/react-dialog": "1.1.23",
"@radix-ui/react-popover": "1.1.23",
"@radix-ui/react-slot": "1.3.3",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "1.47.0",
"sonner": "2.0.8",
"tailwind-merge": "3.7.0",
"next-themes": "0.4.6",
"embla-carousel-react": "8.6.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",
"@tailwindcss/vite": "4.3.3",
"tailwindcss": "4.3.3",
"tw-animate-css": "1.4.0"
}
}
src/App.tsx
import {
Navigate,
Route,
Routes,
} from 'react-router';
import AppLayout from './components/AppLayout';
import SettingsPage from './pages/SettingsPage';
import TaskDetailPage from './pages/TaskDetailPage';
import TaskListPage from './pages/TaskListPage';
export default function App() {
return (
<Routes>
<Route element={<AppLayout />}>
<Route
index
element={<Navigate to="/tasks" replace />}
/>
<Route
path="/tasks"
element={<TaskListPage />}
/>
<Route
path="/tasks/:taskId"
element={<TaskDetailPage />}
/>
<Route
path="/settings"
element={<SettingsPage />}
/>
</Route>
</Routes>
);
}
src/actions/saveProfile.ts
export type SaveProfileState = {
message: string;
};
export async function saveProfile(
_previousState: SaveProfileState,
formData: FormData,
): Promise<SaveProfileState> {
const displayName = String(
formData.get('displayName') ?? '',
).trim();
await new Promise((resolve) =>
setTimeout(resolve, 500),
);
if (!displayName) {
return {
message: '이름을 입력해 주세요.',
};
}
return {
message: '프로필을 저장했습니다.',
};
}
src/actions/updateTask.ts
export async function updateTaskOnServer(
taskId: string,
): Promise<void> {
await new Promise((resolve) =>
setTimeout(resolve, 600),
);
if (!taskId || taskId === 'fail') {
throw new Error('작업 ID가 없습니다.');
}
}
src/components/AppLayout.tsx
import {
NavLink,
Outlet,
} from 'react-router';
import { Toaster } from '@/components/ui/sonner';
import Header from './Header';
export default function AppLayout() {
return (
<main className="app">
<Header />
<nav aria-label="주요 메뉴">
<NavLink to="/tasks">작업</NavLink>
{' · '}
<NavLink to="/settings">설정</NavLink>
</nav>
<Outlet />
<Toaster />
</main>
);
}
src/components/AsyncDemo.tsx
import { useState } from 'react';
import OptimisticTaskList from './OptimisticTaskList';
import type { Task } from '../types/task';
export default function AsyncDemo() {
const [tasks, setTasks] = useState<Task[]>([{id:'success',title:'저장 성공 실습',completed:false},{id:'fail',title:'저장 실패·복구 실습',completed:false}]);
return <details><summary>낙관적 UI 실습</summary><p>실제 서버가 아닌 지연 모의 요청입니다. 아래 목록은 기본 작업 목록과 분리됩니다.</p>
<OptimisticTaskList tasks={tasks} onCommitToggle={id => setTasks(previous => previous.map(task => task.id === id ? {...task,completed:!task.completed} : task))} /></details>;
}
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/OptimisticTaskList.tsx
import {
useTransition,
useOptimistic,
useState,
} from 'react';
import { updateTaskOnServer } from '../actions/updateTask';
import type { Task } from '../types/task';
type OptimisticTaskListProps = {
tasks: Task[];
onCommitToggle: (taskId: string) => void;
};
export default function OptimisticTaskList({
tasks,
onCommitToggle,
}: OptimisticTaskListProps) {
const [isPending, startTransition] = useTransition();
const [errorMessage, setErrorMessage] =
useState('');
const [optimisticTasks, toggleOptimistic] =
useOptimistic(
tasks,
(currentTasks, taskId: string) =>
currentTasks.map((task) =>
task.id === taskId
? {
...task,
completed: !task.completed,
}
: task,
),
);
const handleToggle = (taskId: string) => {
setErrorMessage('');
startTransition(async () => {
toggleOptimistic(taskId);
try {
await updateTaskOnServer(taskId);
startTransition(() => onCommitToggle(taskId));
} catch {
setErrorMessage(
'저장에 실패했습니다. 기존 상태로 돌아갑니다.',
);
}
});
};
return (
<section>
{errorMessage ? (
<p role="alert">{errorMessage}</p>
) : null}
<ul>
{optimisticTasks.map((task) => (
<li key={task.id}>
<button
type="button"
disabled={isPending}
onClick={() => handleToggle(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<span>{task.title}</span>
</li>
))}
</ul>
</section>
);
}
src/components/ProfileSaveForm.tsx
import { useActionState } from 'react';
import {
saveProfile,
type SaveProfileState,
} from '../actions/saveProfile';
const initialState: SaveProfileState = {
message: '',
};
export default function ProfileSaveForm() {
const [state, formAction, isPending] =
useActionState(saveProfile, initialState);
return (
<form action={formAction}>
<label>
표시 이름
<input name="displayName" />
</label>
<button disabled={isPending}>
{isPending ? '저장 중...' : '저장'}
</button>
<p>{state.message}</p>
</form>
);
}
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 { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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');
toast.success('작업이 추가되었습니다.');
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-2"
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자 이상 입력해 주세요.',
},
})}
/>
{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 { Link } from 'react-router';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useTaskContext } from '@/context/TaskContext';
import type { Task } from '@/types/task';
type TaskItemProps = {
task: Task;
};
export default function TaskItem({
task,
}: TaskItemProps) {
const { dispatch } = useTaskContext();
const handleDelete = () => {
dispatch({
type: 'task/deleted',
id: task.id,
});
};
return (
<TaskCard>
<Button
type="button"
variant="outline"
onClick={() =>
dispatch({
type: 'task/toggled',
id: task.id,
})
}
>
{task.completed ? '완료 취소' : '완료'}
</Button>
<span>{task.title}</span>
<Link to={`/tasks/${task.id}`}>
상세
</Link>
<Popover>
<PopoverTrigger asChild>
<Button type="button" variant="outline">
정보
</Button>
</PopoverTrigger>
<PopoverContent>
현재 상태: {task.completed ? '완료' : '진행 중'}
</PopoverContent>
</Popover>
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="destructive">
삭제
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
이 작업을 삭제할까요?
</DialogTitle>
<DialogDescription>
삭제한 작업은 현재 예제에서 복구할 수 없습니다.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
>
삭제 확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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/components/ui/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
src/components/ui/carousel.tsx
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
src/components/ui/dialog.tsx
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
src/components/ui/input.tsx
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
src/components/ui/popover.tsx
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
src/components/ui/sonner.tsx
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }
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 {
useDeferredValue,
useState,
useTransition,
type Dispatch,
type SetStateAction,
} from 'react';
import type {
Task,
TaskStatusFilter,
} from '../types/task';
export function useTaskFilter(tasks: Task[]) {
const [keyword, setKeyword] = useState('');
const [status, setStatusState] =
useState<TaskStatusFilter>('all');
const [isPending, startTransition] =
useTransition();
const deferredKeyword =
useDeferredValue(keyword);
const normalizedKeyword = deferredKeyword
.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 setStatus: Dispatch<
SetStateAction<TaskStatusFilter>
> = (nextStatus) => {
startTransition(() => {
setStatusState(nextStatus);
});
};
const resetFilter = () => {
setKeyword('');
startTransition(() => {
setStatusState('all');
});
};
return {
keyword,
setKeyword,
status,
setStatus,
visibleTasks,
resetFilter,
isStale: keyword !== deferredKeyword,
isPending,
};
}
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
@import "tailwindcss";
@import "tw-animate-css";
@theme inline {
--color-background: var(--background); --color-foreground: var(--foreground);
--color-card: var(--background); --color-card-foreground: var(--foreground);
--color-popover: var(--background); --color-popover-foreground: var(--foreground);
--color-primary: #171717; --color-primary-foreground: #fff;
--color-secondary: #eee; --color-secondary-foreground: #171717;
--color-muted: #eee; --color-muted-foreground: #555;
--color-accent: #eee; --color-accent-foreground: #171717;
--color-destructive: #b91c1c; --color-destructive-foreground: #fff;
--color-border: #aaa; --color-input: #aaa; --color-ring: #555;
--radius-sm: 0px; --radius-md: 0px; --radius-lg: 0px;
}
:root { --background: #fff; --foreground: #111; }
* {
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; }
.rounded-lg,.rounded-md,.rounded-sm { border-radius: 0; } .shadow-lg,.shadow-md,.shadow-sm { box-shadow:none; }
src/lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import './index.css';
import App from './App';
import { TaskProvider } from './context/TaskContext';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TaskProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</TaskProvider>
</StrictMode>,
);
src/pages/SettingsPage.tsx
import AsyncDemo from '../components/AsyncDemo';
import ProfileSaveForm from '../components/ProfileSaveForm';
export default function SettingsPage() {
return (
<section>
<h2>설정</h2>
<p>TaskFlow 설정 화면입니다.</p>
<ProfileSaveForm /><AsyncDemo />
</section>
);
}
src/pages/TaskDetailPage.tsx
import {
Link,
useParams,
} from 'react-router';
import { useTaskContext } from '../context/TaskContext';
export default function TaskDetailPage() {
const { taskId } = useParams();
const { tasks } = useTaskContext();
const task = tasks.find(
(item) => item.id === taskId,
);
if (!task) {
return (
<section>
<h2>작업을 찾을 수 없습니다.</h2>
<Link to="/tasks">
목록으로 돌아가기
</Link>
</section>
);
}
return (
<section>
<h2>{task.title}</h2>
<p>
상태: {task.completed ? '완료' : '진행 중'}
</p>
<Link to="/tasks">
목록으로 돌아가기
</Link>
</section>
);
}
src/pages/TaskListPage.tsx
import { useEffect, lazy, Suspense, Profiler, type ProfilerOnRenderCallback } 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 StatsSkeleton from '../components/StatsSkeleton';
const TaskStats = lazy(() => import('../components/TaskStats'));
const handleRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration) => console.table({id,phase,actualDuration,baseDuration});
export default function TaskListPage() {
const { tasks } = useTaskContext();
const { totalCount, completedCount, remainingCount } = useTaskStats();
const { keyword, setKeyword, status, setStatus, visibleTasks, resetFilter, isStale, isPending } = useTaskFilter(tasks);
useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
return <section>
<h2>작업</h2>
<NetworkStatus />
<TaskSummary total={totalCount} completed={completedCount} remaining={remainingCount} />
<TaskForm />
<TaskFilter keyword={keyword} onKeywordChange={setKeyword} status={status} onStatusChange={setStatus} onReset={resetFilter} />
<p role="status">{isStale ? "검색 결과 갱신 중..." : isPending ? "필터 갱신 중..." : ""}</p>
<Profiler id="TaskList" onRender={handleRender}><TaskList tasks={visibleTasks} /></Profiler>
<Suspense fallback={<StatsSkeleton />}><TaskStats tasks={tasks} /></Suspense>
<StateResetDemo />
</section>;
}
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';
import tailwindcss from '@tailwindcss/vite';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } });
글 소개·학습 준비·전체 목차

React · 31/32 · TaskFlow 누적 실습
React DevTools Profiler 사용법 리렌더링 측정 원인 찾기
React · 31 / 32 · 고급 · 성능 측정
리렌더링 원인을 찾으려면 React DevTools Profiler에서 기록을 시작하고 사용자 동작 하나를 수행한 뒤 해당 commit의 컴포넌트 변화를 살펴봅니다. 같은 데이터와 동작을 반복해 렌더 횟수와 소요 시간을 비교한 다음 최적화가 필요한지 판단합니다.
DevTools의 Profiler 탭은 기록을 화면에서 살펴보는 도구이고, <Profiler> 컴포넌트는 onRender 콜백으로 측정값을 받는 방법입니다.
이번 글에서 배우는 내용
- 최적화 전에 측정해야 하는 이유
- React DevTools Profiler
- mount와 update
- actualDuration과 baseDuration
- <Profiler> 컴포넌트
- 측정 조건을 동일하게 유지하는 방법
목차
다음 학습
다음 글에서는 Profiler 결과를 바탕으로 React Compiler와 수동 memoization을 비교합니다. 측정 근거가 없는 useMemo/useCallback은 추가하지 않습니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.