본문 바로가기
프로그래밍/Spring Cloud

[MSA] spring boot에서 resilience4j 사용해보자 - Retry, CircuitBreaker 편

by 사바라다 2021. 12. 12.

안녕하세요. 이번 포스팅에서는 resilience4j를 실제로 spring boot 환경에서 사용해보는 방법에 대해서 알아보는 시간을 가져보도록 하겠습니다.

환경

먼저 오늘 실습에 사용된 환경은 아래와 같습니다.

  • Java
    • 11
  • Spring Boot
    • 2.5.5
  • resilience4j
    • 1.7.1

Spring Boot

기본적으로 resilience4j는 Spring Boot 전용이 아닙니다. 다양한 프레임워크에서 돌아갈 수 있도록 모듈이 준비되어있는데요. 그래서 Spring Boot에서 지원하는 properties 또는 Bean을 활용한 설정을 하기위해서는 Spring Boot 전용 모듈을 함께 추가해 주어야합니다. 추가해주어야하는 모듈은 아래와 같습니다.

dependencies {
  compile "io.github.resilience4j:resilience4j-spring-boot2:${resilience4jVersion}"
  compile('org.springframework.boot:spring-boot-starter-aop')
}

어노테이션기반의 사용을 위해서 spring boot의 aop 모듈인 org.springframework.boot:spring-boot-starter-aop와 모니터링을 위해서 org.springframework.boot:spring-boot-starter-actuator 모듈은 추가적으로 필요할 수 있습니다.

retry

retry는 실패한 실행을 짧은 지연을 가진 후 재시도하는 매커니즘을 가집니다. spring boot 환경에서 사용하기 위해서는 아래와 같은 의존성이 필요합니다. 여기서 hystrix와 다르게 resilience는 패턴별로 모듈을 따로 의존할 수 있는것을 알 수 있습니다. 이렇게 제공함으로써 실제로 필요한 패턴만을 가져올 수 있기때문에 경량을 기대할 수 있습니다.

dependencies {
  compile "io.github.resilience4j:resilience4j-retry:${resilience4jVersion}"
}

아래는 resilience4j Retry가 제공하는 옵션들입니다. 기본적으로 옵션들은 application.yml에 properties로 설정할 수 있습니다. 하지만 intervalFunction 등과 같이 설정하지 못하는 옵션도 있기때문에 참고하시기 바랍니다.

  • maxAttempts (기본값 : 3)
    • 최대 시도 가능 횟수 ( 최초 호출을 포함합니다. )
  • waitDuration (기본값 : 500 [ms])
    • 재시도 호출 간격
  • intervalFunction (기본값 : 없음) --> 1.7.x 버전에서 현재 bug로 남아있음.
    • dynamic한 waitDuration을 만들고자 할 때 사용, input 시도 횟수, output waitDuration
    • properties로 정할 수 없고 RetryConfigCustomizer를 이용해야합니다.
  • intervalBiFunction (기본값 : 없음)
    • A function to modify the waiting interval after a failure based on attempt number and result or exception. When used together with intervalFunction will throw an IllegalStateException.
    • dynamic한 waitDuration을 만들고자 할 때 사용, input 시도 횟수, output waitDuration
    • properties로 정할 수 없고 RetryConfigCustomizer를 이용해야합니다.
    • 1.7.x 버전에서는 버그로 잘 사용이 되지 않습니다. 1.6.x 버전을 이용해주시기 바랍니다. Link
  • retryOnResultPredicate (기본값 : 없음)
    • 반환되는 결과에 따라서 retry를 할지 말지 결정하는 filter, true로 반환하면 retry하고 false로 반환하면 retry 하지 않습니다.
  • retryExceptionPredicate (기본값 : 없음)
    • exception에 따라서 retry 할지 말지 결정하는 filter, true로 반환하면 retry하고 false로 반환하면 retry 하지 않습니다.
  • retryExceptions (기본값 : empty)
    • 실패로 기록하고 재시도하는 Exception 리스트입니다.
  • ignoreExceptions (기본값 : empty)
    • Exception이 발생해도 ignore하는 Exception 리스트입니다.
  • failAfterMaxRetries (기본값 : false)
    • 모든 시도를 실패했을 때 MaxRetriesExceededException를 리턴할지 아니면 해당 Exception을 리턴할지 정하는 값입니다.

