본문 바로가기
프론트엔드

MSW란?

by 코딩의 세계 2026. 2. 4.

공식 사이트

MSW (Mock Service Worker) - 네트워크 레벨 API 모킹

들어가며

당신이 프론트엔드 개발 중에 백엔드 API가 완성되기를 기다린 적이 있나요? 또는 백엔드 개발자가 특정 에러 상황을 재현하기 어려워 했나요?

MSW(Mock Service Worker)는 이 모든 문제를 해결합니다. Service Worker API를 사용하여 네트워크 레벨에서 API 요청을 가로채고, 정의된 핸들러가 응답을 제공합니다. 이는 단순한 모킹을 넘어, 실제 네트워크 요청처럼 작동합니다.

공식 깃허브

https://github.com/mswjs/msw

MSW의 핵심 개념

기존 방식 vs MSW

기존 방식 (fetch 모킹):
┌─ 테스트 코드
├─ jest.mock('/api/users', ...)
├─ 매번 설정 필요
└─ 부분적 모킹만 가능

MSW (네트워크 레벨 모킹):
┌─ 애플리케이션
├─ fetch / axios / 등 모든 HTTP 라이브러리
├─ Service Worker (MSW)
├─ 정의된 핸들러
└─ 응답 반환

장점:
- 라이브러리 무관 (fetch든 axios든 상관없음)
- 한 번 정의하면 모든 테스트/개발에 사용
- 실제 네트워크처럼 작동

MSW가 해결하는 문제

// ❌ 문제 1: API 완성 대기
// 백엔드가 아직 구현 중이면 프론트엔드는 대기
// 월요일: API 문서 받음
// 목요일: 실제 API 완성
// → 4일 낭비

// ✅ MSW로 해결
// 월요일: API 문서 기반 MSW 핸들러 작성
// 화요일: 프론트엔드 개발 시작
// 목요일: 실제 API와 통합 (핸들러만 제거)

// ❌ 문제 2: 에러 상황 테스트 어려움
// 404, 500 에러는 언제 발생할까?
// 프로덕션 데이터로는 테스트 불가

// ✅ MSW로 해결
// scenarios.errorFlow() // 500 에러 시나리오
// scenarios.notFound()  // 404 에러 시나리오
// 원하는 만큼 테스트 가능

// ❌ 문제 3: 네트워크 지연 테스트
// 3G 환경은 어떻게?
// 느린 네트워크에서 로딩 상태가 제대로 표시되나?

// ✅ MSW로 해결
// await new Promise(r => setTimeout(r, 2000));  // 2초 지연
// 로딩 상태 테스트 가능

MSW 설치 및 기본 설정

1. 설치

npm install msw --save-dev

2. 기본 핸들러 정의

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

export const handlers = [
  // GET /api/users/:id
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    
    // 특정 사용자에 대한 mock 응답
    return HttpResponse.json({
      id,
      name: 'John Doe',
      email: 'john@example.com',
      avatar: 'https://example.com/avatar.jpg'
    });
  }),

  // POST /api/users
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    
    // 요청 본문을 기반으로 응답
    return HttpResponse.json(
      {
        id: '123',
        ...body,
        createdAt: new Date().toISOString()
      },
      { status: 201 }  // 201 Created
    );
  }),

  // DELETE /api/users/:id
  http.delete('/api/users/:id', ({ params }) => {
    return HttpResponse.json(
      { message: 'User deleted' },
      { status: 204 }
    );
  }),

  // 에러 응답
  http.get('/api/users/999', () => {
    return HttpResponse.json(
      { error: 'User not found' },
      { status: 404 }
    );
  })
];

3. 브라우저 환경 설정

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

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

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

// 개발 환경에서만 MSW 활성화
if (process.env.NODE_ENV === 'development') {
  const { worker } = await import('./mocks/browser');
  await worker.start({
    // 경고 비활성화 (선택사항)
    onUnhandledRequest: 'bypass'
  });
}

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

4. Node.js 테스트 환경 설정

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

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

// src/__tests__/setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from '../mocks/server';

// 모든 테스트 시작 전 서버 시작
beforeAll(() => server.listen());

// 각 테스트 후 핸들러 초기화
afterEach(() => server.resetHandlers());

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

