MSW 스타일 Mock Handler 패턴
API mock을 구성할 때, 모든 핸들러를 한 파일에 몰아넣으면 금방 관리가 어려워진다. MSW(Mock Service Worker)는 이 문제를 핸들러를 도메인별로 분리하고 중앙에서 등록하는 구조로 해결한다. 이 패턴은 MSW 고유의 것이 아니라 axios-mock-adapter 같은 다른 mock 라이브러리에도 동일하게 적용할 수 있다. 핵심은 "핸들러를 어떤 라이브러리로 작성하느냐"가 아니라 "핸들러를 어떻게 조직하느냐"에 있다.
문제: 모놀리식 Mock
mock 코드가 작을 때는 하나의 파일에 모든 핸들러를 넣어도 문제가 없다.
import MockAdapter from "axios-mock-adapter";
import { api } from "./instance";
const mock = new MockAdapter(api);
// 피드 관련
mock.onGet("/feed").reply(200, { data: [] });
mock.onGet(/\/feed\/\d+/).reply(200, { data: {} });
// 사용자 관련
mock.onGet("/users/me").reply(200, { name: "Alice" });
mock.onPost("/auth/login").reply(200, { token: "abc" });
// 댓글 관련
mock.onGet(/\/comments/).reply(200, { data: [] });
mock.onPost(/\/comments/).reply(201, { id: 1 });
// 알림 관련...
// 검색 관련...
// 계속 늘어남...
엔드포인트가 10개를 넘어가면 파일이 수백 줄이 되고, 특정 도메인의 핸들러를 찾거나 수정하는 데 시간이 걸리기 시작한다. 피드 관련 mock을 고치려는데 댓글 핸들러를 건드리는 실수가 생기기도 한다.
해결: 핸들러 분리 패턴
MSW에서 흔히 사용하는 구조를 그대로 가져온다. mock 인스턴스를 하나의 파일에서 생성하고, 도메인별 핸들러 파일이 이 인스턴스를 import해서 자기 담당 엔드포인트만 등록하는 방식이다.
디렉토리 구조
api/
├── instance.ts # axios 인스턴스
└── mocks/
├── mockSetup.ts # MockAdapter 인스턴스 생성
├── index.ts # 핸들러 등록 진입점
├── data/
│ └── posts.ts # mock 데이터 생성
└── handlers/
└── posts.ts # 피드 관련 핸들러
MSW에서는 handlers/ 디렉토리에 posts.ts, auth.ts, comments.ts 같은 파일을 두고, index.ts에서 모든 핸들러를 합쳐서 setupWorker(...allHandlers)에 전달한다. 동일한 구조를 axios-mock-adapter에 적용한 것이다.
mock 인스턴스 분리
api/
├── instance.ts # axios 인스턴스
└── mocks/
├── mockSetup.ts # MockAdapter 인스턴스 생성
├── index.ts # 핸들러 등록 진입점
├── data/
│ └── posts.ts # mock 데이터 생성
└── handlers/
└── posts.ts # 피드 관련 핸들러
mock 인스턴스를 별도 파일로 분리하는 이유는 순환 참조를 방지하기 위해서다. 핸들러 파일들이 mock 인스턴스를 import하고, 등록 진입점이 핸들러 파일들을 import하는 구조에서, mock 인스턴스 생성과 핸들러 등록이 같은 파일에 있으면 import 순서에 따라 문제가 생길 수 있다.
delayResponse: 800은 실제 네트워크 지연을 시뮬레이션한다. 로딩 상태 UI가 제대로 보이는지 확인할 때 유용하다.
도메인별 핸들러
// mocks/mockSetup.ts
import MockAdapter from "axios-mock-adapter";
import { api } from "@/api/instance";
export const mock = new MockAdapter(api, { delayResponse: 800 });
핸들러가 단순히 정적 데이터를 반환하는 것이 아니라 실제 API의 로직을 시뮬레이션하고 있다. lastId 기반 커서 페이지네이션, 데이터 슬라이싱, hasMore 플래그 계산까지 구현했다. 이렇게 하면 프론트엔드의 무한 스크롤이나 "더 보기" 기능을 백엔드 없이 완전히 테스트할 수 있다.
핸들러를 함수(setupPostHandlers)로 감싸는 것도 포인트다. 모듈이 import되는 시점이 아니라 함수가 호출되는 시점에 핸들러가 등록되므로, 등록 타이밍을 제어할 수 있다.
중앙 등록
// mocks/handlers/posts.ts
import { TOTAL_POSTS, PAGE_SIZE_POSTS } from "@/api/mocks/data/posts";
import { mock } from "@/api/mocks/mockSetup";
export const setupPostHandlers = () => {
mock.onGet("/feed").reply((config) => {
const limit = Number(config.params?.limit) || PAGE_SIZE_POSTS;
const lastId = Number(config.params?.lastId) || 0;
const startIndex = lastId
? TOTAL_POSTS.findIndex((post) => Number(post.id) === lastId) + 1
: 0;
const posts = TOTAL_POSTS.slice(startIndex, startIndex + limit);
const newLastId = posts.length > 0 ? Number(posts[posts.length - 1].id) : null;
const hasMore = startIndex + limit < TOTAL_POSTS.length;
return [
200,
{
message: "피드 조회 완료",
data: { result: posts, lastId: newLastId, hasMore },
},
];
});
};
새로운 도메인이 추가되면 핸들러 파일을 만들고 여기에 한 줄 추가하면 된다. 기존 핸들러를 건드릴 필요가 없다.
Mock 데이터 팩토리
핸들러와 함께 mock 데이터도 분리한다. 팩토리 함수를 만들어서 일관된 형태의 테스트 데이터를 동적으로 생성한다.
// mocks/index.ts
import { setupPostHandlers } from "@/api/mocks/handlers/posts";
export const setupMocks = () => {
setupPostHandlers();
// setupAuthHandlers();
// setupCommentHandlers();
};
이 패턴의 장점:
- 타입 안전성:
Post타입을 import해서 사용하므로 실제 API 응답과 구조가 일치하는지 컴파일 타임에 확인된다 - 대량 데이터:
Array.from으로 100개의 포스트를 한 번에 생성해서 페이지네이션 테스트가 가능하다 - 결정적 데이터: id 기반으로 데이터를 생성하므로 매번 같은 결과가 나온다. 날짜는
Date.now()기준이라 실행 시점에 따라 달라지지만, 상대적 순서는 유지된다
조건부 활성화
mock은 개발 환경에서만 활성화되어야 한다. 프로덕션 빌드에 mock 코드가 포함되면 번들 크기가 늘어나고, 실수로 활성화되면 실제 API 대신 mock 데이터가 표시되는 사고가 생긴다.
// mocks/data/posts.ts
import { Post } from "@/types/post";
const PAGE_SIZE = 12;
export const generateMockPost = (id: number): Post => ({
id: id,
createdAt: new Date(Date.now() - id * 86400000).toISOString(),
title: `블로그 포스트 #${id}`,
viewCount: 0,
path: "/",
author: `작성자 ${(id % 5) + 1}`,
thumbnail: `https://picsum.photos/640/480?random=${id}`,
blogPlatform: "etc",
summary: "",
tag: [],
});
export const TOTAL_POSTS = Array.from({ length: 100 }, (_, i) =>
generateMockPost(i + 1)
);
export const PAGE_SIZE_POSTS = PAGE_SIZE;
동적 import()를 사용하면 조건이 false일 때 mock 관련 코드가 아예 로드되지 않는다. 번들러가 코드 스플리팅을 적용해서 mock 코드는 별도 청크로 분리되고, 프로덕션에서는 해당 청크가 요청되지 않는다.
환경 변수는 .env.development에 설정한다.
// main.ts
if (import.meta.env.VITE_USE_MOCK === "true") {
const { setupMocks } = await import("./api/mocks");
setupMocks();
}
MSW와의 구조 비교
MSW를 사용하면 핸들러 구조가 이렇게 된다.
# .env.development
VITE_USE_MOCK=true
구조적으로 거의 동일하다. 차이는 핸들러 정의 문법(mock.onGet vs http.get)과 등록 방식(setupMocks() vs setupWorker())뿐이다. 핵심 아이디어인 "도메인별 핸들러 분리 → 중앙 등록"은 완전히 같다.
이 패턴을 이해하고 있으면 axios-mock-adapter에서 MSW로 마이그레이션할 때도 디렉토리 구조와 데이터 팩토리는 그대로 유지하고, 핸들러 문법만 바꾸면 된다.
핸들러 확장 패턴
프로젝트가 커지면 단일 도메인 안에서도 핸들러가 많아진다. 이때 CRUD 단위로 더 세분화하거나, 동적으로 핸들러를 추가하는 패턴이 필요하다.
// MSW 방식
import { http, HttpResponse } from "msw";
export const postHandlers = [
http.get("/feed", ({ request }) => {
const url = new URL(request.url);
const limit = Number(url.searchParams.get("limit")) || 12;
// ...페이지네이션 로직
return HttpResponse.json({ data: { result: posts, hasMore } });
}),
];
// 등록
import { setupWorker } from "msw/browser";
import { postHandlers } from "./handlers/posts";
const worker = setupWorker(...postHandlers);
worker.start();
이 패턴은 사용자 인터랙션에 따라 필요한 mock을 그때그때 등록하는 방식이다. 예를 들어 목록에서 특정 항목을 클릭했을 때, 해당 항목의 상세 조회 API에 대한 mock을 동적으로 등록한다. 모든 가능한 id에 대해 미리 핸들러를 등록하는 것보다 효율적이다.
다만 정규식 핸들러로 대체할 수도 있다.
// 런타임에 특정 리소스에 대한 핸들러를 동적으로 추가
export const mockDetailPost = (feedId: number) => {
mock.onGet(`/feed/${feedId}`).reply(() => {
return [200, generateDetailResponse(feedId)];
});
};
정규식 방식이 더 간결하지만, 특정 id에 대해서만 다른 응답을 돌려야 하는 경우(예: id 999는 404 반환)에는 동적 등록이 더 유연하다.
정리
MSW 스타일 mock handler 패턴의 핵심은 세 가지다.
- mock 인스턴스를 분리해서 순환 참조를 방지한다
- 도메인별 핸들러 파일로 관심사를 분리한다
- 중앙 진입점에서 한 번에 등록한다
이 구조를 따르면 mock 코드가 실제 API 구조를 반영하게 되어, 코드를 읽는 것만으로 "이 프로젝트에 어떤 API가 있는지" 파악할 수 있다. 또한 새로운 API가 추가될 때 기존 핸들러를 건드리지 않고 파일만 추가하면 되므로 충돌 없이 병렬 개발이 가능하다.