Spring Cloud Gateway: 테스트 전략

Spring Cloud Gateway는 리액티브 스택 위에서 동작하므로 테스트 방식도 일반 Spring MVC와 다르다. 단위 테스트부터 통합 테스트까지 계층별로 어떻게 검증할지 정리한다.


1. 테스트 전략 개요

Gateway 테스트는 크게 두 가지 계층으로 나눌 수 있다.

계층목적도구
단위 테스트Filter, Predicate 로직 개별 검증JUnit 5, Mockito, MockServerWebExchange
통합 테스트전체 Gateway 동작 검증 (라우팅, 필터 체인)@SpringBootTest, WebTestClient, WireMock

단위 테스트는 빠르고 격리된 환경에서 비즈니스 로직을 검증하는 데 적합하다. 통합 테스트는 실제 Gateway가 올바른 하위 서비스를 호출하는지, 필터가 올바른 순서로 적용되는지 확인한다.


2. GatewayFilter 단위 테스트

MockServerWebExchangeMockGatewayFilterChain을 활용하면 실제 서버 없이 필터 로직을 검증할 수 있다.

커스텀 필터 예제 (테스트 대상)

// RequestIdFilter.java
@Component
public class RequestIdFilter implements GlobalFilter, Ordered {
 
    public static final String REQUEST_ID_HEADER = "X-Request-Id";
 
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        if (exchange.getRequest().getHeaders().containsKey(REQUEST_ID_HEADER)) {
            return chain.filter(exchange);
        }
 
        String requestId = UUID.randomUUID().toString();
        ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
                .header(REQUEST_ID_HEADER, requestId)
                .build();
 
        return chain.filter(exchange.mutate().request(mutatedRequest).build());
    }
 
    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

단위 테스트 코드

// RequestIdFilterTest.java
class RequestIdFilterTest {
 
    private final RequestIdFilter filter = new RequestIdFilter();
 
    @Test
    void 요청에_X_Request_Id_헤더가_없으면_자동으로_추가한다() {
        // given
        MockServerHttpRequest request = MockServerHttpRequest
                .get("/api/test")
                .build();
        MockServerWebExchange exchange = MockServerWebExchange.from(request);
 
        List<ServerWebExchange> capturedExchanges = new ArrayList<>();
        GatewayFilterChain chain = capturedExchange -> {
            capturedExchanges.add(capturedExchange);
            return Mono.empty();
        };
 
        // when
        StepVerifier.create(filter.filter(exchange, chain))
                .verifyComplete();
 
        // then
        assertThat(capturedExchanges).hasSize(1);
        String requestId = capturedExchanges.get(0)
                .getRequest()
                .getHeaders()
                .getFirst(RequestIdFilter.REQUEST_ID_HEADER);
 
        assertThat(requestId).isNotNull().isNotBlank();
    }
 
    @Test
    void 요청에_이미_X_Request_Id_헤더가_있으면_그대로_유지한다() {
        // given
        String existingId = "existing-request-id-123";
        MockServerHttpRequest request = MockServerHttpRequest
                .get("/api/test")
                .header(RequestIdFilter.REQUEST_ID_HEADER, existingId)
                .build();
        MockServerWebExchange exchange = MockServerWebExchange.from(request);
 
        List<ServerWebExchange> capturedExchanges = new ArrayList<>();
        GatewayFilterChain chain = capturedExchange -> {
            capturedExchanges.add(capturedExchange);
            return Mono.empty();
        };
 
        // when
        StepVerifier.create(filter.filter(exchange, chain))
                .verifyComplete();
 
        // then
        String requestId = capturedExchanges.get(0)
                .getRequest()
                .getHeaders()
                .getFirst(RequestIdFilter.REQUEST_ID_HEADER);
 
        assertThat(requestId).isEqualTo(existingId);
    }
}

Mockito를 활용한 GatewayFilterChain 모킹

// JwtAuthenticationFilterTest.java
@ExtendWith(MockitoExtension.class)
class JwtAuthenticationFilterTest {
 
    @Mock
    private JwtTokenProvider jwtTokenProvider;
 
    @Mock
    private GatewayFilterChain chain;
 
    private JwtAuthenticationFilter filter;
 
    @BeforeEach
    void setUp() {
        filter = new JwtAuthenticationFilter(jwtTokenProvider);
        when(chain.filter(any())).thenReturn(Mono.empty());
    }
 
