Next.js에서 MSW 2로 브라우저용 Mock API를 어떻게 준비하나요?
MSW 2는 브라우저에서 보낸 요청을 가로채 학습용 응답을 만들어 줍니다. worker.start()가 끝난 뒤 fetch를 실행해야 Mock 응답과 준비 실패를 구분할 수 있습니다.
적용 조건·범위: 브라우저 Service Worker가 가로채는 요청은 브라우저에서 보낸 fetch입니다. Server Component의 서버 fetch가 같은 Mock을 사용한다고 가정하지 않습니다. handler URL이 맞지 않은 요청은 이 예제의 bypass 설정에 따라 실제 서버로 넘어가므로 Mock 성공과 실제 HTTP 실패를 나누어 봅니다.
본문 근거: MSW 브라우저 통합 안내 · MSW
독립 실습: worker 준비 후에만 Mock 요청 보내기
기대 결과: worker 초기화 뒤 Mock 성공을 표시합니다. Network에 서비스 워커가 응답한 요청을 확인합니다. production에서는 개발 모드 전용입니다.
직접 실행: 환경 준비와 전체 파일
React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.
npm install -D msw, npx msw init public/ --save 후 npm run dev를 실행합니다. localhost /practice에서 버튼을 누릅니다. /api/practice-mock 실제 Route Handler는 만들지 않습니다.
app/practice/worker.ts 전체 코드
import { http, HttpResponse } from 'msw';
import { setupWorker } from 'msw/browser';
export const worker = setupWorker(
http.get('/api/practice-mock', () => HttpResponse.json({ message: 'Mock 성공' })),
);
let startPromise: ReturnType<typeof worker.start> | undefined;
export function startMockWorker() {
if (!startPromise) {
startPromise = worker.start({ onUnhandledRequest: "bypass" }).catch((error) => {
startPromise = undefined;
throw error;
});
}
return startPromise;
}
app/practice/page.tsx 전체 코드
"use client";
import { useState } from 'react';
export default function Page() {
const [message, setMessage] = useState('대기');
const [pending, setPending] = useState(false);
async function load() {
if (process.env.NODE_ENV !== 'development') { setMessage('개발 모드 전용'); return; }
setPending(true);
try {
const { startMockWorker } = await import('./worker');
await startMockWorker();
const response = await fetch('/api/practice-mock');
if (!response.ok) throw new Error('응답 오류');
const data: { message: string } = await response.json();
setMessage(data.message);
} catch { setMessage('worker 또는 요청 실패'); }
finally { setPending(false); }
}
return <main><button disabled={pending} onClick={load}>Mock 조회</button><p>{message}</p></main>;
}
자가 확인: handler 경로를 요청과 다르게 바꿨을 때 Mock과 실제 404를 구분하고 복구합니다.
설치
npm install -D msw
npx msw init public/ --save
handler 작성
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/tasks', () => {
return HttpResponse.json([
{
id: 'task-1',
title: 'MSW 공부하기',
completed: false,
},
]);
}),
];
browser worker
초기화 Promise를 모듈에서 공유해 React StrictMode나 재마운트에서도 worker를 중복 시작하지 않습니다. 초기화가 실패하면 저장한 Promise를 비워 다시 시도할 수 있습니다.
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
let startPromise: ReturnType<typeof worker.start> | undefined;
export function startMockWorker() {
if (!startPromise) {
startPromise = worker.start({ onUnhandledRequest: "bypass" }).catch((error) => {
startPromise = undefined;
throw error;
});
}
return startPromise;
}
개발 환경에서만 실행
Mock이 운영 환경에서 활성화되지 않도록 환경을 분리합니다.
NEXT_PUBLIC_API_MOCKING=enabled
자주 발생하는 오류
- public/mockServiceWorker.js 생성 누락
- handler URL과 실제 요청 URL 불일치
- 운영 환경에서 MSW 활성화
- 서버 요청과 브라우저 요청 경계를 구분하지 않음
브라우저 Service Worker가 가로채는 요청은 브라우저에서 보낸 fetch입니다. Server Component의 서버 fetch가 같은 Mock을 사용한다고 가정하지 않습니다. handler URL이 맞지 않은 요청은 이 예제의 bypass 설정에 따라 실제 서버로 넘어가므로 Mock 성공과 실제 HTTP 실패를 나누어 봅니다.
원리 확인: MSW 브라우저 통합 안내 (2026-10-12). 아래는 기대 결과이며 이 편집에서 실제 실행은 확인하지 않았습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.
MSW를 개발 모드에서만 시작하고 worker.start가 끝난 뒤 API 실습 UI를 렌더링합니다. 기본 서버 목록과 MSW의 브라우저 Mock 저장소는 별개입니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow Next.js 프로젝트 적용
시작 체크포인트: 이전: Next.js Server Component 데이터 조회와 Streaming의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: .env.example, app/(app)/tasks/page.tsx, app/ui/api-demo.tsx, app/ui/msw-provider.tsx, package.json, src/mocks/browser.ts, src/mocks/handlers.ts. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
독자가 직접 확인할 질문과 결과
질문: 백엔드 없이 /tasks에서 API 요청의 결과를 어떻게 확인할 수 있나요?
npm install후npx msw init public/ --save로 worker를 준비하고.env.local에NEXT_PUBLIC_API_MOCKING=enabled를 설정한 뒤 개발 서버를 다시 시작합니다./tasks의 API 실습 영역을 엽니다.- ‘목록 API 조회’는 초기 Mock 기준
GET /api/tasks의200: 1개 작업, ‘API 작업 추가’는POST /api/tasks의201: API 실습 작업을 상태 문구에 표시합니다. 다시 목록을 조회하면 Mock 목록 개수가 늘어납니다. 네트워크 탭에서도 경로·상태·JSON을 대조하세요. - TaskFlow 서버 작업 목록은 별도 저장소입니다. Mock 추가가 그 목록에 자동 반영되지는 않습니다. 현재 handler는 GET/POST와 짧은 제목의 400 응답만 정의하며, 화면 버튼은 정상 제목만 전송합니다. 400은 개발자 도구에서 직접 짧은 제목을 보내야 확인할 수 있습니다. DELETE/500은 이 체크포인트에 구현되지 않았습니다.
오류 점검: .env.local 값, 서버 재시작, public/mockServiceWorker.js, 요청 경로를 확인합니다. 초기화 실패 메시지와 운영 모드의 Mock 비활성화를 구분하세요. 빌드·서버·브라우저 실행 검증은 미확인입니다.
완료 코드 ZIP 다운로드 — next-step-15-msw
이전 체크포인트: next-step-14-server-data
이번 회차의 변경
MSW를 개발 모드에서만 시작하고 worker.start가 끝난 뒤 API 실습 UI를 렌더링합니다. 기본 서버 목록과 MSW의 브라우저 Mock 저장소는 별개입니다. .env.local에서 NEXT_PUBLIC_API_MOCKING을 enabled로 설정하고 개발 서버를 재시작해야 Mock 요청이 동작합니다. 운영 build에서는 MSW가 켜지지 않습니다.
변경·추가 파일:
app/(app)/tasks/page.tsxsrc/mocks/handlers.tssrc/mocks/browser.tsapp/ui/msw-provider.tsxapp/ui/api-demo.tsx.env.example
전체 파일·코드 보기
실행과 검증
Node.js 22 LTS 이상 권장(Next.js 최소 20.9). 루트 app/ 구조, CSS 사용.
npm install
npm run dev
npm run build
npm run start
MSW: 개발 시에만 NEXT_PUBLIC_API_MOCKING을 enabled로 설정합니다. npx msw init public/ --save로 worker를 만들 수 있습니다. Mock 데이터와 서버 데이터는 별개이며 17편부터 기본 흐름은 실제 Route Handler입니다.
전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.
완료 체크포인트: next-step-15-msw
실습 프로젝트 전체 코드
.gitignore
node_modules
.next
.env*
!.env.example
*.tsbuildinfo
.env.example
NEXT_PUBLIC_API_MOCKING=
app/(app)/@modal/(.)tasks/[id]/page.tsx
import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
import Modal from "@/app/ui/modal";
export default async function TaskModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const task = await getTask(id);
if (!task) notFound();
return (
<Modal>
<h1>{task.title}</h1>
<p>{task.description}</p>
<p>상태: {task.completed ? "완료" : "진행 중"}</p>
</Modal>
);
}
app/(app)/@modal/[…catchAll]/page.tsx
export default function ResetModal() {
return null;
}
app/(app)/@modal/default.tsx
export default function Default() {
return null;
}
app/(app)/layout.tsx
import type { ReactNode } from "react";
import SiteHeader from "@/app/_components/SiteHeader";
export default async function AppLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
return (
<>
<SiteHeader />
{children}
{modal}
</>
);
}
app/(app)/settings/page.tsx
import ThemeSetting from "@/app/ui/theme-setting";
export default function Settings() {
return (
<main>
<h1>설정</h1>
<ThemeSetting />
</main>
);
}
app/(app)/tasks/[id]/page.tsx
import { getTask } from "@/app/lib/data";
import { notFound } from "next/navigation";
import Link from "next/link";
export default async function TaskDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const task = await getTask(id);
if (!task) notFound();
return (
<main>
<h1>{task.title}</h1>
<p>{task.description}</p>
<p>상태: {task.completed ? "완료" : "진행 중"}</p>
<Link href="/tasks">목록</Link>
</main>
);
}
app/(app)/tasks/error.tsx
"use client";
export default function ErrorPage({
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<main>
<h1>작업을 불러오지 못했습니다.</h1>
<button onClick={reset}>다시 시도</button>
</main>
);
}
app/(app)/tasks/loading.tsx
export default function Loading() {
return (
<main>
<p>작업을 불러오는 중...</p>
</main>
);
}
app/(app)/tasks/not-found.tsx
export default function NotFound() {
return (
<main>
<h1>작업을 찾을 수 없습니다.</h1>
</main>
);
}
app/(app)/tasks/page.tsx
import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
import { Suspense } from "react";
import TaskStats from "@/app/ui/task-stats";
import ApiDemo from "@/app/ui/api-demo";
import MswProvider from "@/app/ui/msw-provider";
export default async function TasksPage() {
const tasks = await getTasks();
return (
<main>
<h1>작업 목록</h1>
<TaskSearch />
<ul>
{tasks.map((task) => (
<li key={task.id}>
<Link href={"/tasks/" + task.id}>{task.title}</Link>
<span> · {task.completed ? "완료" : "진행 중"}</span>
</li>
))}
</ul>
<Suspense fallback={<p>통계 로딩 중...</p>}>
<TaskStats />
</Suspense>
<MswProvider>
<ApiDemo />
</MswProvider>
</main>
);
}
app/(app)/template.tsx
import type { ReactNode } from "react";
export default function Template({ children }: { children: ReactNode }) {
return <>{children}</>;
}
app/(public)/page.tsx
import Link from "next/link";
export default function HomePage() {
return (
<main>
<h1>TaskFlow Next.js</h1>
<p>App Router 기반 작업 관리 서비스입니다.</p>
<Link href="/tasks">작업 목록 보기</Link>
</main>
);
}
app/_components/SiteHeader.tsx
import Link from "next/link";
export default function SiteHeader() {
return (
<header>
<nav aria-label="주 메뉴">
<Link href="/">홈</Link>
<Link href="/tasks">작업</Link>
<Link href="/settings">설정</Link>
</nav>
</header>
);
}
app/globals.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #171717;
background: #fafafa;
font-family: Arial, sans-serif;
line-height: 1.6;
}
header,
main {
width: min(880px, calc(100% - 32px));
margin: 24px auto;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
a {
color: inherit;
text-underline-offset: 3px;
}
button,
input,
select {
min-height: 44px;
padding: 8px 12px;
font: inherit;
border: 1px solid #aaa;
background: #fff;
color: #171717;
max-width: 100%;
}
button {
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
input {
display: block;
width: 100%;
}
label {
display: block;
}
form,
section,
aside {
margin: 20px 0;
}
ul {
padding: 0;
list-style: none;
}
li {
padding: 12px;
margin: 12px 0;
border: 1px solid #ccc;
overflow-wrap: anywhere;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.controls form {
margin: 0;
}
.modal {
width: min(640px, calc(100% - 32px));
border: 1px solid #777;
padding: 24px;
background: #fff;
color: #171717;
}
.modal::backdrop {
background: rgb(0 0 0 / 55%);
}
:focus-visible {
outline: 3px solid #555;
outline-offset: 3px;
}
[data-theme="dark"] {
background: #202020;
color: #fafafa;
padding: 16px;
}
[data-density="compact"] li {
padding: 8px;
margin: 8px 0;
}
img {
max-width: 100%;
height: auto;
}
app/layout.tsx
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
app/lib/data.ts
import type { Task } from "./types";
const tasks: Task[] = [
{
id: "task-1",
title: "App Router 공부",
description: "서버와 브라우저의 역할을 구분합니다.",
completed: false,
ownerId: "demo",
},
{
id: "task-2",
title: "TaskFlow 만들기",
description: "누적 실습을 완성합니다.",
completed: true,
ownerId: "demo",
},
];
export async function getTasks() {
return tasks;
}
export async function getTask(id: string) {
return tasks.find((t) => t.id === id);
}
export async function getTaskStats() {
const ts = await getTasks();
return { total: ts.length, completed: ts.filter((t) => t.completed).length };
}
app/lib/types.ts
export type Task = {
id: string;
title: string;
description: string;
completed: boolean;
ownerId: string;
};
app/ui/api-demo.tsx
"use client";
import { useState } from "react";
export default function ApiDemo() {
const [message, setMessage] = useState("API 응답 확인 전");
const [busy, setBusy] = useState(false);
async function request(method: "GET" | "POST") {
setBusy(true);
try {
const response = await fetch("/api/tasks", {
method,
headers: { "Content-Type": "application/json" },
...(method === "POST"
? { body: JSON.stringify({ title: "API 실습 작업" }) }
: {}),
});
const data = await response.json();
setMessage(
response.ok
? `${response.status}: ${Array.isArray(data) ? data.length + "개 작업" : data.title}`
: `${response.status}: ${data.message}`,
);
} catch {
setMessage("API 요청 실패");
} finally {
setBusy(false);
}
}
return (
<section>
<h2>API 실습</h2>
<p>
Mock 저장소와 서버 저장소는 별개입니다. 17편부터 실제 Route Handler를
사용합니다.
</p>
<div className="controls">
<button disabled={busy} onClick={() => request("GET")}>
목록 API 조회
</button>
<button disabled={busy} onClick={() => request("POST")}>
API 작업 추가
</button>
</div>
<p role="status">{message}</p>
</section>
);
}
app/ui/modal.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { useRouter } from "next/navigation";
export default function Modal({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDialogElement>(null);
const router = useRouter();
useEffect(() => {
const dialog = ref.current;
if (dialog && !dialog.open) dialog.showModal();
return () => {
dialog?.close();
};
}, []);
return (
<dialog
className="modal"
ref={ref}
aria-label="작업 상세"
onCancel={(event) => {
event.preventDefault();
router.back();
}}
>
<button type="button" onClick={() => router.back()}>
닫기
</button>
{children}
</dialog>
);
}
app/ui/msw-provider.tsx
"use client";
import { useState, useEffect, type ReactNode } from "react";
export default function MswProvider({ children }: { children: ReactNode }) {
const enabled =
process.env.NODE_ENV === "development" &&
process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
const [ready, setReady] = useState(!enabled);
const [error, setError] = useState("");
useEffect(() => {
if (!enabled) return;
let active = true;
import("@/src/mocks/browser")
.then(({ startMockWorker }) => startMockWorker())
.then(() => {
if (active) setReady(true);
})
.catch(() => {
if (active)
setError("Mock API 초기화에 실패했습니다. worker 파일을 확인하세요.");
});
return () => {
active = false;
};
}, [enabled]);
return error ? (
<p role="alert">{error}</p>
) : ready ? (
<>{children}</>
) : (
<p>Mock API 준비 중...</p>
);
}
app/ui/task-search.tsx
"use client";
import { useState } from "react";
export default function TaskSearch() {
const [keyword, setKeyword] = useState("");
return (
<label>
작업 검색
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="검색어 입력"
/>
</label>
);
}
app/ui/task-stats.tsx
import { getTaskStats } from "@/app/lib/data";
export default async function TaskStats() {
await new Promise((resolve) => setTimeout(resolve, 600));
const s = await getTaskStats();
return (
<aside aria-label="작업 통계">
<p>
전체: {s.total} · 완료: {s.completed}
</p>
</aside>
);
}
app/ui/theme-setting.tsx
"use client";
import { useState, useEffect } from "react";
export default function ThemeSetting() {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
try {
const saved = localStorage.getItem("taskflow-theme");
if (saved === "light" || saved === "dark") setTheme(saved);
} catch {}
}, []);
function change(value: "light" | "dark") {
setTheme(value);
try {
localStorage.setItem("taskflow-theme", value);
} catch {}
}
return (
<section data-theme={theme}>
<p>현재 테마: {theme}</p>
<div className="controls">
<button onClick={() => change("light")}>밝게</button>
<button onClick={() => change("dark")}>어둡게</button>
</div>
</section>
);
}
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: false };
export default config;
package.json
{
"name": "taskflow-next",
"version": "1.0.15",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "24.13.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"msw": "2.15.0"
}
}
src/mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
let startPromise: ReturnType<typeof worker.start> | undefined;
export function startMockWorker() {
if (!startPromise) {
startPromise = worker.start({ onUnhandledRequest: "bypass" }).catch((error) => {
startPromise = undefined;
throw error;
});
}
return startPromise;
}
src/mocks/handlers.ts
import { http, HttpResponse } from "msw";
let tasks = [
{
id: "task-1",
title: "Mock API 연결",
completed: false,
},
];
export const handlers = [
http.get("/api/tasks", () => {
return HttpResponse.json(tasks);
}),
http.post("/api/tasks", async ({ request }) => {
const body = (await request.json()) as {
title?: string;
};
if (typeof body.title !== "string" || body.title.trim().length < 2) {
return HttpResponse.json(
{
message: "제목을 입력해 주세요.",
},
{
status: 400,
},
);
}
const newTask = {
id: crypto.randomUUID(),
title: body.title.trim(),
completed: false,
};
tasks = [newTask, ...tasks];
return HttpResponse.json(newTask, {
status: 201,
});
}),
];
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
글 소개·학습 준비·전체 목차

Next.js · 15/38 · TaskFlow 누적 실습
Next.js MSW 2 Mock API 만들기
Next.js · 15 / 38 · 필수 실무 · MSW 2
백엔드 없이 실제 fetch 흐름으로 API UI를 시험하려면 어떻게 할까요? MSW 2 handler가 브라우저 요청을 가로채 응답을 만들게 하고 worker.start()가 끝난 뒤 요청합니다. 아래 한 버튼 예제로 Mock 성공과 worker·요청 실패를 구분한 뒤, 하단에서 GET·POST·400 응답을 TaskFlow에 연결합니다.
이번 글에서 배우는 내용
- MSW 설치
- Service Worker 초기화
- handler
- GET/POST Mock API
- 성공·400 응답과 미구현 오류 범위 구분
- 환경변수로 mock 활성화
목차
다음 학습
다음 글에서는 브라우저에서 외부 API를 요청해야 하는 경우 Axios를 처음 설치하고 서버 fetch와 역할을 구분합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 39개 · 필수 38개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.