2025-09-22 01:11

  • MSW는 서비스 워커 API를 활용해 네트워크 요청을 가로채는 혁신적인 API 모킹 라이브러리다.

  • 애플리케이션 코드 수정 없이 브라우저와 Node.js 환경 모두에서 일관된 모킹 경험을 제공한다.

  • 이를 통해 프론트엔드와 백엔드 개발의 의존성을 끊고, 안정적이며 예측 가능한 테스트 환경을 구축할 수 있다.

MSW 완벽 정복 핸드북 프론트엔드 개발과 테스트의 경계를 허물다

프론트엔드 개발자라면 누구나 한 번쯤 겪어봤을 딜레마가 있다. 디자인과 기획은 확정되었고, 이제 데이터를 연동해 화면을 그리기만 하면 되는데, 백엔드 API가 아직 준비되지 않은 상황. 혹은 특정 에러 상황(500 서버 에러, 403 권한 없음 등)을 테스트해야 하는데, 재현하기가 까다로운 경우도 많다.

이런 문제들을 해결하기 위해 우리는 지금까지 다양한 방법을 사용해왔다. JSON 파일을 로컬에 두고 import해서 사용하거나, json-server와 같은 간단한 목 서버를 직접 구축하기도 했다. 테스트 코드에서는 jest.mock('axios')와 같이 특정 라이브러리의 구현을 직접 모킹했다.

하지만 이 방법들은 모두 명확한 한계를 지닌다.

  • 코드 침투: 모킹을 위해 애플리케이션의 실제 코드를 수정해야 한다. (if (process.env.NODE_ENV === 'development') { ... })

  • 환경 종속성: 목 서버는 별도의 실행 환경이 필요하며, 팀원들과 공유하고 관리하기 번거롭다.

  • 구현 의존성: axios를 모킹한 테스트는 fetch로 교체하면 전부 깨진다.

  • 불일치: 개발 환경에서 쓰던 모킹 로직과 테스트 환경의 모킹 로직이 달라 일관성을 유지하기 어렵다.

이 모든 고통의 사슬을 끊기 위해 등장한 것이 바로 **MSW(Mock Service Worker)**다. MSW는 단순히 ‘또 하나의 모킹 라이브러리’가 아니다. API 모킹에 대한 접근 방식 자체를 바꾸는, 패러다임의 전환에 가깝다. 이 핸드북을 통해 MSW가 왜 만들어졌고, 어떤 원리로 동작하며, 어떻게 우리의 개발 및 테스트 워크플로우를 혁신할 수 있는지 깊이 있게 탐구해 보자.

1장 MSW의 탄생 배경 왜 우리는 네트워크 레벨의 모킹이 필요했나

MSW의 핵심 철학을 이해하려면 기존 모킹 방식의 근본적인 문제점을 먼저 짚어봐야 한다.

전통적인 모킹의 고질병

전통적인 API 모킹은 크게 두 가지로 나눌 수 있다. ‘애플리케이션 레벨’ 모킹과 ‘외부 서버’ 모킹.

  1. 애플리케이션 레벨 모킹: 이는 axiosfetch 같은 데이터 요청 함수의 구현 자체를 가짜 함수로 바꿔치기하는 방식이다. Jest 테스트에서 흔히 볼 수 있다.

    JavaScript

    // 예: Jest에서 axios 모킹
    import axios from 'axios';
    jest.mock('axios');
    
    test('should fetch users', async () => {
      const users = [{ id: 1, name: 'John Doe' }];
      const resp = { data: users };
      axios.get.mockResolvedValue(resp); // axios.get을 가짜 성공 응답으로 대체
    
      // ... 컴포넌트 렌더링 및 테스트
    });
    

    이 방식은 간단하지만, 애플리케이션이 axios라는 특정 구현에 강하게 의존하게 만든다. 만약 프로젝트가 fetchreact-query 내부의 fetch로 마이그레이션한다면 모든 테스트 코드를 수정해야 하는 대참사가 발생한다. 또한, 브라우저에서 직접 UI를 확인하며 개발할 때는 사용할 수 없다는 치명적인 단점이 있다.

  2. 외부 목 서버 모킹: json-server나 Express로 직접 간단한 서버를 만드는 방식이다. API 명세에 따라 실제 서버처럼 동작하는 가짜 서버를 띄우는 것이다.

    이 방식은 구현에 대한 의존성은 없앨 수 있지만, 새로운 문제를 낳는다.

    • 관리 포인트 증가: 개발을 위해 항상 목 서버를 실행해야 한다.

    • 환경 설정의 복잡성: CORS 문제, 포트 충돌 등 신경 쓸 것이 많아진다.

    • 상태 관리의 어려움: POST 요청으로 새로운 데이터가 추가되었을 때, 이 상태를 목 서버가 계속 유지해야 하는가? 테스트 간 상태를 격리하기는 더 어렵다.