실전: 복잡한 시나리오 관리

1. 핸들러 구조화

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

export const userHandlers = [
  // 사용자 목록
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: '1', name: 'Alice', email: 'alice@example.com' },
      { id: '2', name: 'Bob', email: 'bob@example.com' },
      { id: '3', name: 'Charlie', email: 'charlie@example.com' }
    ]);
  }),

  // 개별 사용자
  http.get('/api/users/:id', ({ params }) => {
    const users: Record<string, any> = {
      '1': { id: '1', name: 'Alice', email: 'alice@example.com' },
      '2': { id: '2', name: 'Bob', email: 'bob@example.com' }
    };

    if (params.id in users) {
      return HttpResponse.json(users[params.id]);
    }

    return HttpResponse.json(
      { error: 'User not found' },
      { status: 404 }
    );
  }),

  // 사용자 생성
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();

    // 유효성 검증
    if (!body.email || !body.name) {
      return HttpResponse.json(
        { error: 'Missing required fields' },
        { status: 400 }
      );
    }

    return HttpResponse.json(
      {
        id: Math.random().toString(),
        ...body,
        createdAt: new Date().toISOString()
      },
      { status: 201 }
    );
  })
];

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

export const productHandlers = [
  // 상품 목록
  http.get('/api/products', ({ request }) => {
    const url = new URL(request.url);
    const category = url.searchParams.get('category');

    const products = [
      { id: '1', name: 'Product 1', category: 'electronics', price: 100 },
      { id: '2', name: 'Product 2', category: 'books', price: 20 },
      { id: '3', name: 'Product 3', category: 'electronics', price: 200 }
    ];

    if (category) {
      return HttpResponse.json(
        products.filter(p => p.category === category)
      );
    }

    return HttpResponse.json(products);
  }),

  // 상품 검색
  http.get('/api/products/search', ({ request }) => {
    const url = new URL(request.url);
    const query = url.searchParams.get('q');

    if (!query) {
      return HttpResponse.json(
        { error: 'Query parameter required' },
        { status: 400 }
      );
    }

    return HttpResponse.json([
      { id: '1', name: `Product matching ${query}`, price: 100 }
    ]);
  })
];

// src/mocks/handlers/index.ts
import { userHandlers } from './users';
import { productHandlers } from './products';

export const handlers = [
  ...userHandlers,
  ...productHandlers
];

2. 시나리오 관리

// src/mocks/scenarios.ts
import { server } from './server';
import { http, HttpResponse } from 'msw';

export const scenarios = {
  // 정상 흐름
  normalFlow: () => {
    server.use(
      http.get('/api/users/1', () =>
        HttpResponse.json({ id: '1', name: 'John' })
      )
    );
  },

  // 네트워크 오류
  networkError: () => {
    server.use(
      http.get('/api/users/1', () => {
        return HttpResponse.error();
      })
    );
  },

  // 타임아웃
  timeout: () => {
    server.use(
      http.get('/api/users/1', async () => {
        // 요청이 타임아웃될 때까지 대기
        await new Promise(r => setTimeout(r, 10000));
        return HttpResponse.json({ id: '1' });
      })
    );
  },

  // 느린 네트워크 (3G)
  slowNetwork: () => {
    server.use(
      http.get('/api/users/1', async () => {
        await new Promise(r => setTimeout(r, 2000));
        return HttpResponse.json({ id: '1', name: 'John' });
      })
    );
  },

  // 서버 오류
  serverError: () => {
    server.use(
      http.get('/api/users/1', () =>
        HttpResponse.json(
          { error: 'Internal Server Error' },
          { status: 500 }
        )
      )
    );
  },

  // 인증 오류
  unauthorized: () => {
    server.use(
      http.get('/api/users/1', () =>
        HttpResponse.json(
          { error: 'Unauthorized' },
          { status: 401 }
        )
      )
    );
  },

  // 데이터 검증 오류
  validationError: () => {
    server.use(
      http.post('/api/users', () =>
        HttpResponse.json(
          {
            error: 'Validation failed',
            details: {
              email: 'Invalid email format',
              name: 'Name is required'
            }
          },
          { status: 422 }
        )
      )
    );
  }
};

