Skip to content
Published on

모의 서버(Mock Server) 구축 완벽 가이드

Authors

개요

현대 소프트웨어 개발에서 프론트엔드와 백엔드는 대부분 별도 팀이 동시에 작업합니다. 백엔드 API가 완성될 때까지 프론트엔드 개발이 블로킹되거나, 외부 서드파티 API에 의존하는 기능을 테스트할 때 실제 서비스에 접근할 수 없는 상황이 자주 발생합니다.

**모의 서버(Mock Server)**는 이런 문제를 해결하는 핵심 도구입니다. API 계약을 먼저 정의하고, 실제 백엔드 없이도 프론트엔드 개발과 테스트를 진행할 수 있게 해줍니다.


1. 모의 서버가 필요한 이유

1.1 프론트엔드-백엔드 병렬 개발

전통적인 개발 방식 (순차):
  백엔드 API 완성 → 프론트엔드 개발 시작
  └── 지연: 프론트엔드가 백엔드를 기다림

모의 서버를 활용한 방식 (병렬):
  API 스펙 정의 → 백엔드 개발 || 프론트엔드 개발
                              ↑ 모의 서버로 블로킹 없음

두 팀이 API 계약(Contract)만 먼저 합의하면 동시에 개발할 수 있습니다.

1.2 외부 API 의존성 제거

외부 결제 API, 지도 서비스, 날씨 API 등 서드파티 서비스는 다음 문제가 있습니다:

  • 비용 발생: 호출마다 과금되는 API
  • 속도 제한: Rate Limiting으로 테스트 실행이 느려짐
  • 서비스 불안정: 외부 서비스 장애로 CI/CD 파이프라인이 실패
  • 테스트 데이터 오염: 실제 데이터에 테스트 데이터가 섞임

1.3 에러 케이스 재현

실제 서비스에서 재현하기 어려운 상황을 모의 서버로 쉽게 재현할 수 있습니다:

  • 네트워크 타임아웃
  • 500 Internal Server Error
  • 429 Too Many Requests
  • 네트워크 연결 끊김
  • 응답 지연 (느린 네트워크 시뮬레이션)

1.4 테스트 환경 안정성

CI/CD 파이프라인에서 외부 의존성 없애기:
  실제 DB 없이 → InMemory DB Fake
  실제 API 없이 → Mock Server
  실제 메시지 큐 없이 → In-process Fake

2. json-server (가장 빠른 시작)

json-server는 JSON 파일 하나로 30초 만에 REST API 서버를 만들 수 있는 도구입니다.

2.1 설치와 기본 사용법

# 전역 설치
npm install -g json-server

# 또는 프로젝트에 설치
npm install --save-dev json-server

2.2 db.json 구조 설계

{
  "users": [
    { "id": 1, "name": "홍길동", "email": "hong@example.com", "role": "admin" },
    { "id": 2, "name": "이순신", "email": "lee@example.com", "role": "user" }
  ],
  "posts": [
    { "id": 1, "title": "첫 번째 포스트", "body": "내용입니다", "userId": 1, "published": true },
    { "id": 2, "title": "두 번째 포스트", "body": "내용입니다", "userId": 2, "published": false }
  ],
  "comments": [{ "id": 1, "text": "좋은 글이네요", "postId": 1, "userId": 2 }],
  "profile": {
    "name": "관리자",
    "version": "1.0.0"
  }
}

json-server 실행:

json-server --watch db.json --port 3001

자동으로 생성되는 엔드포인트:

GET    /users
GET    /users/1
POST   /users
PUT    /users/1
PATCH  /users/1
DELETE /users/1
GET    /posts?userId=1
GET    /posts?published=true
GET    /posts?_page=1&_limit=10
GET    /posts?_sort=title&_order=asc

2.3 커스텀 라우팅 (routes.json)

{
  "/api/*": "/$1",
  "/api/v1/users/:id": "/users/:id",
  "/blog/:id/comments": "/comments?postId=:id"
}

실행 시 라우팅 파일 적용:

json-server --watch db.json --routes routes.json --port 3001

2.4 미들웨어 추가