발상의 전환 서비스 워커 API

MSW 개발자들은 이 문제에 대한 해답을 브라우저의 기본 기능인 서비스 워커(Service Worker) API에서 찾았다. 서비스 워커는 본래 푸시 알림, 백그라운드 동기화, 오프라인 지원 등 PWA(Progressive Web App)를 위해 만들어진 기술이다.

서비스 워커의 가장 핵심적인 특징은 브라우저와 네트워크 사이에서 프록시(Proxy) 역할을 할 수 있다는 것이다. 즉, 웹 애플리케이션이 보내는 모든 네트워크 요청을 중간에서 가로채고, 검사하고, 심지어 응답을 조작하여 대신 보낼 수 있다.

비유: 서비스 워커를 아파트 단지 입구의 ‘보안실’이라고 생각해보자. 모든 택배(네트워크 요청)는 일단 보안실을 거친다. 보안실 직원은 택배가 어디로 가는지(요청 URL), 무엇이 들었는지(요청 본문) 확인할 수 있다. 그리고 “이 택배는 내가 직접 전달할게”라며, 원래 배달될 물건 대신 준비해둔 다른 물건(모의 응답)을 입주민(애플리케이션)에게 전달할 수 있다. 입주민은 이 물건이 원래 택배기사에게서 온 것인지, 보안실 직원이 바꿔치기한 것인지 전혀 알지 못한다.

MSW는 바로 이 서비스 워커의 프록시 기능을 API 모킹에 활용했다. 애플리케이션은 평소처럼 /api/usersfetch 요청을 보낸다. 하지만 이 요청이 실제로 인터넷을 통해 서버로 날아가기 전에, 백그라운드에서 실행 중인 MSW의 서비스 워커가 이 요청을 가로챈다. 그리고 미리 정의된 핸들러에 따라 가짜 사용자 목록 JSON을 응답으로 보내준다.

이 방식의 위대함은 다음과 같다.

  • 애플리케이션 코드는 100% 그대로다. 모킹을 위해 단 한 줄의 조건문도 추가할 필요가 없다.

  • 실제 네트워크 요청처럼 동작한다. 브라우저 개발자 도구의 ‘Network’ 탭에 실제 요청처럼 모든 과정이 기록된다.

  • 환경에 구애받지 않는다. 개발, 테스트, 심지어 Storybook이나 Cypress 같은 E2E 테스트 환경에서도 동일한 모킹 로직을 재사용할 수 있다.

MSW는 ‘어떻게 가짜 데이터를 만들까?‘라는 질문에서 벗어나, ‘어떻게 네트워크 통신 자체를 가상화할까?’ 라는 근본적인 질문을 던졌고, 서비스 워커에서 그 해답을 찾아낸 것이다.

2장 MSW의 아키텍처 파헤치기 브라우저와 Node.js를 아우르는 설계

MSW는 두 가지 주요 환경, 즉 브라우저와 Node.js에서 동작하도록 설계되었다. 두 환경은 네트워크를 다루는 방식이 근본적으로 다르기 때문에, MSW는 각 환경에 맞는 정교한 전략을 사용한다. 하지만 놀랍게도 개발자는 거의 동일한 코드로 두 환경을 모두 지원할 수 있다.

