WireMock

WireMock은 HTTP 기반 API를 위한 시뮬레이션 도구로, Java 환경에서 개발되었으나 다양한 플랫폼에서 활용 가능하다. 톰 애컬턴(Tom Akehurst)이 개발한 이 오픈소스 도구는 실제 서비스와 동일하게 동작하는 모의(Mock) API를 쉽게 구축할 수 있게 해준다.

WireMock의 주요 특징

WireMock의 작동 원리

WireMock은 실제로 두 가지 주요 모드로 작동한다:

독립 실행형 프로세스

별도의 Java 프로세스로 실행되며, HTTP API를 통해 제어된다.
이 방식은 다양한 언어로 개발된 클라이언트를 테스트할 때 유용하다.

1
2
# JAR 파일로 독립 실행
java -jar wiremock-standalone-3.0.0.jar --port 8080

JUnit 통합 라이브러리

Java 테스트 코드 내에 직접 내장되어 사용된다. 이 방식은 Java 개발 환경에서 특히 효율적이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// JUnit 5와 WireMock 통합 예시
@WireMockTest(httpPort = 8080)
public class MyApiClientTest {
    
    @Test
    public void testApiClient() {
        // 스텁 설정
        stubFor(get(urlEqualTo("/api/resource"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"message\":\"Success\"}")));
        
        // 테스트 대상 API 클라이언트 호출
        MyApiClient client = new MyApiClient("http://localhost:8080");
        ApiResponse response = client.getResource();
        
        // 검증
        assertEquals("Success", response.getMessage());
        
        // 요청 검증
        verify(getRequestedFor(urlEqualTo("/api/resource")));
    }
}

WireMock 설치 및 설정

WireMock을 사용하기 위한 설치 방법은 사용 방식에 따라 다양하다.

Java 프로젝트에 추가 (Maven)

1
2
3
4
5
6
7
<!-- pom.xml에 의존성 추가 -->
<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock-jre8</artifactId>
    <version>2.35.0</version>
    <scope>test</scope>
</dependency>

독립 실행형으로 설치

1
2
3
4
5
# 최신 버전 다운로드
wget https://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-jre8-standalone/2.35.0/wiremock-jre8-standalone-2.35.0.jar -O wiremock.jar

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

Docker로 실행

1
2
# Docker로 WireMock 실행
docker run -it --rm -p 8080:8080 wiremock/wiremock:2.35.0

WireMock 기본 사용법

WireMock의 핵심 기능은 HTTP 요청을 매칭하고 적절한 응답을 반환하는 것이다.

이를 위한 기본적인 구성 요소를 살펴보면:

스텁 정의

스텁(stub)은 특정 요청 패턴에 대해 어떤 응답을 반환할지 정의한다.

Java 코드를 사용한 스텁 정의:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// GET 요청에 대한 스텁 설정
stubFor(get(urlEqualTo("/api/users"))
    .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("[{\"id\":1,\"name\":\"홍길동\"},{\"id\":2,\"name\":\"김철수\"}]")));

// POST 요청에 대한 스텁 설정
stubFor(post(urlEqualTo("/api/users"))
    .withRequestBody(containing("\"name\":"))
    .willReturn(aResponse()
        .withStatus(201)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"id\":3,\"name\":\"새사용자\",\"created\":true}")));

JSON 파일을 사용한 스텁 정의 (독립 실행형):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// mappings/users-get.json
{
  "request": {
    "method": "GET",
    "url": "/api/users"
  },
  "response": {
    "status": 200,
    "headers": {
      "Content-Type": "application/json"
    },
    "body": "[{\"id\":1,\"name\":\"홍길동\"},{\"id\":2,\"name\":\"김철수\"}]"
  }
}

요청 패턴 매칭

WireMock은 다양한 방식으로 요청을 매칭할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// URL 정확히 일치
urlEqualTo("/exact/path")

// URL 패턴 일치
urlPathMatching("/path/[a-z]+")

// 쿼리 파라미터 포함
urlPathEqualTo("/api/search").withQueryParam("q", equalTo("keyword"))