// middleware.js
module.exports = (req, res, next) => {
  // 인증 헤더 검사
  if (req.headers.authorization !== 'Bearer valid-token') {
    if (req.method !== 'GET') {
      return res.status(401).json({ error: 'Unauthorized' })
    }
  }

  // 응답 지연 시뮬레이션 (500ms)
  setTimeout(next, 500)
}
json-server --watch db.json --middlewares middleware.js

2.5 관계형 데이터 모델링

json-server는 _embed_expand를 통해 관계형 데이터를 지원합니다:

# 댓글 포함한 포스트 조회
GET /posts/1?_embed=comments

# 작성자 정보 포함
GET /posts?_expand=user

# 중첩 관계
GET /users?_embed=posts

2.6 페이지네이션, 정렬, 필터링

# 페이지네이션 (10개씩, 2페이지)
GET /posts?_page=2&_limit=10

# 정렬 (제목 내림차순)
GET /posts?_sort=title&_order=desc

# 필터링 (published=true인 포스트)
GET /posts?published=true

# 검색 (전체 텍스트)
GET /posts?q=검색어

# 범위 필터
GET /posts?id_gte=5&id_lte=10

2.7 커스텀 서버 스크립트

// server.js - 더 세밀한 제어가 필요할 때
const jsonServer = require('json-server')
const server = jsonServer.create()
const router = jsonServer.router('db.json')
const middlewares = jsonServer.defaults()

server.use(middlewares)

// 커스텀 라우트
server.post('/auth/login', (req, res) => {
  const { email, password } = req.body
  if (email === 'admin@example.com' && password === 'password') {
    res.json({ token: 'fake-jwt-token', userId: 1 })
  } else {
    res.status(401).json({ error: 'Invalid credentials' })
  }
})

server.use(router)
server.listen(3001, () => {
  console.log('JSON Server is running on port 3001')
})

3. MSW (Mock Service Worker) - 현대적 방법

MSW는 Service Worker를 활용해 네트워크 레벨에서 요청을 가로채는 현대적인 API 모킹 라이브러리입니다. 브라우저와 Node.js 양쪽에서 동일한 핸들러를 사용할 수 있습니다.

3.1 설치 및 설정

npm install msw --save-dev

# Service Worker 파일 생성 (public 폴더에)
npx msw init public/ --save

3.2 REST API 핸들러 작성

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

export const handlers = [
  // GET 사용자 목록
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: '홍길동', email: 'hong@example.com' },
      { id: 2, name: '이순신', email: 'lee@example.com' },
    ])
  }),

  // GET 특정 사용자
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params

    if (id === '999') {
      return new HttpResponse(null, { status: 404 })
    }

    return HttpResponse.json({
      id: Number(id),
      name: '홍길동',
      email: 'hong@example.com',
    })
  }),

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

    return HttpResponse.json({ id: Date.now(), ...newUser }, { status: 201 })
  }),

  // PUT 사용자 수정
  http.put('/api/users/:id', async ({ params, request }) => {
    const { id } = params
    const updates = await request.json()

    return HttpResponse.json({ id: Number(id), ...updates })
  }),

  // DELETE 사용자 삭제
  http.delete('/api/users/:id', ({ params }) => {
    return new HttpResponse(null, { status: 204 })
  }),
]

3.3 GraphQL 핸들러

import { graphql, HttpResponse } from 'msw'

export const graphqlHandlers = [
  graphql.query('GetUser', ({ variables }) => {
    const { id } = variables

    return HttpResponse.json({
      data: {
        user: {
          id,
          name: '홍길동',
          email: 'hong@example.com',
          posts: [{ id: 1, title: '첫 번째 포스트' }],
        },
      },
    })
  }),

  graphql.mutation('CreateUser', ({ variables }) => {
    const { name, email } = variables

    return HttpResponse.json({
      data: {
        createUser: {
          id: String(Date.now()),
          name,
          email,
        },
      },
    })
  }),
]

3.4 동적 응답 (요청 파라미터 활용)