브라우저 환경 서비스 워커의 마법

브라우저에서 MSW는 앞서 설명한 서비스 워커를 통해 작동한다. 동작 흐름은 다음과 같다.

  1. 초기화 (setupWorker): 개발자가 작성한 요청 핸들러(Request Handler)들을 모아 setupWorker 함수로 워커를 설정한다.

    JavaScript

    // src/mocks/browser.js
    import { setupWorker } from 'msw';
    import { handlers } from './handlers';
    
    export const worker = setupWorker(...handlers);
    
  2. 서비스 워커 등록: 애플리케이션이 시작될 때, worker.start()를 호출한다. 이 과정에서 public 폴더에 위치한 mockServiceWorker.js 파일이 브라우저에 서비스 워커로 등록된다. 이 파일은 npx msw init 명령어로 자동 생성된다.

    JavaScript

    // src/index.js
    if (process.env.NODE_ENV === 'development') {
      const { worker } = require('./mocks/browser');
      worker.start();
    }
    
  3. 요청 가로채기 (fetch 이벤트): 서비스 워커는 활성화된 후 fetch 이벤트를 리스닝한다. 애플리케이션에서 발생하는 모든 fetch, axios 요청은 이 이벤트를 트리거한다.

  4. 핸들러 매칭 및 응답: 가로챈 요청(URL, 메서드, 헤더 등)을 setupWorker에 등록된 핸들러 목록과 비교한다.

    • 매칭 성공 시: 해당 핸들러의 로직(Response Resolver)을 실행하여 모의 응답(Mocked Response)을 생성하고, 실제 네트워크로 요청을 보내는 대신 이 모의 응답을 애플리케이션에 반환한다.

    • 매칭 실패 시: 요청을 그대로 두어 실제 네트워크를 통해 서버로 전송되도록 한다 (req.passthrough()).

MSW의 브라우저 환경 동작 흐름도 (출처: mswjs.io)

Node.js 환경 교묘한 원숭이 패치

Jest나 Vitest 같은 테스트 환경은 브라우저가 아닌 Node.js 환경에서 실행된다. Node.js에는 서비스 워커가 없다. 그렇다면 MSW는 어떻게 Node.js에서 네트워크 요청을 가로채는 것일까?

답은 **원숭이 패치(Monkey Patching)**에 있다. MSW는 Node.js의 네이티브 모듈인 http, https와 요청을 보내는 데 사용되는 XMLHttpRequest 클래스를 가로채서 자체 로직으로 덮어쓴다.

  1. 초기화 (setupServer): 브라우저의 setupWorker 대신, msw/node에서 제공하는 setupServer를 사용한다. 놀랍게도 인자로 받는 요청 핸들러는 브라우저용과 완전히 동일하다.

    JavaScript

    // src/mocks/server.js
    import { setupServer } from 'msw/node';
    import { handlers } from './handlers';
    
    export const server = setupServer(...handlers);
    
  2. 서버 리스닝: 테스트 파일 전체가 실행되기 전에 server.listen()을 호출한다. 이 순간부터 Node.js 프로세스에서 발생하는 모든 나가는(outgoing) HTTP 요청은 MSW의 감시망에 들어온다.

    JavaScript

    // tests/setup.js
    import { server } from '../src/mocks/server.js';
    
    beforeAll(() => server.listen());
    afterEach(() => server.resetHandlers());
    afterAll(() => server.close());
    
  3. 요청 가로채기 및 응답: 애플리케이션 코드(예: 테스트 중인 컴포넌트)가 API를 호출하면, 패치된 http 모듈이 이 요청을 감지한다. 이후 과정은 브라우저와 동일하다. 등록된 핸들러와 매칭하여 모의 응답을 반환하거나, 실제 네트워크로 요청을 보낸다.

이러한 설계 덕분에 우리는 **단 하나의 핸들러 정의 파일(handlers.js)**로 개발 환경의 브라우저 모킹과 테스트 환경의 Node.js 모킹을 모두 커버할 수 있는 것이다. 이것이 MSW가 제공하는 강력한 일관성이다.