// 헤더 매칭
withHeader("Content-Type", containing("application/json"))

// 쿠키 매칭
withCookie("session", matching(".*"))

// 본문 매칭
withRequestBody(equalToJson("{\"key\":\"value\"}"))
withRequestBody(matchingJsonPath("$.user.name"))

응답 설정

다양한 HTTP 응답 특성을 설정할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 기본 응답 설정
aResponse()
    .withStatus(200)                            // 상태 코드
    .withStatusMessage("OK")                    // 상태 메시지
    .withHeader("Content-Type", "text/plain")   // 헤더
    .withBody("Hello, World!")                  // 본문
    .withFixedDelay(1500)                       // 지연 시간(ms)

// 파일로부터 응답 본문 로드
aResponse().withBodyFile("response.json")       // __files 디렉토리에서 로드

// 이진 응답 설정
aResponse().withBody(new byte[] { ... })

WireMock 고급 기능

WireMock은 단순한 요청-응답 매칭을 넘어 다양한 고급 기능을 제공한다.

응답 템플릿팅

Handlebars 기반 템플릿 엔진을 사용하여 동적 응답을 생성할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 템플릿 기반 응답
stubFor(get(urlPathMatching("/api/users/([0-9]+)"))
    .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("{\n" +
                "  \"id\": {{request.pathSegments.[2]}},\n" +
                "  \"name\": \"사용자{{request.pathSegments.[2]}}\",\n" +
                "  \"requestTime\": \"{{now}}\"\n" +
                "}")));

이 예시에서는:

상태 기반 동작

WireMock은 상태(Scenario)를 통해 순차적으로 변화하는 동작을 시뮬레이션할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 시나리오 기반 스텁
stubFor(get(urlEqualTo("/api/resource"))
    .inScenario("리소스 수정 시나리오")
    .whenScenarioStateIs(Scenario.STARTED)
    .willReturn(aResponse()
        .withBody("{\"status\":\"초기 상태\"}"))
    .willSetStateTo("업데이트됨"));

stubFor(get(urlEqualTo("/api/resource"))
    .inScenario("리소스 수정 시나리오")
    .whenScenarioStateIs("업데이트됨")
    .willReturn(aResponse()
        .withBody("{\"status\":\"업데이트된 상태\"}")));

이 예시에서는 첫 번째 호출과 두 번째 호출이 서로 다른 응답을 반환한다.

프록시 모드

실제 서버와 모킹을 결합하여 사용할 수 있다:

1
2
3
4
5
6
7
8
9
// 기본 프록시 설정
stubFor(get(urlMatching(".*"))
    .atPriority(10)
    .willReturn(aResponse().proxiedFrom("https://api.real-server.com")));

// 특정 패턴만 모킹하고 나머지는 프록시
stubFor(get(urlEqualTo("/api/mocked"))
    .atPriority(1)  // 우선순위 높음
    .willReturn(aResponse().withBody("Mocked response")));

응답 레코딩

실제 API의 응답을 기록하여 모킹 데이터로 활용할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 레코딩 시작
WireMock.startRecording(
    recordSpec()
        .forTarget("https://api.real-server.com")
        .extractBinaryBodiesOver(10240)
        .extractTextBodiesOver(2048)
        .makeStubsPersistent(true)
        .ignoreRepeatRequests()
);

// 클라이언트로 요청 수행...

// 레코딩 중지
StubMapping[] recordings = WireMock.stopRecording();

장애 시뮬레이션

다양한 네트워크 문제와 장애 상황을 시뮬레이션할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 응답 지연
aResponse().withFixedDelay(3000)  // 3초 지연

// 랜덤 지연
aResponse().withUniformRandomDelay(1000, 5000)  // 1~5초 랜덤 지연

// 연결 끊김 시뮬레이션
aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)

// 손상된 응답
aResponse().withFault(Fault.MALFORMED_RESPONSE_CHUNK)