import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/products', ({ request }) => {
    const url = new URL(request.url)
    const category = url.searchParams.get('category')
    const page = Number(url.searchParams.get('page') ?? '1')
    const limit = Number(url.searchParams.get('limit') ?? '10')

    const allProducts = [
      { id: 1, name: '노트북', category: 'electronics', price: 1200000 },
      { id: 2, name: '마우스', category: 'electronics', price: 30000 },
      { id: 3, name: '책상', category: 'furniture', price: 250000 },
    ]

    let filtered = allProducts
    if (category) {
      filtered = allProducts.filter((p) => p.category === category)
    }

    const start = (page - 1) * limit
    const data = filtered.slice(start, start + limit)

    return HttpResponse.json({
      data,
      total: filtered.length,
      page,
      limit,
    })
  }),
]

3.5 에러 응답 시나리오

import { http, HttpResponse } from 'msw'

// 에러 시나리오를 동적으로 제어하는 패턴
let shouldFailNext = false

export const handlers = [
  // 에러 시뮬레이션 제어 엔드포인트 (테스트 전용)
  http.post('/test/simulate-error', async ({ request }) => {
    const { fail } = await request.json()
    shouldFailNext = fail
    return new HttpResponse(null, { status: 200 })
  }),

  http.post('/api/payment', async ({ request }) => {
    if (shouldFailNext) {
      shouldFailNext = false
      return HttpResponse.json(
        { error: 'PAYMENT_DECLINED', message: '카드가 거절되었습니다' },
        { status: 402 }
      )
    }

    const { amount } = await request.json()
    return HttpResponse.json({
      paymentId: `PAY-${Date.now()}`,
      amount,
      status: 'SUCCESS',
    })
  }),

  // 네트워크 에러 시뮬레이션
  http.get('/api/unstable', () => {
    if (Math.random() > 0.7) {
      return HttpResponse.error()
    }
    return HttpResponse.json({ data: 'success' })
  }),
]

3.6 브라우저 환경 설정

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

export const worker = setupWorker(...handlers)
// src/main.tsx (React 앱 진입점)
async function enableMocking() {
  if (process.env.NODE_ENV !== 'development') {
    return
  }

  const { worker } = await import('./mocks/browser')
  return worker.start({
    onUnhandledRequest: 'bypass' // 처리되지 않은 요청은 그대로 통과
  })
}

enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  )
})

3.7 Node.js 환경 설정 (테스트)

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

export const server = setupServer(...handlers)
// jest.setup.ts
import { server } from './src/mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// 테스트에서 핸들러 오버라이드
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'

test('API 에러 처리 테스트', async () => {
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.json({ error: 'Server Error' }, { status: 500 })
    })
  )

  const result = await fetchUsers()
  expect(result.error).toBe('Server Error')
})

3.8 Next.js에서 MSW 설정

// next.config.js
const nextConfig = {
  // Service Worker를 위한 헤더 설정
  async headers() {
    return [
      {
        source: '/mockServiceWorker.js',
        headers: [{ key: 'Service-Worker-Allowed', value: '/' }],
      },
    ]
  },
}
// src/app/providers.tsx (App Router)
'use client'

import { useEffect } from 'react'

export function MSWProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
      import('../mocks/browser').then(({ worker }) => {
        worker.start({ onUnhandledRequest: 'bypass' })
      })
    }
  }, [])

  return children
}

4. WireMock - Java/JVM 환경

WireMock은 Java/JVM 생태계에서 가장 널리 사용되는 HTTP 모의 서버입니다.

4.1 WireMock Standalone 서버 실행

# WireMock 다운로드
curl -o wiremock.jar https://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-jre8-standalone/2.35.0/wiremock-jre8-standalone-2.35.0.jar

# 실행
java -jar wiremock.jar --port 8080 --verbose

디렉토리 구조:

wiremock/
├── mappings/          # Stub 정의 JSON 파일
│   ├── users.json
│   └── orders.json
└── __files/           # 응답 파일 (body 파일)
    ├── users.json
    └── order-template.json

4.2 JSON 파일로 Stub 정의