3장 MSW 실전 사용법 A-Z

이제 이론을 넘어 실제로 MSW를 프로젝트에 적용하는 방법을 단계별로 알아보자.

1단계 설치 및 초기 설정

먼저 개발 의존성으로 msw를 설치한다.

Bash

npm install msw --save-dev
# 또는
yarn add msw --dev

다음으로, MSW CLI를 사용하여 서비스 워커 스크립트를 생성한다. 이 스크립트는 브라우저가 이해하고 실행할 수 있는 파일이다. <public_dir>에는 index.html이 위치한 프로젝트의 public 폴더 경로를 지정한다. (예: public, dist)

Bash

npx msw init <public_dir> --save

이 명령을 실행하면 지정된 폴더에 mockServiceWorker.js 파일이 생성된다. 이 파일은 버전 관리에 포함해야 한다.

2단계 요청 핸들러 정의하기

모든 모킹 로직의 심장부인 요청 핸들러를 작성한다. 보통 src/mocks/handlers.js와 같은 파일에 모아둔다.

핸들러는 rest (REST API) 또는 graphql (GraphQL API) 객체를 사용하여 정의한다.

JavaScript

// src/mocks/handlers.js
import { rest } from 'msw';

export const handlers = [
  // 로그인 요청을 처리
  rest.post('/login', (req, res, ctx) => {
    // 세션 정보를 저장 (실제로는 사용하지 않음)
    sessionStorage.setItem('is-authenticated', 'true');

    return res(
      // 200 OK 상태 코드로 응답
      ctx.status(200),
    );
  }),

  // 사용자 정보를 가져오는 요청을 처리
  rest.get('/user', (req, res, ctx) => {
    // 사용자가 인증되었는지 확인
    const isAuthenticated = sessionStorage.getItem('is-authenticated');

    if (!isAuthenticated) {
      // 인증되지 않았다면 403 에러 응답
      return res(
        ctx.status(403),
        ctx.json({
          errorMessage: 'Not authorized',
        }),
      );
    }

    // 인증되었다면 사용자 정보 응답
    return res(
      ctx.status(200),
      ctx.json({
        username: 'admin',
        firstName: 'John',
      }),
    );
  }),
];
  • rest.post, rest.get: HTTP 메서드와 URL 경로를 지정하여 특정 요청을 가로챈다. URL에 :userId 같은 파라미터를 사용할 수도 있다.

  • req: 들어온 요청에 대한 정보(파라미터, 쿼리, 헤더, 본문 등)를 담고 있는 객체.

  • res: 모의 응답을 생성하는 함수.

  • ctx: 응답의 상태 코드, JSON 본문, 헤더, 딜레이 등을 설정하는 유틸리티 함수들의 모음.

3단계 환경별 설정 파일 작성

정의한 핸들러를 사용하여 브라우저용 워커와 Node.js용 서버를 설정한다.

브라우저용 설정 (src/mocks/browser.js)

JavaScript

import { setupWorker } from 'msw';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

Node.js용 설정 (src/mocks/server.js)

JavaScript

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

핸들러 배열(handlers)이 두 파일에서 모두 재사용되는 것을 확인하자.

4단계 애플리케이션 및 테스트 환경에 통합하기

개발 환경 (애플리케이션 진입점)

애플리케이션의 최상단 파일(예: src/index.js 또는 src/main.js)에서 개발 환경일 때만 MSW 워커를 실행하도록 설정한다.

JavaScript

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

