Runtime Mock Switching with MSW
백엔드 API가 배포되기 전에 화면을 만들려고 MSW를 도입하면 곧 한 가지 불편을 만납니다. 오류 화면이나 로딩 상태를 보려면 핸들러 코드를 고치고 다시 불러와야 합니다. 예전에 팀에서 쓰던 개발용 툴바와 최근 프로젝트에서 실제 응답을 캡처해 모킹 기준으로 삼은 경험을 바탕으로 그 설계를 MSW 2.15.0 기준으로 다시 정리했습니다.
런타임에 바꾸고 싶은 것은 응답, 상태 코드, 지연입니다
MSW는 서비스 워커로 브라우저의 요청을 가로채 등록한 응답을 돌려줍니다. 비즈니스 코드에 테스트용 분기를 넣지 않아도 되므로 API 명세만 합의되면 백엔드 배포를 기다리지 않고 화면을 만들 수 있습니다.
화면을 만들다 보면 같은 엔드포인트에서 여러 상황을 봐야 합니다.
- 목록이 비었을 때, 한 건일 때, 페이지가 넘칠 때의 화면
- 404, 500 같은 실패 응답과 오류 경계의 동작
- 응답이 늦을 때의 로딩 표시와 중복 클릭 방지
- 첫 요청이 401로 실패한 뒤 토큰을 갱신하고 다시 요청하는 흐름
핸들러를 매번 고치지 않으려고 쓰던 툴바에는 이런 기능이 있었습니다. 등록된 엔드포인트를 URL별로 묶어 보여 주고 각 항목의 상태 코드, 지연 시간, 응답 본문을 화면에서 고쳐 적용했습니다. 한 번만 쓰이고 사라지는 일회성 응답을 만들 수 있었고 이 응답은 같은 URL과 메서드의 기존 응답보다 먼저 쓰였습니다. 바꾼 내용은 localStorage에 저장했다가 다시 불러올 수 있었고 처음 정의로 되돌리는 기능과 단축키로 모킹을 켜고 끄는 기능도 있었습니다.
이 기능은 모두 MSW 2.x의 공개 API만으로 다시 만들 수 있습니다. 아래 예제는 그 구조를 새로 쓴 코드입니다.
응답 규칙을 함수가 아니라 데이터로 둡니다
툴바가 응답을 보여 주고 고치고 저장하려면 응답 규칙이 데이터여야 합니다. 그 툴바도 함수로 넘긴 응답은 내용을 알 수 없어 “unknown”으로만 표시하고 수정할 수 없게 했습니다. 메서드, 경로, 상태 코드, 지연, 본문을 담은 객체를 기본 형식으로 두고 MSW 핸들러는 마지막에 만듭니다.
import { http, HttpResponse, delay, type JsonBodyType } from 'msw'
export type MockSpec = {
method: 'get' | 'post' | 'put' | 'patch' | 'delete'
path: string // '/api/users/:userId'처럼 경로 매개변수를 쓸 수 있다
status: number
delayMs: number
body: JsonBodyType
once?: boolean
}
export function toHandler(spec: MockSpec) {
return http[spec.method](
spec.path,
async () => {
if (spec.delayMs > 0) await delay(spec.delayMs)
return HttpResponse.json(spec.body, { status: spec.status })
},
{ once: spec.once },
)
}MSW 1.x의 rest.get(url, (req, res, ctx) => res(ctx.status(500), ctx.delay(1000), ctx.json(body)))는 2.x에서 http.get과 Fetch API의 Response를 확장한 HttpResponse로 바뀌었습니다. 지연도 ctx.delay 대신 리졸버 안에서 await delay()를 호출합니다. 2.15.0 소스에서 인자 없는 delay()는 브라우저에서 100~400ms 사이의 임의 지연을 쓰고 delay('infinite')는 응답을 끝없이 미룹니다. 로딩 화면을 오래 들여다볼 때 유용합니다. 네트워크 오류 자체를 흉내 내려면 HttpResponse.error()를 돌려줍니다.
worker.use와 resetHandlers로 덮어쓰고 되돌립니다
worker.use()는 핸들러를 기존 목록 앞에 붙입니다. 공식 문서는 이렇게 붙인 런타임 핸들러가 처음 핸들러보다 먼저 요청을 처리한다고 설명합니다. 툴바에서 고친 값은 처음 정의를 바꾸지 않고 use()로 덮어쓰기만 하면 됩니다.
되돌리는 함수는 세 가지입니다.
| 호출 | 결과 |
|---|---|
worker.resetHandlers() | use()로 붙인 런타임 핸들러를 모두 걷어 내고 처음 핸들러만 남김 |
worker.resetHandlers(...next) | 처음 핸들러 목록 자체를 next로 교체 |
worker.restoreHandlers() | once로 이미 쓰인 핸들러를 다시 쓸 수 있게 되돌림 |
런타임 핸들러는 현재 실행 환경이 살아 있는 동안만 유지됩니다. 페이지를 새로 불러오면 처음 핸들러만 남으므로 시나리오를 유지하려면 직접 저장해야 합니다. 앞 절에서 규칙을 데이터로 둔 덕분에 JSON으로 저장할 수 있습니다.
import type { SetupWorker } from 'msw/browser'
import { toHandler, type MockSpec } from './mock-spec'
const STORAGE_KEY = 'dev-mock-overrides'
export function applyOverrides(worker: SetupWorker, specs: MockSpec[]) {
worker.resetHandlers() // 이전 시나리오를 걷어 내고 기준선만 남긴다
worker.use(...specs.map(toHandler))
// 일회성 응답은 이미 쓰였을 수 있어 저장하지 않는다
localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.filter((spec) => !spec.once)))
}
export function restoreOverrides(worker: SetupWorker) {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) worker.use(...(JSON.parse(saved) as MockSpec[]).map(toHandler))
}
export function clearOverrides(worker: SetupWorker) {
localStorage.removeItem(STORAGE_KEY)
worker.resetHandlers()
}일회성 응답은 once: true 옵션 하나로 충분합니다. 토큰 만료 흐름을 확인할 때는 프로필 요청에 401을 한 번만 돌려주는 핸들러를 붙입니다. 이 핸들러가 소진되면 다음 요청은 기존 200 응답으로 처리됩니다.
sequenceDiagram
participant App as 화면
participant Once as 일회성 핸들러 401
participant Base as 기준 핸들러 200
App->>Once: GET /api/profile
Once-->>App: 401, 이후 소진
App->>Base: POST /api/token 토큰 갱신
Base-->>App: 새 토큰
App->>Base: GET /api/profile 재요청
Base-->>App: 200 프로필개발용 도구는 운영 번들에서 떼어 냅니다
MSW 문서는 worker.start()가 끝날 때까지 앱 렌더링을 미루라고 경고합니다. 서비스 워커 등록 전에 첫 요청이 나가면 모킹되지 않은 응답을 받기 때문입니다. 워커와 툴바는 개발 모드 조건 안에서 동적으로 불러와 운영 번들에 들어가지 않게 합니다.
async function enableMocking() {
if (import.meta.env.MODE !== 'mock') return
const { setupWorker } = await import('msw/browser')
const { baselineHandlers } = await import('./mocks/baseline')
const { restoreOverrides } = await import('./mocks/overrides')
const worker = setupWorker(...baselineHandlers)
await worker.start({ onUnhandledRequest: 'warn' })
restoreOverrides(worker)
return worker
}
enableMocking().then((worker) => {
createRoot(document.getElementById('root')!).render(
<App>{worker && <LazyMockToolbar worker={worker} />}</App>,
)
})핸들러가 없는 요청은 실제 네트워크로 나갑니다. onUnhandledRequest가 'warn'이면 콘솔에 경고를 남기고 'error'면 요청을 실패시킵니다. 모킹하지 않은 API가 실제 서버로 새는 것을 놓치기 싫다면 테스트에서는 'error'를 씁니다.
캡처한 실제 응답을 기준선으로 둡니다
손으로 쓴 모킹이나 명세에서 생성한 무작위 데이터는 타입은 맞아도 모양이 실제와 다릅니다. 배열의 길이, null이 나오는 필드, 값의 분포, 정렬 순서가 대표적입니다. 화면은 모킹에서 멀쩡하다가 실제 데이터에서 깨집니다.
최근 프로젝트에서는 개발계 백엔드에 원격 데스크톱 안에서만 접속할 수 있었고 그 안에서는 클립보드도 쓸 수 없었습니다. 그래서 개발 서버를 캡처 모드로 띄우면 실제 응답을 파일로 남기게 했습니다. 캡처 파일은 원격 환경 안에서 커밋하고 밖에서 모킹을 맞춘 뒤 지웠습니다. 명세에서 생성했던 시계열 모킹은 1년에 50~110점이었는데 캡처로 다시 만들자 실제 응답처럼 730점이 됐습니다.
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Plugin } from 'vite'
export function captureResponses(dir = 'src/mocks/captures'): Plugin {
return {
name: 'capture-responses',
apply: 'serve', // 개발 서버에서만 동작한다
configureServer(server) {
server.middlewares.use('/__capture', (req, res) => {
let raw = ''
req.on('data', (chunk) => (raw += chunk))
req.on('end', () => {
const capture = JSON.parse(raw) // { method, pathname, status, body }
const file = `${capture.method}${capture.pathname.replace(/\W+/g, '_')}.json`
mkdirSync(dir, { recursive: true })
writeFileSync(path.join(dir, file), JSON.stringify(capture, null, 2))
res.statusCode = 204
res.end()
})
})
},
}
}앱 쪽에서는 캡처 모드일 때만 응답 인터셉터가 메서드, 쿼리를 뺀 경로, 상태 코드, 본문을 /__capture로 보냅니다. MSW는 핸들러 경로의 쿼리 문자열을 매칭에 쓰지 않으므로 경로만 남깁니다. 저장된 파일은 그대로 기준선 핸들러가 됩니다.
import { http, HttpResponse, type JsonBodyType } from 'msw'
type Capture = {
method: 'get' | 'post' | 'put' | 'patch' | 'delete'
pathname: string
status: number
body: JsonBodyType
}
const captures = import.meta.glob<Capture>('./captures/*.json', { eager: true, import: 'default' })
export const baselineHandlers = Object.values(captures).map((capture) =>
http[capture.method](capture.pathname, () =>
HttpResponse.json(capture.body, { status: capture.status }),
),
)이렇게 하면 모킹은 세 층이 됩니다. 아래층의 캡처가 실제 응답의 모양을 지키고 그 위에 시나리오와 일회성 응답을 데이터로 얹습니다.
flowchart TB
Request["요청"] --> Once{"일회성 응답이 있나"}
Once -->|"있음"| R1["상태 코드, 지연, 본문을 한 번 응답"]
Once -->|"없음"| Scenario{"시나리오 덮어쓰기가 있나"}
Scenario -->|"있음"| R2["툴바에서 고친 응답"]
Scenario -->|"없음"| Base{"캡처 기준선이 있나"}
Base -->|"있음"| R3["캡처한 실제 응답"]
Base -->|"없음"| Network["실제 네트워크와 경고"]캡처에는 주의할 점이 있습니다.
- 응답에 개인정보나 토큰이 들어 있을 수 있습니다. 인증 엔드포인트는 캡처에서 빼고 커밋 전에 민감한 필드를 가립니다.
- 캡처는 찍은 날의 데이터입니다. 명세가 바뀌면 다시 캡처하고 생성한 타입과 맞는지 확인합니다.
- 캡처가 보장하는 것은 응답의 모양입니다. 백엔드의 업무 처리가 맞는지는 검증하지 않습니다.
브라우저에서 저장한 HAR 파일을 쓰는 방법도 있습니다. MSW 팀의 Source 라이브러리는 fromTraffic으로 HAR에서 핸들러를 만들고 같은 요청이 여러 번 기록되면 기록된 순서대로 응답합니다. 기록된 응답 시간만큼 지연도 재현합니다. MSW 2.x용은 @mswjs/source 0.5.0(peer msw ^2.10.0)이고 이름이 바뀐 @msw/source 0.7.0은 msw ^3.0.0을 요구합니다.
확인 방법과 한계
모킹이 적용됐는지는 브라우저 콘솔에서 확인합니다. MSW는 가로챈 요청을 콘솔에 기록하고 worker.start({ quiet: true })로 이 기록을 끌 수 있습니다. 툴바로 바꾼 시나리오가 새로 불러온 뒤에도 남는지와 일회성 응답이 한 번만 쓰이는지도 같이 봅니다.
이 방식의 한계도 분명합니다. 런타임 핸들러는 탭마다 따로 살아 있으므로 여러 탭을 오가며 확인할 때는 저장소에서 다시 불러와야 합니다. 모킹은 응답 계약을 흉내 낼 뿐이어서 명세 변경을 잡으려면 타입 생성이나 계약 검사가 따로 필요합니다.
이 글의 예제는 2026년 10월에 MSW 2.15.0 소스와 문서로 확인했습니다. 2026년 9월에 나온 MSW 3.0도 http, HttpResponse, delay, worker.use, resetHandlers를 그대로 제공합니다. 다만 3.0은 ESM 전용이고 Node.js 22 이상을 요구합니다. onUnhandledRequest는 onUnhandledFrame으로 이름이 바뀌었고 worker.stop()은 Promise를 돌려줍니다.