    @Test
    void 유효한_JWT로_요청하면_사용자_정보_헤더가_주입된다() {
        // given
        Claims claims = mock(Claims.class);
        when(claims.getSubject()).thenReturn("user-123");
        when(claims.get("roles", List.class)).thenReturn(List.of("ROLE_USER"));
        when(jwtTokenProvider.parseToken("valid-token")).thenReturn(claims);
 
        MockServerHttpRequest request = MockServerHttpRequest
                .get("/api/users/me")
                .header("Authorization", "Bearer valid-token")
                .build();
        MockServerWebExchange exchange = MockServerWebExchange.from(request);
 
        // when
        StepVerifier.create(filter.filter(exchange, chain))
                .verifyComplete();
 
        // then
        ArgumentCaptor<ServerWebExchange> exchangeCaptor =
                ArgumentCaptor.forClass(ServerWebExchange.class);
        verify(chain).filter(exchangeCaptor.capture());
 
        HttpHeaders headers = exchangeCaptor.getValue().getRequest().getHeaders();
        assertThat(headers.getFirst("X-User-Id")).isEqualTo("user-123");
        assertThat(headers.getFirst("X-User-Roles")).isEqualTo("ROLE_USER");
    }
 
    @Test
    void Authorization_헤더가_없으면_401을_반환한다() {
        // given
        MockServerHttpRequest request = MockServerHttpRequest
                .get("/api/users/me")
                .build();
        MockServerWebExchange exchange = MockServerWebExchange.from(request);
 
        // when
        StepVerifier.create(filter.filter(exchange, chain))
                .verifyComplete();
 
        // then
        assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
        verify(chain, never()).filter(any());
    }
}

3. 커스텀 Predicate 단위 테스트

// ClientVersionRoutePredicateFactory.java
public class ClientVersionRoutePredicateFactory
        extends AbstractRoutePredicateFactory<ClientVersionRoutePredicateFactory.Config> {
 
    public ClientVersionRoutePredicateFactory() {
        super(Config.class);
    }
 
    @Override
    public Predicate<ServerWebExchange> apply(Config config) {
        return exchange -> {
            String version = exchange.getRequest()
                    .getHeaders()
                    .getFirst("X-Client-Version");
            if (version == null) return false;
 
            try {
                int clientVersion = Integer.parseInt(version);
                return clientVersion >= config.getMinVersion()
                        && clientVersion <= config.getMaxVersion();
            } catch (NumberFormatException e) {
                return false;
            }
        };
    }
 
    @Data
    public static class Config {
        private int minVersion;
        private int maxVersion;
    }
}
// ClientVersionRoutePredicateFactoryTest.java
class ClientVersionRoutePredicateFactoryTest {
 
    private final ClientVersionRoutePredicateFactory factory =
            new ClientVersionRoutePredicateFactory();
 
    private Predicate<ServerWebExchange> buildPredicate(int min, int max) {
        ClientVersionRoutePredicateFactory.Config config =
                new ClientVersionRoutePredicateFactory.Config();
        config.setMinVersion(min);
        config.setMaxVersion(max);
        return factory.apply(config);
    }
 
    @Test
    void 버전이_범위_내에_있으면_매칭된다() {
        Predicate<ServerWebExchange> predicate = buildPredicate(3, 5);
 
        MockServerWebExchange exchange = MockServerWebExchange.from(
                MockServerHttpRequest.get("/test")
                        .header("X-Client-Version", "4")
                        .build());
 
        assertThat(predicate.test(exchange)).isTrue();
    }
 
    @Test
    void 버전이_최솟값보다_낮으면_매칭되지_않는다() {
        Predicate<ServerWebExchange> predicate = buildPredicate(3, 5);
 
        MockServerWebExchange exchange = MockServerWebExchange.from(
                MockServerHttpRequest.get("/test")
                        .header("X-Client-Version", "2")
                        .build());
 
        assertThat(predicate.test(exchange)).isFalse();
    }
 
    @Test
    void 버전_헤더가_없으면_매칭되지_않는다() {
        Predicate<ServerWebExchange> predicate = buildPredicate(3, 5);
 
        MockServerWebExchange exchange = MockServerWebExchange.from(
                MockServerHttpRequest.get("/test").build());
 
        assertThat(predicate.test(exchange)).isFalse();
    }
}

4. 통합 테스트 설정

의존성

// build.gradle
dependencies {
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock'
    testImplementation 'io.projectreactor:reactor-test'
    testImplementation 'it.ozimov:embedded-redis:0.7.3'
}

통합 테스트 기본 설정