// 요청 비율 제한
stubFor(get(urlEqualTo("/api/limited"))
    .atPriority(1)
    .inScenario("요청 제한")
    .whenScenarioStateIs(Scenario.STARTED)
    .willReturn(aResponse().withStatus(200))
    .willSetStateTo("제한됨"));

stubFor(get(urlEqualTo("/api/limited"))
    .atPriority(1)
    .inScenario("요청 제한")
    .whenScenarioStateIs("제한됨")
    .willReturn(aResponse().withStatus(429))
    .willSetStateTo(Scenario.STARTED));

WireMock 확장 및 플러그인

WireMock은 확장 가능한 아키텍처를 가지고 있어 다양한 플러그인을 통해 기능을 확장할 수 있다.

커스텀 요청 매처

특별한 요청 매칭 로직이 필요한 경우 커스텀 매처를 구현할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 커스텀 요청 매처 구현
public class CustomHeaderMatcher extends RequestMatcherExtension {
    @Override
    public MatchResult match(Request request, Parameters parameters) {
        String headerValue = request.getHeader("X-Custom-Header");
        String expectedPattern = parameters.getString("expectedPattern");
        
        boolean matches = Pattern.matches(expectedPattern, headerValue);
        return MatchResult.of(matches);
    }
}

// 사용 방법
stubFor(requestMatching(new CustomHeaderMatcher(), 
                       Parameters.one("expectedPattern", "value-\\d+"))
        .willReturn(aResponse().withStatus(200)));

응답 변환기

응답을 동적으로 변환하는 커스텀 로직을 구현할 수 있다:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 커스텀 응답 변환기 구현
public class DateStampTransformer extends ResponseTransformerV2 {
    @Override
    public Response transform(Request request, Response response, FileSource files, Parameters parameters) {
        String body = response.getBodyAsString();
        String transformed = body.replace("{{currentDate}}", 
                             LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE));
        
        return Response.Builder.like(response)
                .but().body(transformed)
                .build();
    }

    @Override
    public String getName() {
        return "date-stamp";
    }
}

// 사용 방법
stubFor(get("/api/with-date")
        .willReturn(aResponse()
            .withBody("{\"date\":\"{{currentDate}}\"}")
            .withTransformerParameter("transformerName", "date-stamp")));

주요 공식 확장 모듈

WireMock 에코시스템에는 다양한 확장 모듈이 있다:

WireMock 실제 적용 사례

WireMock이 실제 개발 및 테스트 환경에서 어떻게 활용되는지 살펴보면.

단위 테스트에서의 활용

Java JUnit 테스트에서 외부 API 의존성을 모킹:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@WireMockTest(httpPort = 8080)
public class WeatherServiceTest {
    
    private WeatherService weatherService;
    
    @BeforeEach
    void setup() {
        weatherService = new WeatherService("http://localhost:8080");
    }
    
    @Test
    void testGetCurrentWeather() {
        // 날씨 API 응답 모킹
        stubFor(get(urlEqualTo("/api/weather?city=seoul"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"city\":\"Seoul\",\"temperature\":25,\"condition\":\"Sunny\"}")));
        
        // 테스트 대상 메서드 호출
        Weather weather = weatherService.getCurrentWeather("seoul");
        
        // 검증
        assertEquals("Seoul", weather.getCity());
        assertEquals(25, weather.getTemperature());
        assertEquals("Sunny", weather.getCondition());
        
        // API 호출 검증
        verify(getRequestedFor(urlEqualTo("/api/weather?city=seoul")));
    }
    
    @Test
    void testWeatherApiError() {
        // API 오류 시뮬레이션
        stubFor(get(urlEqualTo("/api/weather?city=unknown"))
            .willReturn(aResponse()
                .withStatus(404)
                .withBody("{\"error\":\"City not found\"}")));
        
        // 예외 발생 검증
        WeatherApiException exception = assertThrows(WeatherApiException.class, () -> {
            weatherService.getCurrentWeather("unknown");
        });
        
        assertEquals("City not found", exception.getMessage());
    }
}

통합 테스트에서의 활용

