Webclient에 적용된 CircuitBreaker와 Retry 테스트하기

Minsu Kang·2021년 4월 7일
1

글을 시작하기 전에 사용한 모듈의 버전들을 먼저 적어놓겠습니다. 해당 버전과 다르다면 오류가 발생할 수도 있습니다.

  • org.springframework.boot:spring-boot-starter-webflux:2.1.0

  • io.github.resilience4j:reselience4j-spring-boot2:1.4
  • io.github.resilience4j:resilience4j-reactor:1.4
  • io.github.resilience4j:resilience4j-all:1.4

  • io.projectreactor:reactor-test:3.2.2
  • org.assertj:assertj-core:3.11.1
  • org.junit.jupiter:junit-jupiter-api:5.3.1
  • org.mockito:mockito-core:2.23.0
  • org.mockito:mockito-junit-jupiter:2.23.0

CircuitBreaker, Retry 란?

해당 글을 검색해서 들어왔다면 CircuitBreaker와 Retry에 대해서 이미 알고 있겠지만 간단하게 어떤 개념인지 소개를 하겠습니다.

CircuitBreaker와 Retry는 모두 fault-tolerance를 위한 패턴이라고 생각하면 됩니다.

  • CircuitBreaker : 어떤 이유로 요청이 지속적으로 실패할 경우 해당 시스템에 장애가 발생했다고 판단하여 잠시동안 요청을 차단하는 패턴 입니다.

  • Retry : 어떤 이유로 요청이 실패했을 경우 해당 요청을 다시 시도하는 패턴입니다.

실행 코드

spring webflux의 WebClient를 사용한 코드에 Resilience4j CircuitBreaekr와 Retry를 적용을 시킨 코드입니다.

public class MyClient {

    private WebClient client;

    public Client() {
        client = WebClient.create();
    }

    public Client(WebClient client) {
        this.client = client;
    }

    @SneakyThrows
    public Mono<ClientResponse> notice(String url, DTO dto) {
        return client.post()
                .uri(webHookUrl)
                .contentType(MediaType.APPLICATION_JSON)
                .syncBody(ObjectMapperUtil.writeAsString(dto))
                .exchange()
                .transform(getCircuitBreakerOperator())
                .transform(getRetryOperator());
    }
}

아래는 CircuitBreaker와 Retry의 설정입니다.
CircuitBreaker 설정은 100번 중 50번 실패했을 경우 OPEN 되도록 설정 되어있습니다.
Retry는 요청이 실패했을 경우 3번까지 재시도를 하도록 되어있습니다.

public class CircuitBreakerConfig {

    private static final CircuitBreaker circuitBreaker = CircuitBreaker.of("circuitBreaker", CircuitBreakerConfig.custom()
            .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
            .slidingWindowSize(100)
            .failureRateThreshold(50)
            .slowCallDurationThreshold(Duration.ofSeconds(1l))
            .slowCallRateThreshold(80)
            .permittedNumberOfCallsInHalfOpenState(80)
            .recordExceptions(WebClientException.class, IOException.class, TimeoutException.class)
            .build());

    public static <T> CircuitBreakerOperator<T> getCircuitBreakerOperator() {
        return CircuitBreakerOperator.of(circuitBreaker);
    }

    public static CircuitBreaker getCircuitBreaker() {
        return circuitBreaker;
    }

}

public class RetryConfig {

    private static final Retry retry = Retry.of("retry", RetryConfig.custom()
            .maxAttempts(3)
            .waitDuration(Duration.ofMillis(300l))
            .retryExceptions(WebClientException.class, IOException.class, TimeoutException.class)
            .build());

    public static Retry getRetry() {
        return retry;
    }

    public static <T> RetryOperator<T> getRetryOperator() {
        return RetryOperator.of(retry);
    }
}

테스트 코드