{
  "request": {
    "method": "GET",
    "url": "/api/users"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "jsonBody": [
      { "id": 1, "name": "홍길동", "email": "hong@example.com" },
      { "id": 2, "name": "이순신", "email": "lee@example.com" }
    ]
  }
}

4.3 Request Matching (URL, headers, body)

{
  "request": {
    "method": "POST",
    "url": "/api/orders",
    "headers": {
      "Content-Type": {
        "equalTo": "application/json"
      },
      "Authorization": {
        "matches": "Bearer .+"
      }
    },
    "bodyPatterns": [
      {
        "matchesJsonPath": "$.items[*].productId"
      },
      {
        "equalToJson": {
          "userId": 1
        },
        "ignoreArrayOrder": true,
        "ignoreExtraElements": true
      }
    ]
  },
  "response": {
    "status": 201,
    "jsonBody": {
      "orderId": "ORDER-001",
      "status": "PENDING"
    }
  }
}

4.4 Response Templating (Handlebars)

{
  "request": {
    "method": "GET",
    "urlPathPattern": "/api/users/([0-9]+)"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "body": "{ \"id\": {{request.pathSegments.[2]}}, \"name\": \"사용자-{{request.pathSegments.[2]}}\", \"timestamp\": \"{{now}}\" }",
    "transformers": ["response-template"]
  }
}

4.5 Scenarios (상태 기반 모킹)

시나리오를 사용하면 상태에 따라 다른 응답을 반환할 수 있습니다:

{
  "scenarioName": "주문 처리 흐름",
  "requiredScenarioState": "Started",
  "newScenarioState": "결제 완료",
  "request": {
    "method": "POST",
    "url": "/api/orders/pay"
  },
  "response": {
    "status": 200,
    "jsonBody": { "status": "PAID" }
  }
}
{
  "scenarioName": "주문 처리 흐름",
  "requiredScenarioState": "결제 완료",
  "newScenarioState": "배송 중",
  "request": {
    "method": "GET",
    "url": "/api/orders/ORDER-001"
  },
  "response": {
    "status": 200,
    "jsonBody": { "status": "SHIPPING" }
  }
}

4.6 Record and Playback

실제 API 응답을 기록하고 재생하는 기능:

# 기록 모드로 실행 (실제 API로 프록시)
java -jar wiremock.jar --proxy-all="https://api.example.com" --record-mappings --port 8080

# 기록된 응답 재생 (기본 모드)
java -jar wiremock.jar --port 8080

5. Prism - OpenAPI 기반 모의 서버

Prism은 OpenAPI(Swagger) 스펙에서 자동으로 모의 서버를 생성하는 도구입니다.

5.1 설치와 실행

npm install -g @stoplight/prism-cli

# OpenAPI 스펙으로 모의 서버 실행
npx @stoplight/prism-cli mock api.yaml --port 4010

5.2 OpenAPI 스펙 예시

openapi: 3.0.0
info:
  title: User API
  version: 1.0.0
paths:
  /users:
    get:
      operationId: getUsers
      responses:
        '200':
          description: 사용자 목록
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
              examples:
                default:
                  value:
                    - id: 1
                      name: 홍길동
                      email: hong@example.com
    post:
      operationId: createUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: 사용자 생성됨
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
components:
  schemas:
    User:
      type: object
      required: [id, name, email]
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
          format: email
    CreateUserRequest:
      type: object
      required: [name, email]
      properties:
        name:
          type: string
        email:
          type: string
          format: email

5.3 동적 응답 생성

Prism은 스펙에서 예시값이 없으면 스키마를 기반으로 자동으로 값을 생성합니다:

# 동적 생성 모드 (스키마 기반 랜덤 응답)
npx @stoplight/prism-cli mock api.yaml --dynamic

5.4 Validation 모드

# Validation 모드 - 요청/응답을 스펙과 대조 검증
npx @stoplight/prism-cli proxy api.yaml https://api.example.com --validate-request --validate-response

6. Go로 모의 서버 구현

Go 표준 라이브러리만으로 완전한 모의 서버를 구현할 수 있습니다.

6.1 기본 모의 서버 구조

package mockserver

