
MSW로 API 없이 개발하기: 백엔드 기다리다 지쳐서 만든 목 서버
백엔드 API가 준비되지 않아 프론트엔드 개발이 멈추는 문제를 MSW(Mock Service Worker)로 해결했다. 네트워크 레벨 API 모킹의 원리와 실전 패턴.

백엔드 API가 준비되지 않아 프론트엔드 개발이 멈추는 문제를 MSW(Mock Service Worker)로 해결했다. 네트워크 레벨 API 모킹의 원리와 실전 패턴.
클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

코드를 먼저 짜고 테스트하는 게 아닙니다. 테스트를 먼저 짜고, 그걸 통과하기 위해 코딩하는 것. 순서를 뒤집으면 버그가 사라집니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

스프린트 2주차였다. 나는 대시보드 화면을 만들어야 했다. 디자인은 나왔고, API 명세서도 있었다. 근데 API가 없었다. 백엔드 팀이 1주일 더 필요하다고 했다.
방법은 두 가지였다. 기다리거나, 뭔가를 만들거나.
기다리는 건 선택지가 아니었다. 그래서 대충 이렇게 했다.
// 임시방편... 제발 이거 누가 못 보게 해줘
const MOCK_USER = {
id: "1",
name: "테스트 유저",
email: "test@example.com",
};
const MOCK_POSTS = [
{ id: "1", title: "첫 번째 포스트", status: "published" },
{ id: "2", title: "두 번째 포스트", status: "draft" },
];
export async function getUser() {
// TODO: 실제 API 연결 전까지만 씁니다
return MOCK_USER;
}
처음엔 이게 그냥 임시 해결책인 줄 알았다. 근데 이게 파일이 하나가 되고, 두 개가 되고, 어느 순간 30개를 넘어가 있었다. 실제 API가 나왔을 때 이걸 전부 교체해야 했다. 각 컴포넌트마다 흩어진 MOCK_* 상수들을 뒤지는 데 API 연결 시간보다 정리 시간이 더 걸렸다.
그때 처음 MSW를 알게 됐다.
MSW(Mock Service Worker)는 네트워크 레벨에서 API 요청을 가로채는 도구다.
내가 처음에 이해한 방식은 이렇다. 코드를 하드코딩 목 데이터로 바꾸는 게 아니라, 실제 fetch("/api/user") 요청이 네트워크로 나가기 직전에 가로채서 가짜 응답을 돌려주는 것이다. 앱 코드는 실제 API를 쓰는지 목 서버를 쓰는지 모른다. 그냥 응답이 오는 것처럼 동작한다.
비유하자면 MSW는 영화 세트장이다.
영화에서 배우가 커피숍 장면을 찍는다. 실제 커피숍에 가서 찍는 게 아니다. 스튜디오에 커피숍처럼 꾸며놓은 세트를 만들고, 거기서 찍는다. 배우 입장에선 커피를 주문하고, 카운터가 있고, 바리스타가 있다. 세트인지 진짜인지 구분할 필요가 없다. 카메라에 찍히는 장면(화면에 보이는 것)이 자연스럽게 나오면 그만이다.
MSW가 딱 이 역할이다. 앱 코드(배우)는 fetch("/api/user")를 호출한다(커피를 주문한다). MSW(세트 담당)가 그 요청을 받아서 가짜 응답(소품 커피)을 돌려준다. 앱 코드는 실제 백엔드가 없다는 걸 모른다.
MSW 전에 흔히 쓰는 방법들이 있다.
| 방법 | 문제점 |
|---|---|
| 하드코딩 목 데이터 | 코드에 직접 박혀서 제거하기 어렵다. 흩어진다. |
| json-server | 별도 프로세스 실행 필요. 실제 앱 코드와 분리되어 있다. |
| axios interceptor 수동 작성 | fetch API를 쓰면 못 쓴다. 관리 포인트 늘어난다. |
개발용 if 분기 | 프로덕션 코드에 개발 코드가 섞인다. |
MSW가 이 모든 문제를 해결하는 건 네트워크 레벨에서 동작하기 때문이다. 앱 코드를 건드리지 않는다.
브라우저에서 MSW가 동작하는 원리가 처음엔 잘 안 와닿았다. 근데 서비스 워커(Service Worker)가 뭔지 알고 나서 이해됐다.
서비스 워커는 브라우저와 네트워크 사이에서 동작하는 스크립트다. 원래 목적은 오프라인 캐싱이다. PWA에서 인터넷이 없을 때도 앱이 동작하게 하려고, 서비스 워커가 네트워크 요청을 가로채서 캐시된 응답을 돌려준다.
MSW는 이 메커니즘을 목 API에 활용한다.
[앱 코드]
↓ fetch("/api/user") 요청
[서비스 워커 (MSW)]
↓ "이 요청 내가 처리할게"
↓ 핸들러에서 가짜 응답 생성
↑ { id: "1", name: "테스트 유저" } 반환
[앱 코드]
↑ 실제 응답인 줄 알고 그냥 씀
비유하자면 MSW는 대역 배우(stunt double)다. 위험하거나 아직 준비 안 된 장면은 스턴트맨이 대신한다. 주인공(앱 코드)은 같이 찍는 상대방이 진짜 배우인지 스턴트맨인지 신경 쓸 필요가 없다. 장면이 제대로 찍히면 된다.
테스트 환경에서는 서비스 워커 대신 Node.js 인터셉터를 사용한다. 구조는 같다. 핸들러를 정의하면 MSW가 알아서 브라우저냐 Node냐에 따라 맞는 방식으로 요청을 가로챈다.
설치부터 정리해본다.
npm install msw --save-dev
npx msw init public/ --save
두 번째 명령이 public/mockServiceWorker.js 파일을 생성한다. 이게 브라우저에서 실행되는 서비스 워커 스크립트다. 이 파일은 MSW가 관리하니 직접 편집하지 않는다.
핸들러는 MSW의 핵심이다. "어떤 요청이 오면 어떤 응답을 돌려줄지"를 정의한다.
// src/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
// GET /api/user
http.get("/api/user", () => {
return HttpResponse.json({
id: "1",
name: "김개발",
email: "dev@example.com",
role: "admin",
});
}),
// GET /api/posts - 쿼리 파라미터 처리
http.get("/api/posts", ({ request }) => {
const url = new URL(request.url);
const status = url.searchParams.get("status");
const allPosts = [
{ id: "1", title: "첫 번째 포스트", status: "published" },
{ id: "2", title: "두 번째 포스트", status: "draft" },
{ id: "3", title: "세 번째 포스트", status: "published" },
];
const filtered = status
? allPosts.filter((p) => p.status === status)
: allPosts;
return HttpResponse.json(filtered);
}),
// POST /api/posts - 요청 바디 처리
http.post("/api/posts", async ({ request }) => {
const body = await request.json() as { title: string; content: string };
return HttpResponse.json(
{
id: crypto.randomUUID(),
title: body.title,
content: body.content,
status: "draft",
createdAt: new Date().toISOString(),
},
{ status: 201 }
);
}),
// DELETE /api/posts/:id - URL 파라미터 처리
http.delete("/api/posts/:id", ({ params }) => {
const { id } = params;
console.log(`[MSW] Deleted post: ${id}`);
return new HttpResponse(null, { status: 204 });
}),
];
// src/mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
// src/main.tsx (또는 앱 진입점)
async function enableMocking() {
if (process.env.NODE_ENV !== "development") {
return;
}
const { worker } = await import("./mocks/browser");
return worker.start({
onUnhandledRequest: "bypass", // 핸들러 없는 요청은 실제 네트워크로 보냄
});
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
});
process.env.NODE_ENV !== "development" 조건 때문에 프로덕션 빌드에서는 MSW 코드가 실행되지 않는다. 앱 코드를 더럽히지 않는다.
// src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// src/setupTests.ts (Vitest/Jest 설정)
import { server } from "./mocks/server";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // 테스트 간 핸들러 오염 방지
afterAll(() => server.close());
이제 테스트 코드에서 fetch를 그냥 쓰면 MSW가 가로챈다.
// src/components/UserProfile.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { UserProfile } from "./UserProfile";
test("유저 정보를 렌더링한다", async () => {
render(<UserProfile />);
await waitFor(() => {
expect(screen.getByText("김개발")).toBeInTheDocument();
});
});
fetch를 모킹하는 코드가 테스트 파일에 단 한 줄도 없다. UserProfile 컴포넌트가 fetch("/api/user")를 호출하면, MSW가 알아서 핸들러에서 정의한 응답을 돌려준다.
실제 프로젝트에 넣으면서 몇 가지 패턴이 쓸모 있었다. 정리해본다.
API 에러 처리 UI를 만들 때 가장 유용했다. 실제 서버를 에러 상태로 만드는 건 불가능하지만, MSW는 한 줄이면 된다.
import { http, HttpResponse } from "msw";
// 서버 에러 시뮬레이션
http.get("/api/user", () => {
return HttpResponse.json(
{ message: "Internal Server Error" },
{ status: 500 }
);
});
// 네트워크 오류 시뮬레이션 (서버에 아예 못 닿는 상황)
http.get("/api/user", () => {
return HttpResponse.error();
});
// 401 인증 오류
http.get("/api/user", () => {
return HttpResponse.json(
{ message: "Unauthorized" },
{ status: 401 }
);
});
에러 처리 로직을 개발하면서 실제로 에러가 발생하는 상황을 만들 수 있다는 게 생각보다 큰 차이를 만들었다.
로딩 스피너, 스켈레톤 UI 같은 걸 개발할 때 응답이 너무 빨리 오면 테스트가 어렵다. delay로 해결된다.
import { http, HttpResponse, delay } from "msw";
http.get("/api/posts", async () => {
await delay(1500); // 1.5초 지연
return HttpResponse.json([
{ id: "1", title: "로딩 후 나타나는 포스트" },
]);
});
// delay("real")을 쓰면 실제 네트워크 지연을 흉내냄
http.get("/api/user", async () => {
await delay("real");
return HttpResponse.json({ id: "1", name: "김개발" });
});
기본 핸들러는 성공 응답을 돌려주고, 특정 테스트에서만 에러 응답이 필요할 때가 있다. server.use()로 테스트 안에서 핸들러를 재정의한다.
import { server } from "../mocks/server";
import { http, HttpResponse } from "msw";
test("API 에러 시 에러 메시지를 보여준다", async () => {
// 이 테스트에서만 에러 응답
server.use(
http.get("/api/user", () => {
return HttpResponse.json(
{ message: "Server Error" },
{ status: 500 }
);
})
);
render(<UserProfile />);
await waitFor(() => {
expect(screen.getByText("오류가 발생했습니다.")).toBeInTheDocument();
});
// afterEach에서 server.resetHandlers()가 원래 핸들러로 복구
});
afterEach(() => server.resetHandlers())를 설정해뒀으면 테스트 이후 자동으로 원래 핸들러로 돌아온다. 테스트 간 오염이 없다.
내가 MSW 전에 썼던 json-server와 비교하면 이렇다.
| 비교 항목 | json-server | MSW |
|---|---|---|
| 실행 방식 | 별도 서버 프로세스 | 앱 안에서 동작 |
| 테스트 통합 | 별도 설정 필요 | setupServer()로 바로 통합 |
| 요청 로직 | 파일 기반 (db.json) | 코드 기반 (핸들러 함수) |
| 동적 응답 | 제한적 | 자유롭게 로직 작성 가능 |
| 에러 시뮬레이션 | 어렵다 | HttpResponse.error() 한 줄 |
| 타입 지원 | 없음 | TypeScript 완전 지원 |
json-server는 간단한 CRUD 프로토타이핑엔 빠르지만, 조건부 응답이나 에러 시뮬레이션이 필요해지면 바로 한계가 온다. MSW는 핸들러가 그냥 함수라 뭐든 할 수 있다.
MSW는 네트워크 레벨에서 동작한다. 앱 코드를 전혀 건드리지 않는다. fetch가 실제 API를 쓰든 MSW를 쓰든 앱 코드는 차이를 모른다.
브라우저에서는 서비스 워커, Node에서는 인터셉터를 사용한다. 핸들러 코드는 동일하다. 브라우저 개발 환경과 테스트 환경에서 같은 핸들러를 공유한다.
핸들러는 그냥 함수다. 조건부 응답, 에러 시뮬레이션, 지연, 요청 바디 파싱 같은 걸 코드로 자유롭게 구현할 수 있다. json-server의 db.json과는 차원이 다른 유연성이다.
테스트에서 server.use()로 핸들러를 재정의할 수 있다. 성공 케이스는 기본 핸들러로, 에러 케이스는 테스트 안에서 오버라이드. resetHandlers()로 테스트 간 오염을 막는다.
프로덕션 코드에 목 코드가 섞이지 않는다. process.env.NODE_ENV !== "development" 조건 덕분에 배포 빌드에서는 MSW가 실행되지 않는다.
백엔드 기다리다 지쳐서 만든 임시방편 MOCK_* 상수들이 코드베이스 전체에 퍼지기 시작했을 때, MSW를 알았더라면 좋았을 것이다. 핸들러 파일 하나에 목 응답을 전부 모으고, 실제 API가 나왔을 때 그 파일만 끄면 끝이다. 그게 핵심이다.