# MSW로 네트워크 모킹 — API를 격리하기

> 테스트에서 진짜 API를 부르면 느리고 불안정합니다. MSW(Mock Service Worker)로 네트워크 계층을 격리해 안정적으로 테스트하는 법을 다룹니다.

**Published:** 2026-07-14 | **Updated:** 2026-07-14

---



> 「프론트엔드 테스트 제대로 하기」 시리즈의 **9편**입니다. [전체 목차 보기](/series/프론트엔드-테스트-제대로-하기/) · [용어집](/posts/frontend-testing-glossary/)

컴포넌트가 API를 부르기 시작하면 테스트가 흔들립니다. 서버가 느리면 같이 느려지고, 데이터가 바뀌면 깨지고, 오프라인이면 전멸이죠. **MSW(Mock Service Worker)** 는 네트워크 요청을 경계에서 가로채 준비된 응답을 돌려주는 도구로, 이 문제를 뿌리에서 해결합니다. 이 글 하나로 설치·핸들러·성공과 실패 시뮬레이션까지 정리해요.

시리즈로 따라오셨다면 — [비동기 편](/posts/frontend-testing-async/)부터 "일단 복사해서" 쓰던 `src/mocks/` 폴더의 정체를 오늘 밝힙니다. 검색으로 처음 오셨어도 괜찮아요. 예제는 자급자족하고, Vitest 기준이지만 Jest 환경에서도 MSW 사용법은 동일합니다.