import (
    "encoding/json"
    "log"
    "net/http"
    "sync"
    "time"
)

type MockServer struct {
    server   *http.Server
    mux      *http.ServeMux
    mu       sync.RWMutex
    stubs    map[string]*Stub
    calls    []RequestLog
}

type Stub struct {
    Method      string
    Path        string
    StatusCode  int
    Body        interface{}
    Headers     map[string]string
    Delay       time.Duration
}

type RequestLog struct {
    Method    string
    Path      string
    Headers   map[string][]string
    Body      []byte
    Timestamp time.Time
}

func NewMockServer(port string) *MockServer {
    ms := &MockServer{
        mux:   http.NewServeMux(),
        stubs: make(map[string]*Stub),
    }

    ms.server = &http.Server{
        Addr:    ":" + port,
        Handler: ms.mux,
    }

    ms.mux.HandleFunc("/", ms.handleRequest)

    // 테스트 제어 엔드포인트
    ms.mux.HandleFunc("/__admin/stubs", ms.handleAdminStubs)
    ms.mux.HandleFunc("/__admin/calls", ms.handleAdminCalls)
    ms.mux.HandleFunc("/__admin/reset", ms.handleAdminReset)

    return ms
}

func (ms *MockServer) Start() {
    go func() {
        log.Printf("MockServer starting on %s", ms.server.Addr)
        if err := ms.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("MockServer failed: %v", err)
        }
    }()
    // 서버 시작 대기
    time.Sleep(100 * time.Millisecond)
}

func (ms *MockServer) Stop() {
    ms.server.Close()
}

func (ms *MockServer) AddStub(stub *Stub) {
    ms.mu.Lock()
    defer ms.mu.Unlock()
    key := stub.Method + ":" + stub.Path
    ms.stubs[key] = stub
}

func (ms *MockServer) handleRequest(w http.ResponseWriter, r *http.Request) {
    // 요청 로깅
    ms.mu.Lock()
    ms.calls = append(ms.calls, RequestLog{
        Method:    r.Method,
        Path:      r.URL.Path,
        Headers:   r.Header,
        Timestamp: time.Now(),
    })
    ms.mu.Unlock()

    // Stub 조회
    ms.mu.RLock()
    key := r.Method + ":" + r.URL.Path
    stub, ok := ms.stubs[key]
    ms.mu.RUnlock()

    if !ok {
        http.Error(w, "No stub found for "+r.Method+" "+r.URL.Path, http.StatusNotFound)
        return
    }

    // 응답 지연 시뮬레이션
    if stub.Delay > 0 {
        time.Sleep(stub.Delay)
    }

    // 헤더 설정
    for k, v := range stub.Headers {
        w.Header().Set(k, v)
    }
    if w.Header().Get("Content-Type") == "" {
        w.Header().Set("Content-Type", "application/json")
    }

    w.WriteHeader(stub.StatusCode)

    if stub.Body != nil {
        json.NewEncoder(w).Encode(stub.Body)
    }
}

func (ms *MockServer) handleAdminReset(w http.ResponseWriter, r *http.Request) {
    ms.mu.Lock()
    ms.stubs = make(map[string]*Stub)
    ms.calls = nil
    ms.mu.Unlock()
    w.WriteHeader(http.StatusOK)
}

func (ms *MockServer) handleAdminCalls(w http.ResponseWriter, r *http.Request) {
    ms.mu.RLock()
    defer ms.mu.RUnlock()
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(ms.calls)
}

func (ms *MockServer) handleAdminStubs(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodPost {
        var stub Stub
        if err := json.NewDecoder(r.Body).Decode(&stub); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        ms.AddStub(&stub)
        w.WriteHeader(http.StatusCreated)
        return
    }

    ms.mu.RLock()
    defer ms.mu.RUnlock()
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(ms.stubs)
}

6.2 테스트에서 모의 서버 사용

