
MSW(Mock Service Worker): API 모킹의 표준
테스트에서 fetch를 직접 모킹하거나, axios를 vi.mock()으로 교체하다가 실제 API와 차이가 생겨 고생한 적 있다. MSW는 네트워크 레벨에서 요청을 인터셉트해서 그런 문제를 근본적으로 해결한다. 설정부터 Vitest, Storybook 통합까지 정리했다.

테스트에서 fetch를 직접 모킹하거나, axios를 vi.mock()으로 교체하다가 실제 API와 차이가 생겨 고생한 적 있다. MSW는 네트워크 레벨에서 요청을 인터셉트해서 그런 문제를 근본적으로 해결한다. 설정부터 Vitest, Storybook 통합까지 정리했다.
코드를 먼저 짜고 테스트하는 게 아닙니다. 테스트를 먼저 짜고, 그걸 통과하기 위해 코딩하는 것. 순서를 뒤집으면 버그가 사라집니다.

서버 컴포넌트를 클라이언트 컴포넌트 안에 import 했더니, DB 연결이 끊기고 에러가 폭발했습니다. Next.js App Router의 핵심인 'Composition Pattern'을 구멍 뚫린 도넛에 비유해 설명하고, Context Provider를 올바르게 분리하는 방법을 정리해봤습니다.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