CircuitBreaker 테스트

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class Test {

    @Mock
    WebClient client;

    @Mock
    WebClient.RequestBodyUriSpec requestBodyUriSpec;

    @Mock
    WebClient.RequestBodySpec successRequestBodySpec;

    @Mock
    WebClient.RequestHeadersSpec successRequestHeadersSpec;

    @Mock
    WebClient.RequestBodySpec failureRequestBodySpec;

    @Mock
    WebClient.RequestHeadersSpec failureRequestHeadersSpec;

    @InjectMocks
    MyClient myClient;

    @Test
    @DisplayName("failureRateThreshold에 도달하면 써킷브레이커가 open 된다.")
    void should_circuit_breaker_open() throws InterruptedException {
        // given
        CircuitBreaker circuitBreaker = getCircuitBreaker();
        circuitBreaker.reset();

        int slidingWindowSize = circuitBreaker
                .getCircuitBreakerConfig()
                .getSlidingWindowSize();

        int failureRateThreshold = (int) circuitBreaker
                .getCircuitBreakerConfig()
                .getFailureRateThreshold();

        int maxAttempts = getRetry()
                .getRetryConfig()
                .getMaxAttempts();

        doReturn(requestBodyUriSpec).when(client).post();
        doReturn(successRequestBodySpec).when(requestBodyUriSpec).uri("success");
        doReturn(successRequestBodySpec).when(successRequestBodySpec).contentType(any());
        doReturn(successRequestHeadersSpec).when(successRequestBodySpec).syncBody(any());
        doReturn(Mono.just(ClientResponse.create(HttpStatus.OK).build())).when(successRequestHeadersSpec).exchange();

        doReturn(requestBodyUriSpec).when(client).post();
        doReturn(failureRequestBodySpec).when(requestBodyUriSpec).uri("failure");
        doReturn(failureRequestBodySpec).when(failureRequestBodySpec).contentType(any());
        doReturn(failureRequestHeadersSpec).when(failureRequestBodySpec).syncBody(any());
        doReturn(Mono.error(ReadTimeoutException.INSTANCE)).when(failureRequestHeadersSpec).exchange();

        // when
        // 정상 요청 반복
        for (int i = 0; i < slidingWindowSize - failureRateThreshold; i++) {
            StepVerifier.create(myClient.notice("success", Notice.builder().build()))
                    .consumeNextWith(res -> assertThat(res.statusCode()).isEqualTo(HttpStatus.OK))
                    .verifyComplete();
        }

        // 비정상 요청 반복
        for (int i = 0; i < failureRateThreshold / maxAttempts; i++) {
            StepVerifier.create(myClient.notice("failure", Notice.builder().build()))
                    .expectError(ReadTimeoutException.class)
                    .verify();
        }

        // then
        StepVerifier.create(myClient.notice("failure", Notice.builder().build()))
                .expectError(CallNotPermittedException.class)
                .verify();

        assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
    }
}

먼저 요청을 모킹하는 작업이 필요합니다.
써킷 브레이커 테스트를 위해 실제 서버에 수 많은 요청을 보낼 수는 없기 때문입니다.

  • @Mock
    Mock 객체를 생성합니다. 해당 객체를 모킹하여 원하는 동작을 하도록 지시할 수 있습니다.
  • @InjectMock
    @InjectMock은 @Mock과 비슷하게 동작하지만 @Mock이 붙은 객체를 @InjectMocks가 붙은 객체에게 주입한다는 특징이 있습니다. (생성자 또는 setter 필요)
    우리 코드에서는 MyClient에 WebClient mock 객체를 주입하여 사용할 수 있게 됩니다.

아래 코드가 모킹을 하는 코드입니다.
써킷브레이커 테스트를 위해 정상적인 요청과 비정상적인 요청이 필요하므로 두 가지 요청을 모킹하였습니다.

// 정상 요청 모킹
doReturn(requestBodyUriSpec).when(client).post();
doReturn(successRequestBodySpec).when(requestBodyUriSpec).uri("success");
doReturn(successRequestBodySpec).when(successRequestBodySpec).contentType(any());
doReturn(successRequestHeadersSpec).when(successRequestBodySpec).syncBody(any());
doReturn(Mono.just(ClientResponse.create(HttpStatus.OK).build())).when(successRequestHeadersSpec).exchange();