Spring Boot 애플리케이션에서 외부 서비스 모킹:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@SpringBootTest
@AutoConfigureWireMock(port = 0)  // 랜덤 포트 사용
class PaymentIntegrationTest {

    @Autowired
    private PaymentService paymentService;
    
    @Value("${wiremock.server.port}")
    private int wiremockPort;
    
    @BeforeEach
    void setup() {
        // 외부 결제 게이트웨이 URL 설정
        ReflectionTestUtils.setField(paymentService, "paymentGatewayUrl", 
                                   "http://localhost:" + wiremockPort + "/api");
    }
    
    @Test
    void testProcessPayment() {
        // 결제 게이트웨이 응답 모킹
        stubFor(post(urlEqualTo("/api/payments"))
            .withRequestBody(matchingJsonPath("$.amount", equalTo("1000")))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBody("{\"transactionId\":\"tx123\",\"status\":\"APPROVED\"}")));
        
        // 결제 처리
        PaymentResult result = paymentService.processPayment("order123", 1000);
        
        // 검증
        assertEquals("tx123", result.getTransactionId());
        assertEquals(PaymentStatus.APPROVED, result.getStatus());
        
        // 결제 요청 검증
        verify(postRequestedFor(urlEqualTo("/api/payments"))
            .withRequestBody(matchingJsonPath("$.orderId", equalTo("order123")))
            .withHeader("Content-Type", containing("application/json")));
    }
}

성능 테스트에서의 활용

JMeter 또는 Gatling과 같은 성능 테스트 도구와 함께 사용:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// WireMock 서버 설정 (독립 실행형)
WireMockServer wireMockServer = new WireMockServer(wireMockConfig()
    .port(8080)
    .jettyAcceptors(4)     // 동시 연결 처리 향상
    .jettyAcceptQueueSize(100)
    .asynchronousResponseEnabled(true)
    .containerThreads(100)); // 컨테이너 스레드 수 증가

wireMockServer.start();

// 대량 테스트를 위한 응답 설정
wireMockServer.stubFor(get(urlMatching("/api/products/.*"))
    .willReturn(aResponse()
        .withStatus(200)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"id\":\"{{request.pathSegments.[2]}}\",\"name\":\"Product {{request.pathSegments.[2]}}\",\"price\":1000}")
        .withFixedDelay(5))); // 5ms 지연

// 성능 테스트 실행 후...

wireMockServer.stop();

계약 테스트에서의 활용

소비자-제공자 계약 테스트(Consumer-Driven Contract Testing)에서 WireMock 활용:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 소비자 측 테스트
@WireMockTest(httpPort = 8080)
public class ProductClientContractTest {
    
    @Test
    void testGetProductContract() {
        // 계약에 따른 스텁 설정
        stubFor(get(urlEqualTo("/api/products/1"))
            .willReturn(aResponse()
                .withStatus(200)
                .withHeader("Content-Type", "application/json")
                .withBodyFile("contract/product-1.json")));
        
        // 클라이언트 테스트
        ProductClient client = new ProductClient("http://localhost:8080");
        Product product = client.getProduct(1);
        
        // 계약 검증
        assertEquals(1, product.getId());
        assertEquals("스마트폰", product.getName());
        assertNotNull(product.getDescription());
        assertTrue(product.getPrice() > 0);
    }
}

WireMock 베스트 프랙티스

프로젝트 구조 최적화

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
src/
└── test/
    ├── java/                 # 테스트 코드
    └── resources/
        └── wiremock/
            ├── mappings/     # 스텁 매핑 JSON 파일
            │   ├── users-get.json
            │   └── ...
            └── __files/      # 응답 본문 파일
                ├── users.json
                └── ...

테스트 격리

각 테스트가 서로 영향을 주지 않도록 격리하는 방법:

1
2
3
4
5
6
7
8
// 테스트 메서드마다 WireMock 상태 초기화
@BeforeEach
void setup() {
    WireMock.reset();
}

// 테스트 클래스마다 별도의 WireMock 서버 사용
@WireMockTest(httpPort = 0)  // 랜덤 포트 사용

실제 API와의 동기화