테스트 환경에서는 완벽하게 통과했다. 스테이징에서도 괜찮았다. 프로덕션에서 API 응답 구조가 바뀌었다는 걸 두 달 만에 알았다. 테스트가 실제 API 대신 오래된 모킹 데이터를 쓰고 있었기 때문이다.
이 문제의 근본 원인은 모킹 방식에 있었다. vi.mock('axios')나 global.fetch = jest.fn()처럼 라이브러리 레벨에서 모킹하면, 실제 네트워크 요청이 아예 발생하지 않는다. 컴포넌트가 fetch를 쓰는지, axios를 쓰는지에 따라 모킹 방법이 달라지는 것도 문제다.
MSW(Mock Service Worker)는 이 문제를 다른 방식으로 풀었다. 네트워크 레벨에서 요청을 인터셉트한다. 컴포넌트는 진짜 HTTP 요청을 보내고, MSW가 중간에서 가로채서 가짜 응답을 돌려준다. 어떤 HTTP 클라이언트를 써도 동일하게 동작한다.
// 방법 1: fetch 직접 모킹
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ users: [] }),
})
// 문제: fetch 전체가 교체됨, 다른 URL 요청도 영향받음
// 문제: Response 객체의 실제 동작 (headers, status 등) 안 됨
// 방법 2: axios 모킹
vi.mock('axios')
vi.mocked(axios.get).mockResolvedValue({ data: { users: [] } })
// 문제: axios를 안 쓰면 다시 짜야 함
// 문제: 테스트 코드가 HTTP 클라이언트 구현에 묶임
// 방법 3: API 함수 직접 모킹
vi.mock('./api/users')
vi.mocked(getUsers).mockResolvedValue([])
// 문제: 네트워크 레이어 전체를 건너뜀
// 문제: 실제 API 호출 코드가 테스트되지 않음
컴포넌트 → fetch('/api/users') → [브라우저 Service Worker / Node.js 인터셉터]
↓
MSW 핸들러
↓
가짜 응답 반환
컴포넌트는 진짜 HTTP 요청을 그대로 보낸다. 네트워크 레이어에서 인터셉트하므로:
npm install msw --save-dev
MSW 2.x 기준으로 설명한다.
핸들러는 브라우저와 Node.js 환경에서 공유된다:
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
// GET 요청
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: '김개발', email: 'dev@example.com', role: 'admin' },
{ id: '2', name: '이프론트', email: 'front@example.com', role: 'user' },
])
}),
// 동적 URL 파라미터
http.get('/api/users/:id', ({ params }) => {
const { id } = params
if (id === '999') {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
)
}
return HttpResponse.json({
id,
name: '김개발',
email: 'dev@example.com',
})
}),
// POST 요청 — 요청 바디 읽기
http.post('/api/users', async ({ request }) => {
const body = await request.json() as { name: string; email: string }
return HttpResponse.json(
{
id: String(Date.now()),
...body,
createdAt: new Date().toISOString(),
},
{ status: 201 }
)
}),
// PUT 요청
http.put('/api/users/:id', async ({ params, request }) => {
const { id } = params
const body = await request.json() as Partial<{ name: string; email: string }>
return HttpResponse.json({
id,
...body,
updatedAt: new Date().toISOString(),
})
}),
// DELETE 요청
http.delete('/api/users/:id', ({ params }) => {
return new HttpResponse(null, { status: 204 })
}),
]
# Service Worker 파일 생성
npx msw init public/ --save
// 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: 'warn', // 핸들러 없는 요청 경고
})
}
enableMocking().then(() => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
})
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/test/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from '../mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers()) // 각 테스트 후 핸들러 초기화
afterAll(() => server.close())
// vitest.config.ts
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})
// src/components/UserList.test.tsx
import { vi, describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { UserList } from './UserList'
describe('UserList', () => {
it('유저 목록을 불러와 표시한다', async () => {
render(<UserList />)
// 로딩 상태 확인
expect(screen.getByRole('status')).toHaveTextContent('로딩 중...')
// 데이터 로드 완료 대기
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
// 기본 핸들러에서 반환한 유저들이 표시되는지 확인
expect(screen.getByText('김개발')).toBeInTheDocument()
expect(screen.getByText('이프론트')).toBeInTheDocument()
})
})
기본 핸들러를 유지하면서 특정 테스트에서만 다른 응답을 반환하고 싶을 때:
// src/components/UserList.test.tsx
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
describe('UserList 에러 케이스', () => {
it('API 에러 시 에러 메시지를 표시한다', async () => {
// 이 테스트에서만 에러 응답 반환
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('데이터를 불러올 수 없습니다')
})
})
it('빈 목록일 때 안내 메시지를 표시한다', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json([])
})
)
render(<UserList />)
await waitFor(() => {
expect(screen.getByText('등록된 사용자가 없습니다')).toBeInTheDocument()
})
})
})
// server.resetHandlers()가 afterEach에서 자동 실행돼 기본 핸들러로 복귀
MSW 핸들러에서 요청이 올바른 데이터를 보내는지도 검증할 수 있다:
import { vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../mocks/server'
describe('CreateUser 폼', () => {
it('폼 제출 시 올바른 데이터로 API 호출', async () => {
const user = userEvent.setup()
const requestSpy = vi.fn()
server.use(
http.post('/api/users', async ({ request }) => {
const body = await request.json()
requestSpy(body)
return HttpResponse.json({ id: '123', ...(body as object) }, { status: 201 })
})
)
render(<CreateUserForm />)
await user.type(screen.getByLabelText('이름'), '박개발')
await user.type(screen.getByLabelText('이메일'), 'newdev@example.com')
await user.click(screen.getByRole('button', { name: '생성' }))
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith({
name: '박개발',
email: 'newdev@example.com',
})
})
})
})
import { http, HttpResponse, delay } from 'msw'
export const handlers = [
// 응답 지연
http.get('/api/slow-data', async () => {
await delay(2000) // 2초 지연
return HttpResponse.json({ data: 'slow response' })
}),
// 네트워크 에러 (연결 실패)
http.get('/api/unreachable', () => {
return HttpResponse.error()
}),
// 실제 네트워크 지연 패턴
http.get('/api/realistic', async () => {
await delay('real') // 실제 네트워크 지연 시뮬레이션
return HttpResponse.json({ data: 'ok' })
}),
]
테스트에서 로딩 UI를 테스트할 때 유용하다:
it('로딩 중에는 스켈레톤 UI를 표시한다', async () => {
server.use(
http.get('/api/users', async () => {
await delay(500)
return HttpResponse.json([])
})
)
render(<UserList />)
// 로딩 중 스켈레톤이 보이는지
expect(screen.getAllByTestId('skeleton-item')).toHaveLength(3)
// 로딩 완료 후 스켈레톤이 사라지는지
await waitFor(() => {
expect(screen.queryByTestId('skeleton-item')).not.toBeInTheDocument()
})
})
import { graphql, HttpResponse } from 'msw'
export const handlers = [
graphql.query('GetUser', ({ variables }) => {
const { id } = variables
return HttpResponse.json({
data: {
user: {
id,
name: '김개발',
email: 'dev@example.com',
},
},
})
}),
graphql.mutation('UpdateUser', ({ variables }) => {
return HttpResponse.json({
data: {
updateUser: {
...variables.input,
updatedAt: new Date().toISOString(),
},
},
})
}),
// 에러 응답
graphql.query('GetDeletedUser', () => {
return HttpResponse.json({
errors: [{ message: 'User not found', extensions: { code: 'NOT_FOUND' } }],
})
}),
]
MSW는 Storybook과 통합하면 컴포넌트 개발 시 실제 API 없이도 모든 상태를 시각화할 수 있다.
npm install msw-storybook-addon --save-dev
// .storybook/preview.ts
import { initialize, mswLoader } from 'msw-storybook-addon'
initialize()
export const loaders = [mswLoader]
// components/UserProfile.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { http, HttpResponse } from 'msw'
import { UserProfile } from './UserProfile'
const meta: Meta<typeof UserProfile> = {
component: UserProfile,
}
export default meta
type Story = StoryObj<typeof UserProfile>
export const Default: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/1', () => {
return HttpResponse.json({
id: '1',
name: '김개발',
email: 'dev@example.com',
avatar: '/avatar.jpg',
bio: '프론트엔드 개발자',
})
}),
],
},
},
}
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/1', async () => {
await new Promise(() => {}) // 영원히 pending
}),
],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [
http.get('/api/users/1', () => {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
)
}),
],
},
},
}
이제 Storybook에서 로딩, 에러, 정상 상태를 각각 스토리로 관리할 수 있다.
프로젝트가 커지면 핸들러를 도메인별로 분리한다:
// src/mocks/handlers/users.ts
import { http, HttpResponse } from 'msw'
import { db } from '../db' // 인메모리 DB (선택사항)
export const userHandlers = [
http.get('/api/users', () => {
return HttpResponse.json(db.users.getAll())
}),
http.get('/api/users/:id', ({ params }) => {
const user = db.users.findById(params.id as string)
if (!user) return HttpResponse.json({ error: 'Not found' }, { status: 404 })
return HttpResponse.json(user)
}),
http.post('/api/users', async ({ request }) => {
const data = await request.json() as { name: string; email: string }
const user = db.users.create(data)
return HttpResponse.json(user, { status: 201 })
}),
]
// src/mocks/handlers/posts.ts
import { http, HttpResponse } from 'msw'
export const postHandlers = [
http.get('/api/posts', ({ request }) => {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page') ?? 1)
const limit = Number(url.searchParams.get('limit') ?? 10)
return HttpResponse.json({
data: mockPosts.slice((page - 1) * limit, page * limit),
total: mockPosts.length,
page,
limit,
})
}),
]
// src/mocks/handlers/index.ts
import { userHandlers } from './users'
import { postHandlers } from './posts'
import { authHandlers } from './auth'
export const handlers = [
...authHandlers,
...userHandlers,
...postHandlers,
]
복잡한 CRUD 시나리오에서는 @mswjs/data로 인메모리 DB를 만들 수 있다:
npm install @mswjs/data --save-dev
// src/mocks/db.ts
import { factory, primaryKey, manyOf } from '@mswjs/data'
export const db = factory({
user: {
id: primaryKey(() => String(Date.now())),
name: String,
email: String,
role: String,
createdAt: () => new Date().toISOString(),
},
post: {
id: primaryKey(() => String(Date.now())),
title: String,
content: String,
authorId: String,
},
})
// 시드 데이터
db.user.create({ name: '김개발', email: 'dev@example.com', role: 'admin' })
db.user.create({ name: '이프론트', email: 'front@example.com', role: 'user' })
// 핸들러에서 DB 사용
http.get('/api/users', () => {
const users = db.user.getAll()
return HttpResponse.json(users)
}),
http.post('/api/users', async ({ request }) => {
const data = await request.json() as { name: string; email: string }
const user = db.user.create(data)
return HttpResponse.json(user, { status: 201 })
}),
http.delete('/api/users/:id', ({ params }) => {
db.user.delete({ where: { id: { equals: params.id as string } } })
return new HttpResponse(null, { status: 204 })
}),
이렇게 하면 테스트 간에 진짜 데이터가 추가/삭제되는 상태를 시뮬레이션할 수 있다.
// 잘못된 방법: 매 테스트마다 서버를 재시작 (느림)
beforeEach(() => server.listen())
afterEach(() => server.close())
// 올바른 방법
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers()) // 핸들러만 초기화
afterAll(() => server.close())
// baseURL이 있는 경우 주의
// fetch('/api/users') → 실제 URL: http://localhost:3000/api/users
// 상대 경로로 핸들러 정의
http.get('/api/users', handler) // 올바름
// 절대 경로로도 됨
http.get('http://localhost:3000/api/users', handler)
// 와일드카드
http.get('/api/users/*', handler) // /api/users/1, /api/users/2 등
// fetch로 API를 호출할 때 실제 Response 객체처럼 동작해야 함
// HttpResponse.json()은 Content-Type: application/json 헤더 자동 추가
return HttpResponse.json(data)
// 텍스트 응답
return new HttpResponse('plain text', {
headers: { 'Content-Type': 'text/plain' },
})
// 바이너리 데이터
return HttpResponse.arrayBuffer(buffer, {
headers: { 'Content-Type': 'application/octet-stream' },
})
MSW가 API 모킹의 사실상 표준이 된 이유를 정리하면:
실제 API 서버 없이도 프론트엔드 개발을 완전히 진행할 수 있고, 테스트 환경과 개발 환경의 모킹이 일치하니 "테스트에서는 됐는데 개발에서 안 된다" 문제가 사라진다.
투자할 시간이 있다면 지금 당장 도입하길 권한다.