// 비정상 요청 모킹
doReturn(requestBodyUriSpec).when(client).post();
doReturn(failureRequestBodySpec).when(requestBodyUriSpec).uri("failure");
doReturn(failureRequestBodySpec).when(failureRequestBodySpec).contentType(any());
doReturn(failureRequestHeadersSpec).when(failureRequestBodySpec).syncBody(any());
doReturn(Mono.error(ReadTimeoutException.INSTANCE)).when(failureRequestHeadersSpec).exchange();

아래 코드의 client.post() 부터 시작한 흐름을 모킹한 것입니다.

public Mono<ClientResponse> notice(String url, DTO dto) {
    return client.post()
    	.uri(webHookUrl)
    	.contentType(MediaType.APPLICATION_JSON)
        .syncBody(ObjectMapperUtil.writeAsString(dto))
        .exchange()
        .transform(getCircuitBreakerOperator())
        .transform(getRetryOperator());
    }

client.post() 는 requestBodyUriSpec 을 리턴하고,
requestBodyUriSpec.uri() 는 requestBodySpec 을 리턴하고
...
...
최종적으로 requestHeaderSpec.exchange() 가 정상적인 response를 반환하거나 excpetion을 던지도록 모킹 하였습니다.

이 경우에는 uri로 "success"가 들어올 경우 정상 response를 반환하고 uri로 "failure"가 들어올 경우 ReadTimeoutException 을 던지도록 모킹되어 있습니다.

이제 모킹을 완료하였으니 실제로 요청을 테스트 해보겠습니다.

// when
// 정상 요청 반복
for (int i = 0; i < slidingWindowSize - failureRateThreshold; i++) {
    StepVerifier.create(myClient.notice("success", Notice.builder().build()))
            .consumeNextWith(res -> assertThat(res.statusCode()).isEqualTo(HttpStatus.OK))
            .verifyComplete();
}

// 비정상 요청 반복
for (int i = 0; i < failureRateThreshold / maxAttempts; i++) {
    StepVerifier.create(myClient.notice("failure", Notice.builder().build()))
            .expectError(ReadTimeoutException.class)
            .verify();
}

// then
StepVerifier.create(myClient.notice("failure", Notice.builder().build()))
        .expectError(CallNotPermittedException.class)
        .verify();

assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);

테스트 전략은 다음과 같습니다.
예를들어 100번 중 40번 실패 시 CircuitBreaker 열리는 설정이고 실패 시 3번 Retry를 시도하는 설정이라고 가정하겠습니다.

  1. (100 - 40)번 만큼 success 요청을 보냅니다.

  2. (40 / 3)번 만큼 failure 요청을 보냅니다. retry 시도까지 총 (40 / 3) * 3 번의 failure가 기록됩니다. (이 때, 40 / 3 이 정확히 나누어 떨어진다면 CircuitBreaker가 OPEN 됩니다.)

  3. 40 / 3 이 정확히 나누어 떨어지지 않을수도 있으므로 한 번 더 failure 요청을 보냅니다. (이 요청 이후엔 무조건 CircuitBreaekr가 OPEN 입니다.)

코드를 보시면 SlidingWindowSize - failureRateThreshold 만큼 success 요청을 반복합니다.

그리고 failureRateThreshold / maxAttempts 만큼 failure 요청을 반복합니다.

마지막으로 한번 더 failure 요청을 보냅니다. 이 때는 ReadTimeoutException이 아니라 CallNotPermittedException이 발생합니다. 왜냐하면, CircuitBreaker가 OPEN 되어 더 이상 요청을 보낼 수 없기 때문입니다.

그리고 CircuitBreaker가 OPEN 인지 검증합니다.

참고
StepVerifier는 react-test 모듈에서 제공하는 비동기 테스트를 위한 인터페이스 입니다.
StepVerifier를 사용하면 Publisher의 구현체인 Flux/Mono의 처리 단계를 확인할 수 있습니다.