// 테스트에서 사용
describe('User API', () => {
  it('should fetch user successfully', async () => {
    scenarios.normalFlow();
    // 테스트
  });

  it('should handle 500 error', async () => {
    scenarios.serverError();
    // 에러 처리 테스트
  });

  it('should handle network error', async () => {
    scenarios.networkError();
    // 네트워크 오류 처리 테스트
  });

  it('should show loading during slow network', async () => {
    scenarios.slowNetwork();
    // 로딩 상태 표시 테스트
  });
});

프로덕션 레벨 구현

1. API Contract 문서화

# api-contract.yaml

endpoints:
  GET /api/users/:id:
    description: 사용자 조회
    parameters:
      id:
        type: string
        required: true
        example: "123"
    
    responses:
      200:
        description: 성공
        body:
          id: string
          name: string
          email: string
          avatar?: string
          createdAt: ISO8601
        example:
          id: "123"
          name: "John Doe"
          email: "john@example.com"
          createdAt: "2025-01-29T10:00:00Z"
      
      404:
        description: 사용자 없음
        body:
          error: string
        example:
          error: "User not found"

  POST /api/users:
    description: 사용자 생성
    requestBody:
      name: string (required)
      email: string (required)
      avatar?: string
    
    responses:
      201:
        description: 생성 성공
        body:
          id: string
          name: string
          email: string
          createdAt: ISO8601
      
      400:
        description: 유효성 검증 오류
        body:
          error: string
          details?: object
// MSW 핸들러로 변환
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    if (!params.id || params.id === '999') {
      return HttpResponse.json(
        { error: 'User not found' },
        { status: 404 }
      );
    }

    return HttpResponse.json({
      id: params.id,
      name: 'John Doe',
      email: 'john@example.com',
      createdAt: new Date().toISOString()
    });
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();

    if (!body.name || !body.email) {
      return HttpResponse.json(
        {
          error: 'Validation failed',
          details: {
            name: body.name ? undefined : 'Required',
            email: body.email ? undefined : 'Required'
          }
        },
        { status: 400 }
      );
    }

    return HttpResponse.json(
      {
        id: Math.random().toString(),
        ...body,
        createdAt: new Date().toISOString()
      },
      { status: 201 }
    );
  })
];

2. 테스트 예제

// src/__tests__/user.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { server } from '../mocks/server';
import { scenarios } from '../mocks/scenarios';

describe('User Component', () => {
  beforeEach(() => {
    scenarios.normalFlow();
  });

  afterEach(() => {
    server.resetHandlers();
  });

  it('should display user information', async () => {
    const response = await fetch('/api/users/1');
    const user = await response.json();

    expect(user.name).toBe('John');
    expect(user.email).toBe('john@example.com');
  });

  it('should handle user not found', async () => {
    scenarios.normalFlow();
    
    const response = await fetch('/api/users/999');
    expect(response.status).toBe(404);

    const error = await response.json();
    expect(error.error).toBe('User not found');
  });

  it('should create user', async () => {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: 'Alice',
        email: 'alice@example.com'
      })
    });

    expect(response.status).toBe(201);
    const user = await response.json();
    expect(user.name).toBe('Alice');
  });

  it('should show validation error on invalid data', async () => {
    scenarios.validationError();
    
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: 'Bob'
        // email 누락
      })
    });

    expect(response.status).toBe(400);
    const error = await response.json();
    expect(error.details.email).toBeDefined();
  });

  it('should handle network error', async () => {
    scenarios.networkError();
    
    expect(
      async () => await fetch('/api/users/1')
    ).rejects.toThrow();
  });

  it('should show loading during slow network', async () => {
    scenarios.slowNetwork();
    
    const promise = fetch('/api/users/1');
    
    // 즉시 로딩 상태 확인
    expect(document.querySelector('.loading')).toBeDefined();
    
    // 응답 대기
    const response = await promise;
    expect(response.status).toBe(200);
  });
});

3. React 컴포넌트 통합

// src/components/UserProfile.tsx
import { useEffect, useState } from 'react';

interface User {
  id: string;
  name: string;
  email: string;
}

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);

        if (!response.ok) {
          throw new Error(`User not found (${response.status})`);
        }

        const data = await response.json();
        setUser(data);
        setError(null);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Unknown error');
        setUser(null);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]);

  if (loading) return <div className="loading">Loading...</div>;
  if (error) return <div className="error">{error}</div>;
  if (!user) return <div>No user found</div>;

  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}