아래 yml 설정파일은 reslience4j의 yml 설정파일입니다. 아래와 같이 설정할 수 있습니다. configs.default는 모든 retry가 동일하게 베이스로 가져가는 값입니다. 그리고 instances는 name으로 구분하여 특정 retry만 커스텀할 수 있는 기능이니 참고해주시기바랍니다.

resilience4j:
  retry:
    configs:
      default:
        maxRetryAttempts: 2
        waitDuration: 5
        ignoreException:
          - java.util.NoSuchElementException
    instances:
      getAccessToken:
        baseConfig: default
        maxRetryAttempts: 3

참고로 위에서 말씀드렸던 intervalFunction 등과 같이 function filter 같은 옵션은 아래처럼 java Bean 설정으로 주입해주셔야 합니다.

@Bean
public RetryConfigCustomizer RetryConfigCustomizertestCustomizer():  {
    return RetryConfigCustomizer.of("getAccessToken", builder -> {
        builder
            .intervalFunction {
                log.info { it }
                Duration.ofSeconds(3).toMillis()
            }
    });

아래 코드는 사용하는 Retry Annotation 코드입니다. name은 필수입니다. fallbackMethod는 지정해주시면 Retry를 모두 실패했을 때 실행되는 method 입니다. 만약 fallbackMethod를 지정하지 않으시면 발생한 Exception이 그대로 전파되게됩니다. 만약 failAfterMaxRetries 옵션을 true로 잡으시면 MaxRetriesExceededException가 전파되게 됩니다.

public @interface Retry {

    String name(); // SpEL을 사용할 수 있습니다, default가 없으니 반드시 annotation의 attribute가 필수

    String fallbackMethod() default "";
}

아래는 실제로 @Retry 어노테이션을 사용한 코드입니다.

@Retry(name = "getAccessToken", fallbackMethod = "fallback")
public String getAccessToken() {

    log.info("try")

    GoogleCredentials googleCredentials = GoogleCredentials
        .fromStream(ClassPathResource("key_file.json").getInputStream())
        .createScoped(listOf("{{google_url}}")); // 반드시 실패한다.

    [..하략..]
}

private String fallback(e: Throwable) {
    return "default";
}

아래 코드는 위 코드를 테스트 했을때 노출되는 log 입니다. 본 시도를 포함하여 3번의 시도와 모두 실패로 fallback의 메서드가 실해된 것을 알 수 있습니다.

2021-12-11 21:05:26.122  INFO 26152 --- [    Test worker] c.s.infra.apis.google.FireBaseApi        : try
2021-12-11 21:05:26.134  INFO 26152 --- [    Test worker] c.s.infra.apis.google.FireBaseApi        : try
2021-12-11 21:05:26.140  INFO 26152 --- [    Test worker] c.s.infra.apis.google.FireBaseApi        : try
2021-12-11 21:05:26.142  INFO 26152 --- [    Test worker] c.s.infra.apis.google.FireBaseApiIT      : return Value = default

circuit breaker

circuit breaker는 실패의 횟수가 기준치를 넘을경우 circuit을 열어 일정 기간 동안 해당 메서드를 실행되지 않고 바로 실패하게 해주는 패턴입니다. circuit breaker의 기본적인 메커니즘은 [MSA] Spring Cloud Hystrix - 개념편circuit-breaker 구조를 참고해주시기바랍니다. 기본적으로 슬라이딩 윈도우를 사용해서 호출 결과를 저장하고 집계하여 circuit을 열거나 닫습니다.

횟수 기반 슬라이딩 윈도우(Count-based sliding window)

들어온 횟수를 기반으로 슬라이딩 윈도우를 잡는 방법입니다. N 크기의 circular array를 만들어서 해당 array의 실패률을 이용하여 circuit의 상태를 확정합니다. 상태를 확인하는데 걸리는 시간은 O(1)이며 메모리 소비는 O(N)입니다.

시간 기반 슬라이딩 윈도우(Time-based sliding window)

시간을 기반으로 슬라이딩 윈도우를 잡는 방법입니다. N 초의 슬라이딩 윈도우 크기라면 N 개의 circular array를 만듭니다. 그리고 각 버킷에 특정 초에 발생한 호출의 결과를 집계하여 저장하고 있고 이를 초가 지남에 따라 밀어내고 가지는 형식입니다. N 초가 지나기 시작하면 가장 오래된 버킷은 제거되고 총 집계는 새롭게 이루어집니다. 집계는 실패한 호출 수, 느린 호출 수, 그리고 총 호출 수 3가지를 가지고 있습니다. 별도 집계를 하고 결과를 가지고 있으므로 상태 확인은 O(1)이며 메모리는 O(N)의 부분집계와 O(1)의 총 집계로 구성됩니다.

dependencies {
  compile "io.github.resilience4j:resilience4j-circuitbreaker:${resilience4jVersion}"
}

아래는 resilience4j Circuit Breaker가 제공하는 옵션들입니다. 기본적으로 옵션들은 application.yml에 properties로 설정할 수 있습니다. recordFailurePredicateignoreException은 Retry의 intervalFunction와 마찬가지로 bean 커스터마이징만 가능합니다.

  • failureRateThreshold ( 기본값 : 50 )
    • 실패율 임계값을 설정합니다. 해당 %가 넘거나 같아지면 circuitBreaker의 상태가 Open으로 변경되며 실제 코드를 호출하지 않고 fallback 또는 fail 처리 됩니다.
  • slowCallRateThreshold ( 기본값 : 100 )
    • slow call이 일어날 때 임계값을 설정합ㄴ디ㅏ. 해당 %가 넘거나 같아지면 circuitBreaker의 상태가 Open으로 변경되며 실제 코드를 호출하지 않고 fallback 또는 fail 처리 됩니다.
  • slowCallDurationThreshold ( 기본값 : 60_000 [ms] )
    • slow call이라고 간주하는 시간을 설정합니다.
  • permittedNumberOfCallsInHalfOpenState ( 기본값 : 10 )
    • circuit이 HALF_OPEN 상태일 때 허용되는 call 수이며 해당 call로 들어온 실패율에 따라서 close 또는 open으로 변경된다.
  • maxWaitDurationInHalfOpenState ( 기본값 : 0 [ms] )
    • circuit이 HALF_OPEN 상태일 때 call을 얼마나 기다릴지 정합니다. 0은 무한정 기다린다는 뜻입니다.
  • slidingWindowType ( 기본값 : COUNT_BASED )
    • sliding window로 어떤 값을 사용할지 정합니다. 기본은 COUNT_BASED 이며 TIME_BASED로 사용할 수 있습니다.
  • slidingWindowSize ( 기본값 : 100 )
    • sliding window 크기입니다. COUNT_BASED라면 array 크기이며 TIME_BASED라면 초 입니다.
  • minimumNumberOfCalls ( 기본값 : 100 )
    • circuit을 동작시키기위한 최소한의 call 수 입니다. 실패율이 failureRateThreshold를 넘었다고해도 최소 호출량을 만족시키지 않으면 circuit은 열리지 않습니다.
  • waitDurationInOpenState ( 기본값 : 60_000 [ms] )
    • circuit이 OPEN 상태가 되고나서 대기하는 시간입니다.
  • automaticTransitionFromOpenToHalfOpenEnabled ( 기본값 : false )
    • circuit이 OPEN에서 HALF_OPEN으로 변경시키는 트리거를 위한 모니터링 thread를 별도로 둘지 여부입니다. 두지 않으면 call이 들어왔을때만 판단합니다.
  • recordExceptions ( 기본값 : empty )
    • 실패로 처리할 Exception을 명시할 수 있습니다. 명시하면 명시하지 않은 Exception은 success로 간주합니다.
  • ignoreExceptions ( 기본값 : empty )
    • 실패로 처리하지 않을 Exception을 명시할 수 있습니다.
  • recordFailurePredicate
    • 성공과 실패 여부를 좀 더 커스터마이징 할 수 있습니다.
  • ignoreException
    • 실패로 처리하지 않을 exception에 대해서 좀 더 커스터마이징 할 수 있습니다.

아래 yml 설정파일은 reslience4j의 yml 설정파일입니다. 아래와 같이 설정할 수 있습니다. configs.default는 모든 circuitbreaker가 동일하게 베이스로 가져가는 값입니다. 그리고 instances는 name으로 구분하여 특정 circuitbreaker만 커스텀할 수 있는 기능이니 참고해주시기바랍니다.

resilience4j:
  circuitbreaker:
    configs:
      default:
        registerHealthIndicator: true
        slidingWindowSize: 10
        minimumNumberofCalls: 5
        permittedNumberOfCallsInHalfOpenState: 3
        automaticTransitionFromOpenToHalfOpenEnabled: true
        waitDurationInOpenState: 5s
        failureRateThreshold: 50
        eventConsumerBufferSize: 10
        ignoreException:
          - java.util.NoSuchElementException

    instances:
      order:
        minimumNumberOfCalls: 10
        waitDurationInOpenState: 3s
        failureRateThreshold: 10
      reservation:
        baseConfig: default

아래는 circuitBreaker를 어노테이션 명세입니다. Retry와 동일하다는 사실을 알 수 있었습니다.

public @interface CircuitBreaker {

    String name();

    String fallbackMethod() default "";
}

사용하는 방법도 Retry와 동일합니다.

@CircuitBreaker(name = "notificationSend", fallbackMethod = "sendFallback")
public void send(LocalTime: localTime) {

    log.info("in")

    val ids = notificationService.read(localTime) // read에 실패함
        .map { it.id }
        .toList()

    [..하략..]
}

private fun sendFallback(LocalTime: localTime, Throwable: e) {
    log.info("fallback")
}

위 코드를 지속적으로 호출해보도록 하겠습니다. 위의 옵션에서 minimumNumberofCalls를 충족시키자마자 6번째 호출 부터는 in 로깅없이 fallback 로깅만 노출되는것을 알 수 있었습니다. 이런 상황을 보았을 때 실질 ㅇ메서드가 타지 않게 됬음을 알 수 있습니다.

2021-12-12 03:36:17.397  INFO 53400 --- [ctor-http-nio-2] c.s.a.s.n.NotificationApiService         : in
2021-12-12 03:36:17.400  INFO 53400 --- [ctor-http-nio-2] c.s.a.s.n.NotificationApiService         : fallback
2021-12-12 03:36:41.579  INFO 53400 --- [ctor-http-nio-3] c.s.a.s.n.NotificationApiService         : in
2021-12-12 03:36:41.579  INFO 53400 --- [ctor-http-nio-3] c.s.a.s.n.NotificationApiService         : fallback
2021-12-12 03:36:45.273  INFO 53400 --- [ctor-http-nio-4] c.s.a.s.n.NotificationApiService         : in
2021-12-12 03:36:45.273  INFO 53400 --- [ctor-http-nio-4] c.s.a.s.n.NotificationApiService         : fallback
2021-12-12 03:36:46.927  INFO 53400 --- [ctor-http-nio-5] c.s.a.s.n.NotificationApiService         : in
2021-12-12 03:36:46.928  INFO 53400 --- [ctor-http-nio-5] c.s.a.s.n.NotificationApiService         : fallback
2021-12-12 03:36:47.836  INFO 53400 --- [ctor-http-nio-6] c.s.a.s.n.NotificationApiService         : in
2021-12-12 03:36:47.840  INFO 53400 --- [ctor-http-nio-6] c.s.a.s.n.NotificationApiService         : fallback
2021-12-12 03:36:48.606  INFO 53400 --- [ctor-http-nio-7] c.s.a.s.n.NotificationApiService         : fallback
2021-12-12 03:36:49.526  INFO 53400 --- [ctor-http-nio-8] c.s.a.s.n.NotificationApiService         : fallback

마무리

오늘은 이렇게 resilience4j의 Retry와 Circuit Breaker 패턴을 직접 사용해보는 시간을 가져보았습니다.

추가로 말씀드리면 안타깝게도 아직까지 coroutine을 어노테이션만을 이용해서 fallback method를 지정하는 것은 불가능한 것으로 확인했습니다.

coroutine과 resilience4j를 사용하고자 하시는분들은 resilience4j-kotlin를 이용해서 직접 사용해주시기 바랍니다.

다음시간에는 오늘 다루지 못한 resilience4j 패턴을 살펴보도록 하겠습니다.

감사합니다.

참고

github_resilience4j

spring_spring-cloud-greenwich-rc1-available-now

resilience4j_docs

infoq_spring-cloud-hystrix

baeldung_resilience4j

댓글