func TestUserService_GetUser(t *testing.T) {
    // 모의 서버 시작
    ms := NewMockServer("9999")
    ms.Start()
    defer ms.Stop()

    // Stub 설정
    ms.AddStub(&Stub{
        Method:     "GET",
        Path:       "/api/users/1",
        StatusCode: 200,
        Body: map[string]interface{}{
            "id":    1,
            "name":  "홍길동",
            "email": "hong@example.com",
        },
    })

    // 테스트 대상 서비스 (모의 서버 URL 주입)
    client := NewUserServiceClient("http://localhost:9999")
    user, err := client.GetUser(1)

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if user.Name != "홍길동" {
        t.Errorf("expected 홍길동, got %s", user.Name)
    }
}

func TestUserService_GetUser_NotFound(t *testing.T) {
    ms := NewMockServer("9998")
    ms.Start()
    defer ms.Stop()

    ms.AddStub(&Stub{
        Method:     "GET",
        Path:       "/api/users/999",
        StatusCode: 404,
        Body:       map[string]string{"error": "User not found"},
    })

    client := NewUserServiceClient("http://localhost:9998")
    _, err := client.GetUser(999)

    if err == nil {
        t.Error("expected error, got nil")
    }
}

7. TypeScript/Node.js로 모의 서버 구현

7.1 Express.js + TypeScript 모의 서버

// mock-server/index.ts
import express, { Request, Response, NextFunction } from 'express'
import fs from 'fs'
import path from 'path'

interface StubDefinition {
  method: string
  path: string
  statusCode: number
  body?: unknown
  headers?: Record<string, string>
  delay?: number
}

const app = express()
app.use(express.json())

const stubs: Map<string, StubDefinition> = new Map()
const requestLog: Array<{ method: string; path: string; timestamp: string }> = []

// 요청 로깅 미들웨어
app.use((req: Request, res: Response, next: NextFunction) => {
  requestLog.push({
    method: req.method,
    path: req.path,
    timestamp: new Date().toISOString(),
  })
  next()
})

// 관리 API
app.post('/__admin/stubs', (req: Request, res: Response) => {
  const stub: StubDefinition = req.body
  const key = `${stub.method}:${stub.path}`
  stubs.set(key, stub)
  res.status(201).json({ message: 'Stub registered', key })
})

app.delete('/__admin/stubs', (req: Request, res: Response) => {
  stubs.clear()
  requestLog.length = 0
  res.status(200).json({ message: 'All stubs cleared' })
})

app.get('/__admin/calls', (req: Request, res: Response) => {
  res.json(requestLog)
})

// 동적 응답 핸들러
app.use(async (req: Request, res: Response) => {
  const key = `${req.method}:${req.path}`
  const stub = stubs.get(key)

  if (!stub) {
    return res.status(404).json({
      error: 'No stub found',
      request: { method: req.method, path: req.path },
    })
  }

  // 응답 지연
  if (stub.delay && stub.delay > 0) {
    await new Promise((resolve) => setTimeout(resolve, stub.delay))
  }

  // 헤더 설정
  if (stub.headers) {
    Object.entries(stub.headers).forEach(([key, value]) => {
      res.setHeader(key, value)
    })
  }

  res.status(stub.statusCode).json(stub.body)
})

const PORT = process.env.MOCK_PORT ?? '3001'
app.listen(PORT, () => {
  console.log(`Mock server running on port ${PORT}`)
})

export { app }

7.2 파일 기반 응답 라우팅

// 응답 파일을 디렉토리 구조로 관리
// responses/
//   GET/
//     api/
//       users/
//         index.json    -> GET /api/users
//         1.json        -> GET /api/users/1
//   POST/
//     api/
//       users/
//         index.json    -> POST /api/users (응답 템플릿)

app.use((req: Request, res: Response) => {
  const responsePath = path.join(__dirname, 'responses', req.method, req.path, 'index.json')

  if (fs.existsSync(responsePath)) {
    const responseData = JSON.parse(fs.readFileSync(responsePath, 'utf-8'))
    res.json(responseData)
  } else {
    res.status(404).json({ error: 'No response file found' })
  }
})

7.3 미들웨어로 지연, 에러 주입

// chaos-middleware.ts - 혼돈 엔지니어링을 위한 미들웨어
interface ChaosConfig {
  errorRate: number // 0.0 ~ 1.0
  minDelay: number // ms
  maxDelay: number // ms
}