// src/__tests__/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from '../components/UserProfile';
import { scenarios } from '../mocks/scenarios';
import { server } from '../mocks/server';

describe('UserProfile', () => {
  it('should display user info', async () => {
    scenarios.normalFlow();
    
    render(<UserProfile userId="1" />);

    await waitFor(() => {
      expect(screen.getByText('John')).toBeInTheDocument();
    });
  });

  it('should show error when user not found', async () => {
    server.use(
      http.get('/api/users/999', () =>
        HttpResponse.json({ error: 'Not found' }, { status: 404 })
      )
    );

    render(<UserProfile userId="999" />);

    await waitFor(() => {
      expect(screen.getByText(/not found/i)).toBeInTheDocument();
    });
  });

  it('should show loading state', () => {
    scenarios.slowNetwork();
    
    render(<UserProfile userId="1" />);

    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });
});

팀 협업 가이드

# MSW 사용 규칙

## 핸들러 관리

1. 모든 API 핸들러는 src/mocks/handlers에 정의
2. 엔드포인트별로 파일 분리 (users.ts, products.ts 등)
3. API Contract와 동기화 필수

## 개발 워크플로우

### Phase 1: API 설계 (공동)
- API 문서 작성
- MSW 핸들러 작성 (프론트엔드)
- 프론트엔드 개발 진행

### Phase 2: 백엔드 개발
- 실제 API 구현
- 테스트

### Phase 3: 통합
- MSW 핸들러 제거
- 실제 API와 연결
- 통합 테스트

## 시나리오 정의

모든 가능한 에러 케이스에 대한 시나리오 작성:
- 정상 응답
- 404 (리소스 없음)
- 400 (유효성 검증 오류)
- 401 (인증 오류)
- 500 (서버 오류)
- 네트워크 오류
- 느린 네트워크 (로딩 상태)

## 문제 해결

**Q: MSW가 요청을 가로채지 않음**
A: 핸들러 경로가 정확한지 확인하세요.

**Q: 특정 시나리오만 테스트하고 싶어요**
A: scenarios.scenarioName() 호출 후 테스트하세요.

**Q: 프로덕션에서도 MSW를 사용해야 하나?**
A: 아니오! 프로덕션에서는 MSW를 제거하세요.

결론

MSW는 현대 프론트엔드 개발의 필수 도구입니다:

병렬 개발: 백엔드 완료 대기 불필요 ✅ 완전한 테스트: 모든 에러 시나리오 테스트 가능 ✅ 네트워크 시뮬레이션: 지연, 타임아웃 등 재현 가능 ✅ 라이브러리 무관: fetch, axios, 뭐든 상관없음 ✅ 프로덕션 안전: 개발/테스트에만 사용

특히 다음 경우 강력히 권장됩니다:

  • 프론트엔드와 백엔드가 병렬 개발
  • 엣지 케이스 테스트가 중요한 프로젝트
  • E2E 테스트 안정성이 필요한 경우
  • 네트워크 상황에 따른 UX 테스트

지금 바로 MSW를 도입하고 개발 속도를 높여보세요!

참고 글

https://velog.io/@khy226/msw%EB%A1%9C-%EB%AA%A8%EC%9D%98-%EC%84%9C%EB%B2%84-%EB%A7%8C%EB%93%A4%EA%B8%B0

 

MSW(Mock Service Worker)로 더욱 생산적인 FE 개발하기

MSW(Mock Service Worker)는 Service Worker를 이용해 서버를 향한 실제 네트워크 요청을 가로채서(intercept) 모의 응답 (Mocked response)를 보내주는 API Mocking 라이브러리이다.

velog.io


https://www.youtube.com/watch?v=7zcKu4-YPkU

 

'프론트엔드' 카테고리의 다른 글

Storybook - 컴포넌트 개발의 완전한 혁신  (1) 2026.02.24
Giscus란?  (0) 2026.02.06
Deno란?  (0) 2026.02.03
FSD란?  (0) 2026.02.02
Axios로 API 통신 구현하기  (0) 2026.01.11