// 개발 환경에서만 MSW 활성화
if (process.env.NODE_ENV === 'development') {
  const { worker } = require('./mocks/browser');
  worker.start();
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

이제 개발 서버를 실행하고 브라우저 개발자 도구의 콘솔을 열면 [MSW] Mocking enabled.라는 메시지를 볼 수 있다. 이후 발생하는 모든 네트워크 요청은 MSW에 의해 가로채지게 된다.

테스트 환경 (Jest 설정)

Jest의 경우, jest.setup.js 파일을 만들어 모든 테스트가 실행되기 전후에 서버를 제어하도록 설정한다.

먼저 jest.config.js에 설정 파일을 추가한다.

JavaScript

// jest.config.js
module.exports = {
  // ...
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
};

그런 다음, 설정 파일을 작성한다.

JavaScript

// src/setupTests.js
import { server } from './mocks/server.js';

// 모든 테스트 시작 전에 리스닝 시작
beforeAll(() => server.listen());

// 각 테스트가 끝난 후 핸들러 리셋 (테스트 간 영향 방지)
afterEach(() => server.resetHandlers());

// 모든 테스트가 끝난 후 서버 종료
afterAll(() => server.close());

이제 Jest로 테스트를 실행하면, 컴포넌트 내에서 발생하는 API 호출은 실제 네트워크가 아닌 MSW의 모의 서버로 향하게 된다.

React Testing Library를 사용한 테스트 예시

JavaScript

import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from './UserProfile';

test('인증되지 않은 사용자는 에러 메시지를 보여준다', async () => {
  render(<UserProfile />);
  
  // 비동기적으로 에러 메시지가 나타나는 것을 기다림
  const errorMessage = await screen.findByText(/Not authorized/i);
  expect(errorMessage).toBeInTheDocument();
});

test('인증된 사용자는 사용자 이름을 보여준다', async () => {
  // 세션 스토리지에 인증 정보 설정
  sessionStorage.setItem('is-authenticated', 'true');
  
  render(<UserProfile />);

  // 비동기적으로 사용자 이름이 나타나는 것을 기다림
  const username = await screen.findByText(/Hello, admin/i);
  expect(username).toBeInTheDocument();

  // 테스트 후 세션 스토리지 정리
  sessionStorage.clear();
});

이 테스트는 UserProfile 컴포넌트가 내부적으로 /user API를 호출한다는 사실을 전혀 모른 채, 오직 화면에 나타나는 결과물만을 검증한다. 이것이 MSW가 가능하게 하는, 구현으로부터 분리된 견고한 테스트다.

4장 MSW 200% 활용하기 고급 기법과 패턴

MSW의 기본 사용법에 익숙해졌다면, 이제 더 복잡한 시나리오를 다루는 고급 기법들을 알아보자.

동적 모의 응답

때로는 요청에 담긴 정보에 따라 동적으로 응답을 생성해야 한다. req 객체를 활용하면 URL 파라미터, 쿼리 스트링, 요청 본문 등에 접근할 수 있다.

JavaScript

// GET /products?category=books
rest.get('/products', (req, res, ctx) => {
  const category = req.url.searchParams.get('category');
  
  if (category === 'books') {
    return res(ctx.json([{ id: 'b1', name: 'The Lord of the Rings' }]));
  }
  
  return res(ctx.json([{ id: 'e1', name: 'MacBook Pro' }]));
}),

// GET /reviews/:productId
rest.get('/reviews/:productId', (req, res, ctx) => {
  const { productId } = req.params;
  
  return res(ctx.json({
    productId,
    content: `This is a review for product ${productId}.`
  }));
})

에러 상태와 네트워크 딜레이 시뮬레이션

프론트엔드 코드의 견고함을 테스트하려면 로딩 상태와 에러 처리 로직을 반드시 검증해야 한다. ctx는 이를 위한 강력한 도구를 제공한다.

  • ctx.status(code, message): 404, 500 등 원하는 HTTP 상태 코드를 반환한다.

  • ctx.delay(milliseconds): 실제 네트워크 지연을 시뮬레이션하여 로딩 스피너나 스켈레톤 UI가 올바르게 동작하는지 테스트할 수 있다.

JavaScript

rest.get('/user/bad-request', (req, res, ctx) => {
  return res(
    ctx.status(400),
    ctx.json({ message: 'Invalid user ID' })
  );
}),

rest.get('/user/slow-request', (req, res, ctx) => {
  return res(
    ctx.delay(1500), // 1.5초 지연
    ctx.status(200),
    ctx.json({ username: 'Slow User' })
  );
})

테스트별 핸들러 오버라이딩

기본적으로 모든 테스트는 setupTests.js에 설정된 공통 핸들러를 사용한다. 하지만 특정 테스트 케이스에서만 다른 응답을 내려줘야 할 때가 있다. 예를 들어, ‘상품 목록을 가져오다 500 에러가 발생했을 때 에러 UI가 잘 보이는가?‘를 테스트하고 싶을 수 있다.

이때 server.use()를 사용하면 해당 테스트 동안만 핸들러를 임시로 덮어쓸 수 있다.

JavaScript

import { server } from '../mocks/server';
import { rest } from 'msw';

test('서버 에러 발생 시 에러 메시지를 보여준다', async () => {
  // 기존의 GET /products 핸들러를 500 에러를 반환하도록 오버라이딩
  server.use(
    rest.get('/products', (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );
  
  render(<ProductList />);
  
  // 에러 메시지가 렌더링되는지 확인
  expect(await screen.findByText(/Something went wrong/i)).toBeInTheDocument();
});

afterEach에서 server.resetHandlers()가 호출되므로, 이렇게 추가된 핸들러는 해당 테스트가 끝나면 자동으로 제거된다.

GraphQL 모킹

MSW는 REST API뿐만 아니라 GraphQL도 완벽하게 지원한다. graphql 객체를 사용하여 쿼리(Query)와 뮤테이션(Mutation)을 모킹할 수 있다.

JavaScript

import { graphql } from 'msw';

export const handlers = [
  graphql.query('GetUser', (req, res, ctx) => {
    const { id } = req.variables;
    
    if (id === '1') {
      return res(
        ctx.data({
          user: {
            __typename: 'User',
            id: '1',
            name: 'Andy',
          },
        })
      );
    }
    // ...
  }),
  
  graphql.mutation('UpdateUser', (req, res, ctx) => {
    const { name } = req.variables;
    
    return res(
      ctx.data({
        updateUser: {
          __typename: 'User',
          id: '1',
          name,
        },
      })
    );
  })
]

5장 결론 MSW가 열어가는 새로운 개발 문화

MSW는 단순히 API를 모킹하는 라이브러리를 넘어, 프론트엔드 개발과 테스트의 문화를 바꾸고 있다.

  • 진정한 관심사 분리: 프론트엔드 팀은 더 이상 백엔드 API의 개발 진행 상황에 발목 잡히지 않는다. API 명세만 확정되면, MSW로 명세를 구현하고 즉시 UI 개발에 착수할 수 있다.

  • 견고하고 예측 가능한 테스트: 실제 네트워크의 변덕스러움(속도, 서버 상태 등)을 완벽히 통제하여 테스트의 신뢰도를 극적으로 높인다. ‘내 컴퓨터에선 됐는데 CI 서버에선 실패해요’ 같은 문제를 원천 봉쇄한다.

  • 향상된 개발자 경험: 브라우저에서 직접 다양한 API 시나리오(성공, 실패, 로딩 지연)를 손쉽게 시뮬레이션하며 UI의 완성도를 높일 수 있다.

  • 통합된 워크플로우: 개발, 유닛 테스트, 통합 테스트, E2E 테스트, UI 리뷰(Storybook)에 이르기까지 모든 과정에서 동일한 모킹 코드를 재사용하여 일관성을 확보한다.

MSW를 도입하는 것은 단순히 개발 도구 하나를 추가하는 것이 아니다. 이는 프론트엔드와 백엔드 간의 협업 방식을 개선하고, 애플리케이션의 품질을 한 단계 끌어올리며, 개발자 개개인의 생산성을 극대화하는 현명한 투자다. 아직 MSW의 세계를 경험해보지 못했다면, 지금 바로 당신의 프로젝트에 적용해보길 바란다. 네트워크의 족쇄에서 벗어난 진정한 프론트엔드 개발의 자유를 만끽하게 될 것이다.