let chaosConfig: ChaosConfig = {
  errorRate: 0,
  minDelay: 0,
  maxDelay: 0,
}

export const chaosMiddleware = async (req: Request, res: Response, next: NextFunction) => {
  // 응답 지연
  const delay = chaosConfig.minDelay + Math.random() * (chaosConfig.maxDelay - chaosConfig.minDelay)
  if (delay > 0) {
    await new Promise((resolve) => setTimeout(resolve, delay))
  }

  // 무작위 에러 주입
  if (Math.random() < chaosConfig.errorRate) {
    return res.status(500).json({
      error: 'Chaos monkey strikes!',
      timestamp: new Date().toISOString(),
    })
  }

  next()
}

// 설정 변경 API
app.post('/__admin/chaos', (req: Request, res: Response) => {
  chaosConfig = { ...chaosConfig, ...req.body }
  res.json({ message: 'Chaos config updated', config: chaosConfig })
})

8. Docker Compose로 모의 서버 환경 구성

8.1 docker-compose.yml 예시

version: '3.8'

services:
  # json-server 모의 서버
  json-server:
    image: clue/json-server
    volumes:
      - ./mock-data/db.json:/data/db.json
      - ./mock-data/routes.json:/data/routes.json
    ports:
      - '3001:80'
    command: --watch db.json --routes routes.json
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://localhost/users']
      interval: 10s
      timeout: 5s
      retries: 3

  # WireMock 서버
  wiremock:
    image: wiremock/wiremock:latest
    ports:
      - '8080:8080'
    volumes:
      - ./wiremock/mappings:/home/wiremock/mappings
      - ./wiremock/__files:/home/wiremock/__files
    command:
      - '--verbose'
      - '--global-response-templating'
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://localhost:8080/__admin/health']
      interval: 10s
      timeout: 5s
      retries: 3

  # 실제 애플리케이션 (모의 서버를 의존성으로 사용)
  app:
    build: .
    ports:
      - '8000:8000'
    environment:
      - USER_SERVICE_URL=http://json-server:80
      - PAYMENT_SERVICE_URL=http://wiremock:8080
    depends_on:
      json-server:
        condition: service_healthy
      wiremock:
        condition: service_healthy

  # E2E 테스트 실행
  e2e-tests:
    build:
      context: .
      dockerfile: Dockerfile.test
    environment:
      - APP_URL=http://app:8000
    depends_on:
      - app
    command: npm run test:e2e
    profiles:
      - test

8.2 테스트 실행 스크립트

#!/bin/bash
# run-tests.sh

echo "모의 서버 환경 시작..."
docker-compose up -d json-server wiremock

echo "헬스체크 대기..."
docker-compose exec json-server wget -qO- http://localhost/users
docker-compose exec wiremock wget -qO- http://localhost:8080/__admin/health

echo "애플리케이션 시작..."
docker-compose up -d app

echo "E2E 테스트 실행..."
docker-compose run --rm e2e-tests

echo "정리..."
docker-compose down

8.3 모의 데이터 초기화 스크립트

// scripts/setup-mock-data.ts
const WIREMOCK_URL = 'http://localhost:8080/__admin/mappings'

const stubs = [
  {
    request: {
      method: 'POST',
      url: '/api/payment/charge',
    },
    response: {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
      jsonBody: {
        paymentId: 'TEST-PAYMENT-001',
        status: 'SUCCESS',
        amount: 10000,
      },
    },
  },
  {
    request: {
      method: 'POST',
      url: '/api/payment/charge',
      headers: {
        'X-Simulate-Failure': { equalTo: 'true' },
      },
    },
    response: {
      status: 402,
      headers: { 'Content-Type': 'application/json' },
      jsonBody: {
        error: 'PAYMENT_DECLINED',
        message: '카드가 거절되었습니다',
      },
    },
  },
]

async function setupMockData() {
  for (const stub of stubs) {
    const response = await fetch(WIREMOCK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(stub),
    })
    console.log(`Stub registered: ${response.status}`)
  }
}