간단한 사용법은 다음과 같습니다.

  1. StepVerifier.create() 로 검증할 Flux/Mono 의 StepVerifier 구현체 객체를 생성한다.
    ex) StepVerifier.create(mono);
  1. Flux/Mono가 지정되면 StepVerifier는 해당 리액티브 타입을 구독한 다음에 스트림을 통해 전달되는 데이터에 대한 assertion을 적용한다.
    ex) StepVerifier.create(mono).expectNext();
  1. 해당 스트림이 기대한 대로 완전하게 동작하는지 검사한다. (verify가 subscribe의 역할을 합니다.)
    ex) StepVerifier.create(mono).expectNext().verify();

더 자세한 사용법은 해당 문서를 참고해주세요.

여기까지 CircuitBreaker의 동작을 테스트 해보았습니다.

Retry 테스트

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class Test {

    @Mock
    WebClient client;

    @Mock
    WebClient.RequestBodyUriSpec requestBodyUriSpec;

    @Mock
    WebClient.RequestBodySpec successRequestBodySpec;

    @Mock
    WebClient.RequestHeadersSpec successRequestHeadersSpec;

    @Mock
    WebClient.RequestBodySpec failureRequestBodySpec;

    @Mock
    WebClient.RequestHeadersSpec failureRequestHeadersSpec;

    @InjectMocks
    MyClient myClient;

    @Test
    @DisplayName("exception 발생 시 retry를 시도한다.")
    void should_retry() {
        // given
        getCircuitBreaker().reset();

        doReturn(requestBodyUriSpec).when(client).post();
        doReturn(failureRequestBodySpec).when(requestBodyUriSpec).uri(anyString());
        doReturn(failureRequestBodySpec).when(failureRequestBodySpec).contentType(any());
        doReturn(failureRequestHeadersSpec).when(failureRequestBodySpec).syncBody(any());
        doReturn(Mono.error(ReadTimeoutException.INSTANCE)).when(failureRequestHeadersSpec).exchange();

        // when
        StepVerifier.create(myClient.notice("failure", Notice.builder().build()))
                .expectError(ReadTimeoutException.class)
                .verify();

        // then
        Retry.Metrics metrics = getRetry().getMetrics();
        assertThat(metrics.getNumberOfFailedCallsWithRetryAttempt()).isEqualTo(1);
    }
}

마찬가지로 모킹작업을 먼저 하였습니다.
여기서는 request 시에 무조건 ReadTimeoutException이 발생하도록 모킹 하였습니다.

테스트 전략은 다음과 같습니다.

  1. failure 요청을 보낸다.

  2. 요청이 실패하였기 떄문에 Retry를 시도하고 Retry Metrics에 관련 내용이 기록된다.

  3. Metrics를 활용하여 Retry가 제대로 수행되었는지 검증한다.

코드의 마지막 줄을 보면 Retry Metrics에 기록된 NumberOfFailedCallsWithRetryAttempt를 이용하여 테스트를 검증하였습니다.

여기까지 WebClient + CircuitBreaker와, Retry 테스트를 해보았습니다.
저는 아직 이런 모킹을 사용한 테스트에 익숙하지도 않고 CircuitBreaker와 Retry를 처음 접해보는 거라 올바른 테스트 방법이 아닐 수도 있습니다.

그냥 참고용으로만 봐주시면 좋을 것 같습니다!

내용 추가!

CircuitBreaker 와 retry 중에 어떤 것을 먼저 적용시키는지에 따라 테스트 시나리오가 달라질 수 있습니다.

circuitbreaker 먼저 적용할 경우

모든 retry 시도마다 circuitbreaker 에 fail로 기록이 됩니다.

retry 먼저 적용할 경우

retry 를 3번으로 설정했다면 3번의 retry 이후 circuitbreaker 에 1회 fail로 기록됩니다.

profile
백엔드 개발자

1개의 댓글

comment-user-thumbnail
2021년 4월 8일

어렵네요

답글 달기