MSW로 API 모킹하기: 핸들러 정의, 브라우저·Node 통합, 에러 시뮬레이션, 테스트와 GraphQL
이 글의 핵심
MSW가 네트워크 레벨에서 요청을 가로채는 방식, 핸들러 작성법, 개발 서버와 테스트 환경 연동, 에러·지연 시뮬레이션, GraphQL 모킹까지 예제로 다룹니다.
이 글의 핵심
MSW로 API Mocking을 구현하는 글입니다. Request Handlers, Response Resolver, Browser/Node 통합, Testing까지 예제로 정리하고, 각 단계에서 실제로 자주 걸리는 함정도 함께 적었습니다.
실무에서 마주치는 문제들
백엔드 개발이 늦어요
API 스펙은 합의됐는데 서버 구현이 아직이면 프론트엔드는 하드코딩한 더미 데이터로 버티게 됩니다. 문제는 이 더미 데이터가 컴포넌트 안에 박혀 있으면 나중에 실제 API로 바꿀 때 코드를 다시 고쳐야 한다는 점입니다. MSW는 컴포넌트가 진짜 fetch('/api/users')를 호출하게 두고, 그 요청을 네트워크 층에서 받아 응답만 바꿔 줍니다. 백엔드가 완성되면 핸들러를 끄기만 하면 됩니다.
테스트에서 실제 API를 호출해요
테스트가 스테이징 서버에 의존하면 서버 배포 중이거나 데이터가 바뀌었을 때 코드와 무관하게 테스트가 깨집니다. MSW로 응답을 고정하면 테스트 결과가 네트워크 상태가 아니라 코드에만 좌우됩니다.
에러 상황 테스트가 어려워요
500 응답, 타임아웃, 네트워크 단절은 실서버로 재현하기가 어렵습니다. 핸들러 하나로 원하는 상태 코드와 지연을 돌려줄 수 있어서, 로딩 스피너·에러 배너·재시도 로직을 의도적으로 검증할 수 있습니다.
MSW란?
API Mocking의 진화: Stub → Interceptor → Service Worker
전통적인 API Mocking 방식은 두 가지였습니다:
-
라이브러리 Stub (axios-mock-adapter, nock)
- axios·fetch를 직접 가로채서 Mock 응답 반환
- 문제: 실제 네트워크 요청이 발생하지 않음 → 개발자 도구 Network 탭에 안 보임
-
Mock Server (json-server, mirage)
- 별도 포트(
:3001)에서 Mock API 서버 실행 - 문제: 프로덕션 코드(
/api/users)와 다른 URL(localhost:3001/users) 사용
- 별도 포트(
MSW(Mock Service Worker, 2019)는 Service Worker API로 이 문제를 해결했습니다:
- 네트워크 레벨에서 intercept → 실제 HTTP 요청이 발생
- 개발자 도구에 보임 → Network 탭에서 200 OK 확인 가능
- 프로덕션 URL 그대로 →
/api/users그대로 사용
Service Worker API: 브라우저의 네트워크 프록시
Service Worker는 원래 PWA(Progressive Web App)를 위해 만들어진 W3C 표준입니다. 브라우저와 서버 사이에서 프록시 역할을 합니다.
브라우저 (fetch('/api/users'))
↓
Service Worker (MSW가 여기서 intercept)
↓ (Mock이면 여기서 응답 반환)
↓ (Mock 없으면 실제 네트워크로 통과)
실제 서버
MSW의 동작:
npx msw init public/→mockServiceWorker.js생성- 브라우저가 이 Worker 등록
- 모든
fetch()요청이 Worker를 거침 - MSW가 핸들러에 매칭되는지 확인
- 매칭되면 Mock 응답, 아니면 실제 네트워크로 통과
MSW vs 기존 Mocking 방식
| 측면 | MSW | axios-mock-adapter | json-server |
|---|---|---|---|
| 동작 위치 | Service Worker (네트워크) | 라이브러리 내부 | 별도 서버 |
| Network 탭 | ✅ 보임 | ❌ 안 보임 | ✅ 보임 |
| URL | 프로덕션과 동일 | 프로덕션과 동일 | 다른 포트 |
| 브라우저 | ✅ | ✅ | ✅ |
| Node(테스트) | ✅ | ✅ | ❌ (별도 실행 필요) |
| 코드 수정 | 불필요 | 불필요 | URL 변경 필요 |
핵심 특징
MSW (Mock Service Worker)는 Service Worker 기반 API Mocking 라이브러리입니다. 주요 장점:
- Service Worker: 네트워크 레벨 Mocking (실제 HTTP 요청 발생)
- 브라우저 + Node: 개발(Service Worker) + 테스트(Node.js Interceptor). Node에는 Service Worker가 없으므로
setupServer는http/https모듈과 전역fetch를 패치하는 인터셉터(@mswjs/interceptors)로 동작합니다. 이름은 “server”지만 실제 포트를 여는 서버는 아닙니다. - 타입 안전: TypeScript 지원 (Request·Response 타입)
- 실제와 동일: 개발자 도구 Network 탭에서 확인 가능
- Zero Config: 프로덕션 코드 수정 불필요
사용 시나리오:
- 백엔드 API 개발 전 프론트엔드 개발
- E2E 테스트에서 외부 API 의존성 제거
- 에러·타임아웃·로딩 상태 시뮬레이션
- 다양한 응답 케이스 빠르게 테스트
설치 및 설정
설치
npm install -D msw
이 글의 코드는 MSW 2.x API(http, HttpResponse) 기준입니다. 1.x의 rest.get((req, res, ctx) => res(ctx.json(...))) 형태 예제가 아직 검색 결과에 많이 남아 있는데, 2.x에서는 rest가 http로, res(ctx...) 조합이 표준 Response를 확장한 HttpResponse로 바뀌었습니다. 섞어 쓰면 rest is not exported 류의 import 에러가 나므로, 예제를 가져올 때 버전을 먼저 확인하는 것이 좋습니다. 2.x는 Node 18 이상을 요구합니다.
Service Worker 생성
npx msw init public/ --save
public/은 개발 서버가 루트(/)에서 그대로 서빙하는 정적 폴더여야 합니다. Vite·CRA는 public/, Next.js도 public/이지만, 프로젝트마다 다를 수 있습니다. 경로가 틀리면 브라우저 콘솔에 Failed to register a ServiceWorker ... A bad HTTP response code (404) was received when fetching the script 에러가 뜹니다. --save는 package.json의 msw.workerDirectory에 경로를 기록해 두어, MSW를 업그레이드할 때 워커 스크립트가 자동으로 갱신되게 합니다. 워커 스크립트와 라이브러리 버전이 어긋나면 콘솔에 버전 불일치 경고가 뜨므로 이 옵션을 켜 두는 편이 안전합니다.
Handlers 정의
핸들러는 “어떤 요청을(메서드 + 경로)” “어떻게 응답할지(resolver)“의 쌍입니다. 경로에는 :id 같은 파라미터와 * 와일드카드를 쓸 수 있고, 상대 경로(/api/users)는 현재 페이지의 origin 기준으로 매칭됩니다. 백엔드가 다른 도메인(https://api.example.com)이라면 핸들러에도 절대 URL을 적어야 매칭됩니다.
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'John', email: '[email protected]' },
{ id: 2, name: 'Jane', email: '[email protected]' },
]);
}),
http.post('/api/users', async ({ request }) => {
const newUser = await request.json();
return HttpResponse.json(
{ id: 3, ...newUser },
{ status: 201 }
);
}),
http.delete('/api/users/:id', ({ params }) => {
return HttpResponse.json(
{ success: true },
{ status: 200 }
);
}),
];
request.json()은 표준 Fetch API의 메서드라 본문을 한 번만 읽을 수 있고, TypeScript에서는 반환 타입이 unknown에 가깝게 잡힙니다. ...newUser로 펼칠 때 타입 에러가 나면 http.post<never, { name: string; email: string }>(...)처럼 제네릭으로 요청 본문 타입을 지정하면 됩니다. 핸들러 배열은 위에서부터 순서대로 검사되고, 첫 번째로 응답을 반환한 핸들러가 이깁니다. 그래서 /api/users/me 같은 구체적인 경로는 /api/users/:id보다 위에 두어야 합니다.
브라우저 통합
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
// src/main.tsx
import { worker } from './mocks/browser';
if (process.env.NODE_ENV === 'development') {
worker.start();
}
위 코드는 가장 짧은 형태지만, 실제로 써 보면 첫 화면에서 모킹이 안 되는 경우가 생깁니다. worker.start()는 Promise를 반환하는데, 이를 기다리지 않고 바로 앱을 렌더링하면 워커 등록이 끝나기 전에 첫 fetch가 나가서 실서버(또는 404)로 새어 나갑니다. 처음 도입할 때 가장 흔히 겪는 증상이 “새로고침하면 되는데 첫 로드만 실패한다”는 것이고, 원인은 거의 항상 이 타이밍입니다. 다음처럼 await 후에 렌더링하는 형태를 권합니다.
async function enableMocking() {
if (!import.meta.env.DEV) return; // Vite 기준. webpack 계열은 process.env.NODE_ENV
const { worker } = await import('./mocks/browser');
return worker.start({ onUnhandledRequest: 'bypass' });
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
});
동적 import()를 쓰면 프로덕션 빌드에서 MSW 코드가 번들에 들어가지 않습니다. onUnhandledRequest는 핸들러가 없는 요청을 어떻게 처리할지 정하는 옵션으로, 기본값 'warn'은 폰트·이미지 요청까지 콘솔 경고를 쏟아내므로 개발 서버에서는 'bypass', 테스트에서는 누락된 핸들러를 잡기 위해 'error'로 두는 조합이 실용적입니다.
Node 통합 (테스트)
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/setupTests.ts
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
세 줄의 생명주기 훅은 각각 이유가 있습니다. listen()은 인터셉터를 설치하고, resetHandlers()는 테스트 도중 server.use()로 추가한 일회성 핸들러를 지워 다음 테스트로 새지 않게 하며, close()는 패치를 되돌려 다른 테스트 파일에 영향을 주지 않게 합니다. resetHandlers()를 빠뜨리면 에러 케이스 테스트 뒤에 오는 성공 케이스가 실패하는, 실행 순서에 따라 결과가 달라지는 테스트가 됩니다.
Jest + jsdom 환경에서 MSW 2.x를 쓰면 ReferenceError: TextEncoder is not defined나 Response is not defined 같은 에러를 만나는 경우가 많습니다. jsdom이 Node의 Fetch API 전역을 노출하지 않기 때문입니다. MSW 문서가 권하는 해결책은 jest-fixed-jsdom 환경을 쓰거나, 가능하면 Node 전역을 그대로 쓰는 Vitest로 옮기는 것입니다. server.listen({ onUnhandledRequest: 'error' })로 두면 핸들러를 빠뜨린 요청이 조용히 실서버로 나가는 대신 테스트가 즉시 실패해서 원인을 찾기 쉽습니다.
Response Resolver
동적 응답
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
if (id === '1') {
return HttpResponse.json({ id: 1, name: 'John' });
}
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
});
지연
import { delay } from 'msw';
http.get('/api/users', async () => {
await delay(2000);
return HttpResponse.json([]);
});
params 값은 항상 문자열이라서 id === 1처럼 숫자와 비교하면 언제나 거짓이 됩니다. 위 예제가 '1'로 비교하는 이유입니다. delay()는 인자 없이 호출하면 실제 서버처럼 보이는 짧은 무작위 지연을, 숫자를 넘기면 그만큼, 'infinite'를 넘기면 끝나지 않는 요청을 만듭니다. 마지막 옵션은 타임아웃·취소(AbortController) 로직을 검증할 때 유용합니다. 다만 Node 테스트에서 긴 지연을 넣으면 테스트 러너의 기본 타임아웃(Jest 5초)에 먼저 걸리니 테스트용 지연은 짧게 잡는 편이 좋습니다.
에러 시뮬레이션
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
});
http.get('/api/users', () => {
return HttpResponse.error();
});
두 핸들러는 서로 다른 실패를 흉내 냅니다. 첫 번째는 서버가 응답은 했지만 상태 코드가 500인 경우라서 fetch는 정상 resolve되고 response.ok가 false가 됩니다. 두 번째 HttpResponse.error()는 연결 자체가 실패한 상황으로, fetch가 TypeError: Failed to fetch(Node에서는 TypeError: fetch failed)로 reject됩니다. fetch는 4xx/5xx에서 예외를 던지지 않기 때문에 두 경우의 에러 처리 경로가 다르고, 둘 다 테스트해야 누락이 없습니다. axios는 반대로 4xx/5xx에서도 reject하므로 사용하는 HTTP 클라이언트에 맞춰 기대값을 잡아야 합니다.
같은 경로에 핸들러를 두 개 나란히 두면 앞의 것이 항상 이기므로, 실제로는 위처럼 한 배열에 넣지 말고 테스트마다 server.use()로 필요한 쪽만 덮어씁니다.
테스트에서 사용
// src/components/UserList.test.tsx
import { render, screen } from '@testing-library/react';
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import UserList from './UserList';
test('loads and displays users', async () => {
render(<UserList />);
expect(await screen.findByText('John')).toBeInTheDocument();
expect(screen.getByText('Jane')).toBeInTheDocument();
});
test('handles error', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Failed to fetch' },
{ status: 500 }
);
})
);
render(<UserList />);
expect(await screen.findByText('Failed to fetch')).toBeInTheDocument();
});
첫 테스트는 공용 핸들러(John, Jane)를 그대로 쓰고, 두 번째는 server.use()로 같은 경로를 500 응답으로 덮어씁니다. server.use()로 넣은 핸들러는 기존 목록 앞에 붙기 때문에 우선 적용되고, afterEach의 resetHandlers()에서 제거됩니다. 이 구조 덕분에 공용 핸들러는 “정상 경로”만 담당하고, 예외 시나리오는 테스트 파일 안에 가까이 둘 수 있습니다.
findByText를 쓰는 이유도 중요합니다. 모킹된 응답이라도 fetch는 비동기로 처리되므로 getByText로 즉시 찾으면 렌더링 전이라 실패합니다. 반대로 findByText가 1초 기본 타임아웃 후 실패한다면, 응답을 기다리는 문제가 아니라 핸들러 경로가 요청 URL과 안 맞는 경우가 많습니다. 이때 server.events.on('request:start', ({ request }) => console.log(request.url))로 실제로 나간 URL을 찍어 보면 원인이 바로 보입니다.
GraphQL Mocking
import { graphql, HttpResponse } from 'msw';
const handlers = [
graphql.query('GetUser', ({ variables }) => {
return HttpResponse.json({
data: {
user: {
id: variables.id,
name: 'John',
},
},
});
}),
graphql.mutation('CreateUser', async ({ variables }) => {
return HttpResponse.json({
data: {
createUser: {
id: 3,
...variables.input,
},
},
});
}),
];
GraphQL은 모든 요청이 보통 POST /graphql 하나로 가기 때문에 URL만으로는 구분할 수 없습니다. graphql.query('GetUser', ...)는 요청 본문의 쿼리 문서를 파싱해 operation name으로 매칭합니다. 그래서 클라이언트 쪽 쿼리가 query { user(id: 1) { ... } }처럼 이름 없는 익명 쿼리면 이 핸들러에 걸리지 않습니다. Apollo·urql 등을 쓸 때 쿼리에 이름을 붙이는 습관이 필요한 이유 중 하나입니다. 엔드포인트가 여러 개라면 graphql.link('https://api.example.com/graphql')로 링크별 핸들러를 따로 만들 수 있습니다. 응답은 GraphQL 규격대로 data(필요하면 errors 배열)를 담아야 클라이언트 캐시가 정상 동작합니다.
정리 및 체크리스트
핵심 요약
- MSW: Service Worker 기반 API Mocking
- 브라우저 + Node: 개발 + 테스트
- 타입 안전: TypeScript 지원
- 실제와 동일: 실제 HTTP 요청
- 에러 시뮬레이션: 쉬운 에러 테스트
- GraphQL: GraphQL Mocking
구현 체크리스트
- MSW 설치
- Handlers 정의
- 브라우저 통합
- Node 통합
- Response Resolver 구현
- 에러 시뮬레이션
- 테스트 작성
- GraphQL Mocking
같이 보면 좋은 글
- Jest 실전 테스트 가이드 | 플레이키 테스트·Mock·Snapshot·Coverage 트러블슈팅
- Cypress E2E 테스트 운영: 안정적인 셀렉터, 로그인 세션 재사용, 아키텍처와 커버리지
- Testing Library로 사용자 관점 테스트
자주 묻는 질문 (FAQ)
Q. 프로덕션 번들에 포함되나요?
A. 설정하기 나름입니다. 위의 enableMocking()처럼 개발 환경에서만 동적 import()로 불러오면 프로덕션 번들에서 빠집니다. msw는 devDependency로 두고, public/mockServiceWorker.js는 배포 산출물에 남아도 등록하지 않으면 동작하지 않습니다.
Q. 실제 API 호출을 가로채나요?
A. 브라우저에서는 Service Worker가 페이지의 fetch/XHR 요청을 가로채고, Node에서는 http·fetch 모듈을 패치해 가로챕니다. 핸들러가 없는 요청은 그대로 실제 네트워크로 나갑니다.
Q. GraphQL도 지원하나요?
A. 네, graphql.query/graphql.mutation으로 operation name 기준으로 매칭합니다. 익명 쿼리는 매칭되지 않으니 쿼리에 이름을 붙여야 합니다.
Q. Storybook에서도 쓸 수 있나요?
A. msw-storybook-addon을 쓰면 스토리마다 parameters.msw.handlers로 다른 응답을 지정할 수 있습니다. 같은 핸들러 파일을 개발 서버·테스트·스토리북이 공유하는 것이 MSW를 쓰는 큰 이점입니다.