setupMockData().catch(console.error)

9. 퀴즈

퀴즈 1: MSW(Mock Service Worker)가 다른 모킹 방법과 다른 핵심적인 차이점은 무엇인가요?

정답: MSW는 Service Worker를 활용해 네트워크 레벨에서 요청을 가로채기 때문에, 애플리케이션 코드를 수정하지 않고도 실제 네트워크 요청을 인터셉트할 수 있습니다. 또한 브라우저와 Node.js 양쪽에서 동일한 핸들러를 재사용할 수 있습니다.

설명: axios-mock-adapter나 fetch-mock 같은 라이브러리는 특정 HTTP 클라이언트 라이브러리를 모킹하므로, HTTP 클라이언트가 바뀌면 모킹 코드도 변경해야 합니다. MSW는 실제 HTTP 스택을 통해 요청이 발생하므로 어떤 HTTP 라이브러리를 사용해도 동일하게 동작합니다.

퀴즈 2: json-server의 가장 큰 제약 사항은 무엇이고, 어떻게 극복할 수 있나요?

정답: json-server의 가장 큰 제약 사항은 복잡한 비즈니스 로직 구현이 어렵다는 점입니다. 단순 CRUD 외의 커스텀 응답 로직이 필요할 때 한계가 있습니다.

설명: 이를 극복하려면 json-server를 라이브러리로 사용하여 커스텀 라우터를 추가(server.post('/auth/login', ...))하거나, 미들웨어를 작성하여 복잡한 로직을 처리할 수 있습니다. 더 복잡한 시나리오가 필요하다면 WireMock이나 직접 구현한 모의 서버로 전환하는 것이 좋습니다.

퀴즈 3: WireMock의 Scenarios 기능은 어떤 상황에서 유용한가요?

정답: Scenarios는 상태에 따라 다른 응답을 반환해야 하는 워크플로우 테스트에 유용합니다. 예를 들어 주문 상태(결제 대기 → 결제 완료 → 배송 중 → 배송 완료)나 재시도 로직(첫 번째 요청은 실패, 두 번째는 성공) 테스트에 적합합니다.

설명: 일반적인 Stub은 항상 동일한 응답을 반환하지만, Scenario를 사용하면 요청이 들어올 때마다 상태가 전이되며 각 상태에 맞는 응답을 반환합니다. 이는 멱등성이 없는 API나 상태 기반 워크플로우를 테스트할 때 필수적입니다.

퀴즈 4: Prism을 사용할 때의 주요 장점은 무엇인가요?

정답: Prism의 주요 장점은 OpenAPI 스펙이 있으면 별도의 모킹 코드 없이 자동으로 모의 서버를 생성한다는 점입니다. 또한 Validation 모드에서 실제 API 요청/응답을 스펙과 대조하여 계약 준수 여부를 자동으로 검증할 수 있습니다.

설명: API 스펙 우선 개발(API-First Design) 방식에서 특히 유용합니다. 백엔드 구현 전에 API 스펙을 먼저 정의하면, 프론트엔드는 Prism으로 즉시 개발을 시작할 수 있고, 백엔드가 완성된 후에는 Validation 모드로 스펙 준수 여부를 검증할 수 있습니다.

퀴즈 5: 모의 서버를 사용할 때 "계약 테스트(Contract Testing)"와 결합해야 하는 이유는 무엇인가요?

정답: 모의 서버는 실제 API와 동기화되지 않을 위험이 있습니다. 계약 테스트를 결합하면 모의 서버가 실제 API와 일치하는지 자동으로 검증할 수 있어 "모의 서버와 실제 API가 달라서 발생하는 버그"를 방지할 수 있습니다.

설명: 모의 서버를 사용해 개발했는데 실제 API가 변경되면, 개발 환경에서는 테스트가 통과하지만 실제 환경에서는 실패하는 문제가 발생할 수 있습니다. Pact와 같은 계약 테스트 도구를 사용하면 Consumer(프론트엔드)가 기대하는 API 계약을 Provider(백엔드)가 준수하는지 자동으로 검증할 수 있습니다.