실제 API와 모킹을 항상 동기화하는 방법:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// API 응답 레코딩 자동화
@Test
void recordRealApiResponses() throws Exception {
    WireMock.startRecording(
        recordSpec()
            .forTarget("https://api.real-service.com")
            .captureHeader("Accept")
            .captureHeader("Content-Type")
            .extractBinaryBodiesOver(10240)
            .extractTextBodiesOver(2048)
            .makeStubsPersistent(true)
            .ignoreRepeatRequests()
            .matchRequestBodyWithEqualToJson(true, true)
    );
    
    // 모든 필요한 API 엔드포인트 호출
    apiClient.getUsers();
    apiClient.getUser(1);
    apiClient.createUser(new User("New User"));
    // ...
    
    WireMock.stopRecording();
}

상태 관리 최소화

시나리오와 상태를 최소화하여 테스트 복잡성 관리:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 가능하면 상태 없는(stateless) 스텁 사용
stubFor(get(urlPathMatching("/api/users/([0-9]+)"))
    .willReturn(aResponse()
        .withTransformers("user-transformer"))); // 상태 대신 변환기 사용

// 상태 사용 시 명확하게 리셋 처리
@AfterEach
void resetScenarios() {
    WireMock.resetAllScenarios();
}

최적화

대규모 테스트에서 WireMock 성능 최적화:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
WireMockConfiguration config = wireMockConfig()
    .port(8080)
    .jettyAcceptors(2)
    .jettyAcceptQueueSize(100)
    .containerThreads(50)
    .asynchronousResponseEnabled(true)
    .maxRequestJournalEntries(100)  // 요청 저널 크기 제한
    .disableRequestJournal()        // 대규모 테스트에서는 저널 비활성화
    .networkTrafficListener(new ConsoleNotifier(true));

WireMockServer server = new WireMockServer(config);

11. WireMock의 한계와 대안

모든 도구에는 한계가 있다.

WireMock의 한계

  1. 복잡한 동적 응답 생성: 매우 복잡한 비즈니스 로직이 필요한 응답 생성에는 한계가 있다.
  2. 실시간 프로토콜 제한: HTTP/HTTPS 외의 프로토콜(예: WebSocket, gRPC) 지원에 제한이 있다.
  3. 대규모 환경 확장성: 매우 대규모 테스트 환경에서는 성능 병목이 발생할 수 있다.
  4. GUI 부재: 기본적으로 GUI 인터페이스가 없어 비개발자에게 어려울 수 있습니다.

11.2 대안 및 보완 도구

11.2.1 Hoverfly

Java 외 언어 지원 및 더 높은 확장성이 필요한 경우:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Hoverfly Java 예시
try (Hoverfly hoverfly = new Hoverfly(configs().localConfigs(), SIMULATE)) {
    hoverfly.simulate(dsl(
        service("api.example.com")
            .get("/users")
            .willReturn(success("{\"users\":[…]}", "application/json"))));
    
    // 테스트 수행
    // …
}
11.2.2 MockServer

프록시 기능과 높은 확장성이 필요한 경우:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// MockServer 예시
ClientAndServer mockServer = ClientAndServer.startClientAndServer(1080);

mockServer.when(
    request()
        .withMethod("GET")
        .withPath("/users")
).respond(
    response()
        .withStatusCode(200)
        .withHeader("Content-Type", "application/json")
        .withBody("{\"users\":[…]}")
);
11.2.3 Mountebank

다중 프로토콜 지원이 필요한 경우:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Mountebank 예시 (JavaScript)
const mb = require('mountebank');
const settings = {
    port: 2525,
    protoports: { http: 8080 },
    ipWhitelist: ['*']
};

mb.create(settings).then(() => {
    const stubs = [{
        predicates: [{ equals: { path: '/users' } }],
        responses: [{ is: { statusCode: 200, body: '{"users":[…]}' } }]
    }];
    
    mb.post('/imposters', {
        protocol: 'http',
        port: 8080,
        stubs: stubs
    });
});

용어 정리

용어설명

참고 및 출처