> 실습 코드: 이 편의 상태가 [`step-09` 태그](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-09)에 담겨 있습니다(코드는 지난 편과 동일 — 이 편은 이미 있던 `src/mocks/`의 원리를 밝히는 편이에요). [StackBlitz에서 바로 열기](https://stackblitz.com/github/IsaacEryn/frontend-testing-lab/tree/step-09)도 됩니다.

## 오늘 다루는 것

- MSW 핸들러·서버 셋업
- 성공·실패 응답 시뮬레이션
- fetch를 직접 모킹하지 않는 이유

{{< img src="images/contents/msw-intercept.png" alt="컴포넌트의 API 요청을 MSW가 네트워크 경계에서 가로채 가짜 응답을 돌려주는 구조 다이어그램" >}}

MSW를 한마디로 하면 **아파트 경비실**입니다. 컴포넌트가 보낸 요청(택배 기사)이 현관을 나서기 직전, 경비실이 가로채서 미리 맡아둔 상자(준비된 응답)를 대신 건네줘요. 중요한 건 — **컴포넌트는 이 사실을 전혀 모릅니다.** 진짜 서버와 통신한 줄로만 알아요. fetch 코드도, 에러 처리도, JSON 파싱도 전부 진짜처럼 돌아갑니다. 가짜인 건 딱 하나, 회선 너머뿐이에요.

"어? [테스트 더블 편](/posts/frontend-testing-test-doubles/)에서 배운 `vi.fn()`으로 `fetchUsers`를 통째로 바꾸면 되지 않나?" 싶으실 수 있어요. 좋은 감각인데, 결정적 차이가 있습니다 — 그 방법은 `fetchUsers` **안의 코드가 아예 실행되지 않아요.** URL이 틀려도, 에러 처리가 빠져도 모릅니다. MSW는 함수를 끝까지 진짜로 돌리고 **네트워크만** 가짜로 바꿔요. 어느 쪽을 얼마나 가짜로 할지의 기준은 잠시 뒤에 다시 정리합니다.

---

## 핸들러와 서버

먼저 버전부터: 이 글은 **MSW v2** 기준입니다(`npm i -D msw@latest`). v1과는 import 경로와 API가 달라서, 예전 자료를 섞어 보면 헤매기 쉬워요.

요청을 가로채는 규칙(핸들러)을 정의하고, 테스트용 Node 서버로 띄웁니다. (repo `src/mocks/`)

```ts
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const users = [
  { id: '1', name: 'Alice Kim', email: 'alice@example.com' },
  { id: '2', name: 'Bob Lee', email: 'bob@example.com' },
  { id: '3', name: 'Carol Park', email: 'carol@example.com' },
]

export const handlers = [
  // GET /api/users 요청이 오면 → 위 목록을 JSON으로 응답
  http.get('/api/users', () => HttpResponse.json(users)),
]
```

읽는 법은 단순합니다. `http.get(경로, 리졸버)` — "이 경로로 GET 요청이 오면, 이 함수(리졸버)가 응답을 만든다." `HttpResponse.json(users)`는 그 응답을 JSON으로 빚는 도우미고요. 핸들러 배열에 규칙을 쌓아두면, 그게 곧 우리 가짜 서버의 API 명세가 됩니다.

```ts
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

// 핸들러 규칙으로 무장한 테스트(Node)용 목 서버
export const server = setupServer(...handlers)
```

그리고 셋업 파일에 라이프사이클을 연결합니다. **[환경 세팅 편](/posts/frontend-testing-setup-vitest/)에서 만든 setup.ts가 여기서 완성돼요.**

```ts
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from '../mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())   // 테스트 간 오염 방지
afterAll(() => server.close())
```

세 줄이 각자 맡은 일이 있습니다. `listen`은 테스트 파일이 시작될 때 경비실에 **출근 도장**을 찍는 것이고, `close`는 다 끝나면 퇴근. 가운데 `resetHandlers`는 **매 테스트가 끝날 때마다 핸들러를 처음 상태로 되돌립니다** — 이게 왜 중요한지는 바로 다음 섹션에서 보게 돼요. `onUnhandledRequest: 'error'`는 "등록 안 된 방문자는 통과 금지" 옵션입니다. 핸들러 없는 요청이 몰래 나가면 테스트를 즉시 실패시켜서, "어? 이 컴포넌트가 이런 API도 불렀어?"를 그 자리에서 알려줘요.

---

## 성공 경로 — 핸들러가 있으니 그냥 됩니다

이 핸들러 위에서 컴포넌트 테스트가 어떻게 되는지부터 볼게요. 무대는 [지난 편](/posts/frontend-testing-user-event/)에서 서버 연동으로 업그레이드한 검색 컴포넌트 `UserSearch`입니다 — 마운트되면 `/api/users`에서 목록을 불러와 그려요. 처음 오셨다면 "fetch로 목록을 받아 보여주는 평범한 컴포넌트"라고 생각하시면 충분합니다.

```tsx
it('사용자 목록을 불러와 보여준다', async () => {
  render(<UserSearch />)
  // MSW가 돌려준 목록이 화면에 그려질 때까지 기다렸다가 검증
  expect(await screen.findByText('Alice Kim')).toBeInTheDocument()
})
```

테스트 어디에도 MSW가 안 보이죠? 그게 포인트입니다. 컴포넌트가 `/api/users`를 부르면 경비실이 등록된 규칙대로 Alice·Bob·Carol을 돌려주고, 테스트는 화면에 그려진 결과만 검증해요. **네트워크 사정이라는 변수가 사라진, 언제 돌려도 같은 테스트**가 됐습니다.

---

## 실패도 손쉽게

기억하실지 모르겠어요 — [지난 편](/posts/frontend-testing-user-event/)에서 에러 상태(`role="alert"`) 테스트를 "다음 편의 특기"라며 미뤄뒀었죠. 바로 지금입니다. 한 테스트에서만 서버 에러를 흉내 내려면 핸들러를 덮어씁니다. (repo 실제 테스트)

```tsx
it('서버 오류 시 role="alert"로 알린다', async () => {
  // 이 테스트에서만 /api/users를 500으로 덮어쓰기 (끝나면 자동 복구)
  server.use(http.get('/api/users', () => new HttpResponse(null, { status: 500 })))
  render(<UserSearch />)

  expect(await screen.findByRole('alert')).toHaveTextContent('불러오지 못했습니다')
})
```

`server.use(...)`는 기존 규칙 위에 **임시 규칙을 한 장 덮는 것**이에요. 그리고 아까 셋업의 `resetHandlers()`가 매 테스트 후 그 임시 규칙을 걷어냅니다 — 그래서 이 500 응답은 **이 테스트에서만** 유효하고, 다음 테스트는 다시 정상 응답을 받아요. 진짜 서버라면 "잠깐 500 좀 내주세요"라고 부탁할 수도 없는 노릇인데, 경비실은 부탁 한 줄이면 들어줍니다.

{{< img src="images/contents/msw-lifecycle.png" alt="MSW 라이프사이클 타임라인 다이어그램 - beforeAll의 listen으로 시작해 테스트 1은 기본 핸들러로 200 응답을 받고, 테스트 2는 server.use로 500 오버라이드를 덮지만 afterEach의 resetHandlers가 매번 임시 규칙을 걷어내 테스트 3은 다시 200을 받으며, afterAll의 close로 끝납니다" >}}

---

## 왜 fetch 모킹 대신 MSW

`vi.spyOn(global, 'fetch')`는 구현에 묶여요. 내일 누가 fetch를 axios로 바꾸면, 동작은 그대로인데 테스트가 우수수 깨집니다 — [철학 편](/posts/frontend-testing-testing-library/)에서 경계했던 '구현 테스트'가 네트워크에서 재현되는 거죠. 그리고 앞서 말했듯 `fetchUsers`를 통째로 대역으로 바꾸면 그 함수 속 코드는 검증 밖입니다. MSW는 **네트워크 경계**에서 가로채니, 코드가 axios든 fetch든 React Query든 테스트가 그대로고, 내 코드는 마지막 한 줄까지 진짜로 돕니다.

{{< img src="images/contents/fetch-mock-vs-msw.png" alt="fetch 직접 모킹과 MSW의 비교 다이어그램 - 왼쪽은 vi.spyOn으로 fetch를 바꿔치기해 fetchUsers 내부 코드가 실행되지 않고 fetch를 axios로 바꾸면 테스트가 깨지는 구조, 오른쪽은 MSW가 네트워크 경계에서 가로채 내 코드는 전부 진짜로 실행되고 HTTP 클라이언트를 교체해도 테스트가 그대로인 구조입니다" >}}

정리하면 기준은 이렇습니다 — **내 코드는 진짜로, 회선 너머만 가짜로.** [테스트 더블 편](/posts/frontend-testing-test-doubles/)의 "경계만 가짜로" 원칙이 네트워크에서 완성되는 셈이에요.

게다가 같은 핸들러로 **개발 중 브라우저 목업**(`src/mocks/browser.ts` + `npx msw init public`)도 돌릴 수 있어요. 한 번 만들고 두 번 우려먹기.

아니, 사실 **세 번**입니다. 이 시리즈의 [라이브 데모](https://isaaceryn.github.io/frontend-testing-lab/)는 백엔드가 아예 없는 GitHub Pages인데도 검색이 동작하죠 — 배포판에서 같은 MSW 핸들러가 `/api/users`를 응답하고 있거든요. 테스트용으로 만든 목이 데모 서버 역할까지 하는 셈이에요.

---

## 한 장 요약

- MSW = 네트워크 **경계**에서 요청을 가로채 가짜 응답을 주는 도구 — 코드는 진짜 서버와 대화하는 줄 모름
- 구성 3종: **핸들러**(요청→응답 규칙) + **server**(테스트용 Node) + 셋업 파일의 라이프사이클(listen/resetHandlers/close)
- 실패 시뮬레이션은 `server.use(...)` 한 줄 — 그 테스트에서만 유효, 끝나면 자동 복구
- 기준은 "**내 코드는 진짜로, 회선 너머만 가짜로**" — fetch를 직접 모킹하면 구현이 바뀔 때 테스트도 깨집니다
- `onUnhandledRequest: 'error'`로 몰래 나가는 요청까지 감시, 같은 핸들러를 개발·데모용으로 재활용

---

## 네트워크는 이제 손안에 있습니다

이제 안정적인 컴포넌트 테스트 기반이 갖춰졌어요. 다음 편은 이 챕터의 하이라이트 — 접근성을 테스트에 녹입니다.

오늘 만든 경비실은 이 시리즈가 끝날 때까지 계속 근무합니다. 앞으로 어떤 컴포넌트가 어떤 API를 부르든, 핸들러 한 장 추가하면 성공이든 실패든 원하는 장면을 만들 수 있어요.

> **레벨업**: 네트워크를 격리해 성공·실패 시나리오를 마음대로 시뮬레이션할 수 있습니다.

> **다음 편**: 접근성을 테스트에 녹이기 — role 기반 쿼리

> 낯선 용어가 있었다면 — [용어집](/posts/frontend-testing-glossary/)에 전부 한 줄씩 정리돼 있어요.