// GatewayIntegrationTestBase.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public abstract class GatewayIntegrationTestBase {
 
    @LocalServerPort
    protected int port;
 
    protected WebTestClient webTestClient;
 
    @Autowired
    protected ApplicationContext applicationContext;
 
    // WireMock 서버 (하위 서비스 시뮬레이션)
    protected static WireMockServer userServiceMock;
    protected static WireMockServer orderServiceMock;
 
    @BeforeAll
    static void startMocks() {
        userServiceMock = new WireMockServer(WireMockConfiguration.options().dynamicPort());
        orderServiceMock = new WireMockServer(WireMockConfiguration.options().dynamicPort());
        userServiceMock.start();
        orderServiceMock.start();
    }
 
    @AfterAll
    static void stopMocks() {
        userServiceMock.stop();
        orderServiceMock.stop();
    }
 
    @BeforeEach
    void setUpWebTestClient() {
        webTestClient = WebTestClient
                .bindToServer()
                .baseUrl("http://localhost:" + port)
                .responseTimeout(Duration.ofSeconds(10))
                .build();
    }
 
    @AfterEach
    void resetMocks() {
        userServiceMock.resetAll();
        orderServiceMock.resetAll();
    }
}

application-test.yml

# src/test/resources/application-test.yml
spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: http://localhost:${wiremock.user-service.port}
          predicates:
            - Path=/api/users/**
          filters:
            - RewritePath=/api/users/(?<segment>.*), /users/${segment}
 
        - id: order-service
          uri: http://localhost:${wiremock.order-service.port}
          predicates:
            - Path=/api/orders/**
          filters:
            - RewritePath=/api/orders/(?<segment>.*), /orders/${segment}
 
  redis:
    host: localhost
    port: 6370   # Embedded Redis 포트

WireMock 포트를 Spring 설정에 주입

// WireMockPortInitializer.java
public class WireMockPortInitializer
        implements ApplicationContextInitializer<ConfigurableApplicationContext> {
 
    @Override
    public void initialize(ConfigurableApplicationContext context) {
        // 테스트 클래스에서 WireMock 서버를 시작한 후 포트를 주입
        // 실제 환경에서는 @DynamicPropertySource 활용
    }
}
// 더 간단한 방법: @DynamicPropertySource
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class GatewayRoutingTest {
 
    static WireMockServer userServiceMock = new WireMockServer(
            WireMockConfiguration.options().dynamicPort());
 
    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        userServiceMock.start();
        registry.add("wiremock.user-service.port", userServiceMock::port);
    }
    // ...
}

5. 라우트 통합 테스트

경로 매칭 및 필터 동작 검증

// GatewayRoutingIntegrationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class GatewayRoutingIntegrationTest {
 
    @LocalServerPort
    int port;
 
    static WireMockServer userServiceMock;
    static WireMockServer orderServiceMock;
 
    WebTestClient webTestClient;
 
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        userServiceMock = new WireMockServer(WireMockConfiguration.options().dynamicPort());
        orderServiceMock = new WireMockServer(WireMockConfiguration.options().dynamicPort());
        userServiceMock.start();
        orderServiceMock.start();
        registry.add("wiremock.user-service.port", userServiceMock::port);
        registry.add("wiremock.order-service.port", orderServiceMock::port);
    }
 
    @BeforeEach
    void setUp() {
        webTestClient = WebTestClient.bindToServer()
                .baseUrl("http://localhost:" + port)
                .build();
    }
 
    @AfterEach
    void reset() {
        userServiceMock.resetAll();
        orderServiceMock.resetAll();
    }
 
    @Test
    void GET_api_users_경로가_user_service로_라우팅된다() {
        // given: 하위 서비스 stubbing
        userServiceMock.stubFor(
                WireMock.get(WireMock.urlPathEqualTo("/users/123"))
                        .willReturn(WireMock.aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody("{\"id\": 123, \"name\": \"홍길동\"}")));
 
        // when & then
        webTestClient.get()
                .uri("/api/users/123")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.id").isEqualTo(123)
                .jsonPath("$.name").isEqualTo("홍길동");
 
        // 하위 서비스가 실제로 호출되었는지 확인
        userServiceMock.verify(1,
                WireMock.getRequestedFor(WireMock.urlPathEqualTo("/users/123")));
    }
 
    @Test
    void RewritePath_필터가_버전_prefix를_제거하고_전달한다() {
        // given
        userServiceMock.stubFor(
                WireMock.get(WireMock.urlPathEqualTo("/users/456"))
                        .willReturn(WireMock.aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody("{\"id\": 456}")));
 
        // when: /v1/users/456 요청
        webTestClient.get()
                .uri("/v1/users/456")
                .exchange()
                .expectStatus().isOk();
 
        // then: 하위 서비스에는 /users/456으로 전달되었는지 확인
        userServiceMock.verify(1,
                WireMock.getRequestedFor(WireMock.urlPathEqualTo("/users/456")));
    }
 
    @Test
    void AddRequestHeader_필터로_헤더가_하위_서비스에_전달된다() {
        // given
        userServiceMock.stubFor(
                WireMock.get(WireMock.urlPathMatching("/users/.*"))
                        .withHeader("X-Gateway-Version", WireMock.equalTo("1.0"))
                        .willReturn(WireMock.aResponse()
                                .withStatus(200)
                                .withBody("{\"id\": 1}")));
 
        // when
        webTestClient.get()
                .uri("/api/users/1")
                .exchange()
                .expectStatus().isOk();
 
        // then: 헤더가 포함된 요청이 왔는지 검증
        userServiceMock.verify(WireMock.getRequestedFor(
                WireMock.urlPathEqualTo("/users/1"))
                .withHeader("X-Gateway-Version", WireMock.equalTo("1.0")));
    }
 
    @Test
    void 존재하지_않는_경로는_404를_반환한다() {
        webTestClient.get()
                .uri("/unknown/path")
                .exchange()
                .expectStatus().isNotFound();
    }
}

6. Circuit Breaker 폴백 테스트

// CircuitBreakerFallbackTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CircuitBreakerFallbackTest {
 
    @LocalServerPort
    int port;
 
    static WireMockServer downstreamMock;
 
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        downstreamMock = new WireMockServer(WireMockConfiguration.options().dynamicPort());
        downstreamMock.start();
        registry.add("wiremock.product-service.port", downstreamMock::port);
    }
 
    WebTestClient webTestClient;
 
    @BeforeEach
    void setUp() {
        webTestClient = WebTestClient.bindToServer()
                .baseUrl("http://localhost:" + port)
                .responseTimeout(Duration.ofSeconds(15))
                .build();
    }
 
    @Test
    void 하위_서비스_5xx_오류_시_폴백_응답을_반환한다() {
        // given: 하위 서비스가 500 에러 반환
        downstreamMock.stubFor(
                WireMock.get(WireMock.urlPathMatching("/products/.*"))
                        .willReturn(WireMock.aResponse()
                                .withStatus(500)
                                .withBody("{\"error\": \"Internal Server Error\"}")));
 
        // when & then: Gateway는 폴백 응답 반환
        webTestClient.get()
                .uri("/api/products/999")
                .exchange()
                .expectStatus().isOk()  // 폴백은 200 반환
                .expectBody()
                .jsonPath("$.message").isEqualTo("서비스를 일시적으로 사용할 수 없습니다.");
    }
 
    @Test
    void 하위_서비스_타임아웃_시_폴백_응답을_반환한다() {
        // given: 하위 서비스가 응답 지연 (타임아웃 유발)
        downstreamMock.stubFor(
                WireMock.get(WireMock.urlPathMatching("/products/.*"))
                        .willReturn(WireMock.aResponse()
                                .withStatus(200)
                                .withFixedDelay(10000)  // 10초 지연
                                .withBody("{}")));
 
        // when & then
        webTestClient.get()
                .uri("/api/products/999")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.message").isEqualTo("서비스를 일시적으로 사용할 수 없습니다.");
    }
}

7. Rate Limit 테스트

Embedded Redis 설정

실제 Redis 없이 테스트하기 위해 Embedded Redis를 사용한다.

// TestRedisConfiguration.java
@TestConfiguration
public class TestRedisConfiguration {
 
    private RedisServer redisServer;
 
    @Value("${spring.redis.port:6370}")
    private int port;
 
    @PostConstruct
    public void startRedis() throws IOException {
        redisServer = new RedisServer(port);
        redisServer.start();
    }
 
    @PreDestroy
    public void stopRedis() {
        if (redisServer != null && redisServer.isActive()) {
            redisServer.stop();
        }
    }
}
# application-test.yml
spring:
  redis:
    host: localhost
    port: 6370
 
  cloud:
    gateway:
      routes:
        - id: rate-limited-api
          uri: lb://some-service
          predicates:
            - Path=/limited/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 2
                redis-rate-limiter.burstCapacity: 2
                key-resolver: "#{@ipKeyResolver}"

Rate Limit 통합 테스트

// RateLimitIntegrationTest.java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Import(TestRedisConfiguration.class)
class RateLimitIntegrationTest {
 
    @LocalServerPort
    int port;
 
    static WireMockServer backendMock;
 
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        backendMock = new WireMockServer(WireMockConfiguration.options().dynamicPort());
        backendMock.start();
        registry.add("wiremock.some-service.port", backendMock::port);
    }
 
    WebTestClient webTestClient;
 
    @BeforeEach
    void setUp() {
        webTestClient = WebTestClient.bindToServer()
                .baseUrl("http://localhost:" + port)
                .build();
 
        // 하위 서비스는 항상 200 반환
        backendMock.stubFor(
                WireMock.get(WireMock.anyUrl())
                        .willReturn(WireMock.aResponse().withStatus(200).withBody("{}")));
    }
 
    @Test
    void 허용_횟수_초과_시_429를_반환한다() throws InterruptedException {
        // replenishRate=2, burstCapacity=2 이므로 초기 2번 허용
 
        // 첫 번째 요청: 200
        webTestClient.get().uri("/limited/resource")
                .header("X-Forwarded-For", "10.0.0.1")
                .exchange()
                .expectStatus().isOk();
 
        // 두 번째 요청: 200
        webTestClient.get().uri("/limited/resource")
                .header("X-Forwarded-For", "10.0.0.1")
                .exchange()
                .expectStatus().isOk();
 
        // 세 번째 요청: 429 Too Many Requests
        webTestClient.get().uri("/limited/resource")
                .header("X-Forwarded-For", "10.0.0.1")
                .exchange()
                .expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
    }
 
    @Test
    void 다른_IP는_별도로_제한된다() {
        // IP-A: 첫 번째 요청
        webTestClient.get().uri("/limited/resource")
                .header("X-Forwarded-For", "10.0.0.1")
                .exchange()
                .expectStatus().isOk();
 
        // IP-A: 두 번째 요청
        webTestClient.get().uri("/limited/resource")
                .header("X-Forwarded-For", "10.0.0.1")
                .exchange()
                .expectStatus().isOk();
 
        // IP-B: 첫 번째 요청 (IP-A와 독립적)
        webTestClient.get().uri("/limited/resource")
                .header("X-Forwarded-For", "10.0.0.2")
                .exchange()
                .expectStatus().isOk();
 
        // IP-A: 세 번째 요청 → 429
        webTestClient.get().uri("/limited/resource")
                .header("X-Forwarded-For", "10.0.0.1")
                .exchange()
                .expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
    }
}

8. 테스트 팁

WireMock 요청 이력 검증 (verify)

단순히 응답 상태코드만 확인하는 것을 넘어, 하위 서비스에 어떤 요청이 실제로 전달되었는지 검증해야 필터 동작을 제대로 확인할 수 있다.

// 특정 헤더를 포함한 요청이 1번 왔는지 검증
wireMockServer.verify(1, WireMock.getRequestedFor(WireMock.urlPathEqualTo("/users/123"))
        .withHeader("X-User-Id", WireMock.equalTo("user-42"))
        .withHeader("X-Correlation-Id", WireMock.matching("[a-f0-9\\-]+")));
 
// 특정 헤더가 없는 요청인지 검증 (민감 헤더 제거 확인)
wireMockServer.verify(WireMock.getRequestedFor(WireMock.anyUrl())
        .withoutHeader("Authorization"));
 
// 요청이 전혀 없었는지 검증
wireMockServer.verify(0, WireMock.anyRequestedFor(WireMock.anyUrl()));

@DirtiesContext 활용 시점

@DirtiesContext는 테스트 후 Spring 컨텍스트를 재시작시킨다. 성능 비용이 크므로 신중하게 사용해야 한다.

// 동적 라우트를 추가/삭제하는 테스트는 컨텍스트를 오염시킬 수 있다
@Test
@DirtiesContext  // 이 테스트 후 컨텍스트 재생성
void 동적_라우트_추가_테스트() {
    // RouteDefinitionWriter로 라우트를 추가하는 테스트
}

언제 사용하는가:

  • Actuator API로 라우트를 동적으로 변경하는 테스트
  • Circuit Breaker 상태(OPEN/CLOSED)를 특정 상태로 만드는 테스트
  • 전역 상태(Redis 데이터 등)를 변경하고 격리가 필요한 테스트

대안: 테스트 순서 의존성이 없도록 @AfterEach에서 상태를 정리하는 것이 @DirtiesContext보다 성능상 유리하다.

리액티브 테스트의 주의사항

// 잘못된 예: block()은 테스트에서도 데드락을 유발할 수 있다
@Test
void bad_example() {
    String result = webTestClient.get().uri("/api/test")
            .exchange()
            .expectBody(String.class)
            .returnResult()
            .getResponseBody(); // 이 방식은 OK
    // block()을 직접 호출하는 것은 피한다
}
 
// 올바른 예: StepVerifier 또는 WebTestClient 체이닝 활용
@Test
void good_example() {
    webTestClient.get().uri("/api/test")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.key").isEqualTo("value");
}