Skip to content

✍️ 필사 모드: 모의 서버(Mock Server) 구축 완벽 가이드

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

개요

현대 소프트웨어 개발에서 프론트엔드와 백엔드는 대부분 별도 팀이 동시에 작업합니다. 백엔드 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(백엔드)가 준수하는지 자동으로 검증할 수 있습니다.

현재 단락 (1/877)

현대 소프트웨어 개발에서 프론트엔드와 백엔드는 대부분 별도 팀이 동시에 작업합니다. 백엔드 API가 완성될 때까지 프론트엔드 개발이 블로킹되거나, 외부 서드파티 API에 의존하는 ...

작성 글자: 0원문 글자: 20,737작성 단락: 0/877