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

[MSA] spring boot에서 resilience4j 사용해보자 - RateLimiter, BulkHead, TimeLimiter 편

by 사바라다 2021. 12. 15.

안녕하세요. 이번 포스팅에서는 spring boot 환경에서 resilience4j의 ratelimiter와 bulkhead, 그리고 timelimiter 패턴에 대해서 실습해보는 시간을 가져보도록 하겠습니다.

RateLimiter

rateLimiter는 단위 시간동안 얼마만큼의 실행을 허용할 것인지 제한할 수 있는 메커니즘을 말합니다. resiliece4j에서는 단순하게 요청의 숫자를 이용하여 제한(limit)할 수 있으며 또는 제한에 걸린 요청은 queue를 생성하여 이후에 처리하거나 하는 방법도 제공합니다.

위 이미지는 공식 docs에서 가져온 이미지입니다. rateLimiter가 어떻게 동작하는지 매커니즘을 잘 나타내고 있습니다.

  • System.nanoTime()을 JVM 시작과 함께 일정한 단위 시간(RateLimiterConfig.limitRefreshPeriod)으로 쪼갭니다.
  • 단위 시간 동안 허가되는 요청수(RateLimiterConfig.limitForPeriod)를 설정합니다.
  • RateLimiter에 사용되는 필드가 3가지 있습니다.
    • activeCycle - 마지막 호출에 의해서 사용된 cycle 번호
    • activePermissions - 마지막 호출 이후에 cycle이 끝나기까지 호출할 수 있는 남은 횟수
    • nanosToWait - cycle이 끝나기까지 남은 시간

그렇다면 이제 실제로 spring boot 환경에서 ratelimit를 사용해보도록 하겠습니다. 아래는 rateLimiter를 사용하기 위한 의존성입니다. 이와는 별개로 resiliecne4j의 spring boot 의존성은 반드시 필요합니다. 관련 내용은 [MSA] spring boot에서 resilience4j 사용해보자 - Retry, CircuitBreaker 편를 참고해주세요.

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

rateLimiter와 관련된 옵션은 아래와 같습니다. cycle에 대한 정책을 잡는것에 옵션들이 목적이 있음을 확인할 수 있습니다.

  • timeoutDuration ( 기본값 : 5s )
    • 호출 thread가 rateLimit에 대해서 접근 허가를 얻기위해서 대기하는 시간
  • limitRefreshPeriod ( 기본값 : 500 ns )
    • cycle이 가지는 주기, cycle 주기가 끝나면 호출 가능 횟수는 다시 리셋된다.
  • limitForPeriod ( 기본값 : 50 )
    • cycle 동안 호출할 수 있는 횟수

위 옵션들은 yml 파일에 쉽게 적용할 수 있습니다.

resilience4j: 
  ratelimiter:
    configs:
      default:
        limitForPeriod: 3
        limitRefreshPeriod: 10s
        timeoutDuration: 1s

아래는 RateLimiter를 사용하는 샘플 코드입니다. Controller에서도 사용할 수 있어서 이렇게 어노테이션을 달아서 사용해 보았습니다.

@GetMapping
@RateLimiter(name = "createDevice")
public ResponseEntity<ReadVersionResponse> readVersion(
    @RequestParam AppType appType
) {
    return ResponseEntity.ok(versionApiService.readVersion(appType));
}

위 yml 설정을 보면 10초의 cycle동안 3번의 요청이 허가됨을 알 수 있습니다. 테스트 결과는 아래와 같습니다. 4번째 요청부터 RequestNotPermitted이라는 Exception이 발생한것을 확인할 수 있습니다.

2021-12-15 02:42:28.983  INFO 37288 --- [tor-http-nio-10] c.s.a.f.RestControllerLoggingWebFilter   : Web exchange. request method=200 OK
2021-12-15 02:42:30.200  INFO 37288 --- [tor-http-nio-11] c.s.a.f.RestControllerLoggingWebFilter   : Web exchange. request method=GET, request path=/v1/version, query={appType=[VOICE_DIARY]}
2021-12-15 02:42:31.214  INFO 37288 --- [tor-http-nio-11] c.s.a.f.RestControllerLoggingWebFilter   : Web exchange. request method=200 OK
2021-12-15 02:42:31.229 ERROR 37288 --- [tor-http-nio-11] a.w.r.e.AbstractErrorWebExceptionHandler : [90becda1-1]  500 Server Error for HTTP GET "/v1/version?appType=VOICE_DIARY"

io.github.resilience4j.ratelimiter.RequestNotPermitted: RateLimiter 'createDevice' does not permit further calls
    at io.github.resilience4j.ratelimiter.RequestNotPermitted.createRequestNotPermitted(RequestNotPermitted.java:43) ~[resilience4j-ratelimiter-1.6.1.jar:1.6.1]

BulkHead

이어서 BulkHead 패턴에 대해서 알아보도록 하겠습니다. BulkHead는 resilience4j에서 동시에 호출되는 수를 제한하는 장애 방지 패턴입니다.

resilience4j의 Bulkhead는 2가지를 지원합니다. 첫번째는 Semaphore로 동시 호출을 제한하는 SemaphoreBulkhead이며, 두번째는 고정된 thread pool을 이용하는 FixedThreadPoolBulkhead 방식입니다.

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

아래는 bulkhead를 사용하면서 설정할 수 있는 option 리스트입니다.

  • maxConcurrentCalls ( 기본값 : 25 )
    • 동시에 호출할 수 있는 최대 양
  • maxWaitDuration ( 기본값 : 0 )
    • maxConcurrentCalls를 모두 사용하고 있을 때 대기할 수 있는 시간입니다.
resilience4j:
  bulkhead:
    configs:
      default:
        maxConcurrentCalls: 1 # 동시에 1개만 실행될 수 있도록 한다.
        maxWaitDuration: 0

그렇다면 위 설정을 토대로 한번 bulkhead를 사용하도록 예제를 작성해보도록 하겠습니다. bulkhead를 사용하기 위해서는 @Bulkhead 어노테이션을 이용하면 됩니다. 여기에는 2가지의 필수 파라미터가 있습니다. 바로 name과 type입니다. name은 여느 resilience4j와 동일합니다. type은 SEMAPHORE 기반으로 할지 아니면 유저가 직접 작성할 수 있는 THREADPOOL을 기반으로 할 지 설정하는 부분입니다. 먼저 SEMAPHORE 기반으로 설정해보겠습니다.

@GetMapping
@Bulkhead(name = "readVersion", type = Bulkhead.Type.SEMAPHORE)
public ResponseEntity<ReadVersionResponse> readVersion(
    @RequestParam AppType appType
) { // 10 초 걸리는 작업
    return ResponseEntity.ok(versionApiService.readVersion(appType));
}

위 예제를 동시에 실행하면 아래와 같은 결과를 얻을 수 있었습니다. 동시 요청에 대해서 첫번째 요청은 성공하고 두번째 요청에 대해서는 BulkheadFullException이 발생한 것을 확인할 수 있었습니다.

2021-12-15 15:15:52.841  INFO 43997 --- [ctor-http-nio-2] c.s.a.f.RestControllerLoggingWebFilter   : Web exchange. request method=GET, request path=/v1/version, query={appType=[VOICE_DIARY]}
2021-12-15 15:15:52.873  INFO 43997 --- [ctor-http-nio-3] c.s.a.f.RestControllerLoggingWebFilter   : Web exchange. request method=GET, request path=/v1/version, query={appType=[VOICE_DIARY]}
2021-12-15 15:15:52.944  INFO 43997 --- [ctor-http-nio-2] c.s.a.f.RestControllerLoggingWebFilter   : Web exchange. request method=200 OK
2021-12-15 15:15:52.961 ERROR 43997 --- [ctor-http-nio-2] a.w.r.e.AbstractErrorWebExceptionHandler : [30f22a6a-1]  500 Server Error for HTTP GET "/v1/version?appType=VOICE_DIARY"

io.github.resilience4j.bulkhead.BulkheadFullException: Bulkhead 'readVersion' is full and does not permit further calls
    at io.github.resilience4j.bulkhead.BulkheadFullException.createBulkheadFullException(BulkheadFullException.java:49) ~[resilience4j-bulkhead-1.6.1.jar:1.6.1]

이어서 THREADPOOL 기반으로 설정해보도록 하겠습니다. threadPool 기반으로 하면 추가적인 옵션을 지정할 수 있습니다. 해당 옵션들은 아래와 같습니다.

  • maxThreadPoolSize ( 기본값 : Rntime.getRuntime().availableProcessors() )
    • threadPool의 max 크기를 설정합니다.
  • coreThreadPoolSize ( 기본값 : Runtime.getRuntime().availableProcessors() - 1 )
    • threadPool의 기본 크기(core size)를 설정합니다.
  • queueCapacity ( 기본값 : 100 )
    • queue size를 설정합니다.
  • keepAliveDuration ( 기본값 : 20 [ms] )
    • threadPool의 크기가 core 보다 커졌을 경우 증가한 thread가 idle 될 때 새로운 사작업을 기다리는 최대 시간입니다. idle 타임보다 오래 쉬면 해당 thread는 제거됩니다.
resilience4j:
  bulkhead:
    configs:
      default:
        maxThreadPoolSize: 4
        coreThreadPoolSize: 3
        queueCapacity: 50
        keepALiveDuration: 20
        maxConcurrentCalls: 1 # 동시에 1개만 실행될 수 있도록 한다.
        maxWaitDuration: 0
@GetMapping
@Bulkhead(name = "readVersion", type = Bulkhead.Type.THREADPOOL)
public ResponseEntity<ReadVersionResponse> readVersion(
    @RequestParam AppType appType
) { // 10 초 걸리는 작업
    return ResponseEntity.ok(versionApiService.readVersion(appType));
}

TimeLimiter

마지막으로 알아볼 resilience4j 패턴은 TimeLimiter입니다. TimeLimiter 패턴은 말 그대로 호출에 대해서 타임아웃을 설정할 수 있는 패턴입니다. 특이점은 해당 메서드는 CompleteFuture 기반으로 사용하실때 Future로 리턴될 수 있도록 해주셔야합니다.

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

사용할 수 있는 옵션은 아래와 같습니다.

  • cancelRunningFuture ( 기본값 : true )
    • timeout이 경과한 후 자동으로 future를 취소합니다.
  • timeoutDuration ( 기본값 : 1000 [ms] )
    • timeout 시간을 설정합니다
resilience4j:
  timelimiter:
    configs:
      default:
        cancelRunningFuture: true
        timeoutDuration: 2s

TimeLimiter를 실제로 사용하기 위해서는 유의하실점이 있습니다. 바로 CompletableFuture를 리턴값으로 사용하셔야한다는 것입니다. CompletableFuture는 Java 에서 지원하는 비동기 매커니즘의 하나입니다. 제가 만든 예제코드는 아래와 같습니다.

@TimeLimiter(name = "readVersion")
public CompletableFuture<ReadVersionResponse> readVersion() {

    return CompletableFuture.supplyAsync {

        Thread.sleep(10000)

        ReadVersionResponse(
            appType = appType,
            version = "11",
            developerEmail = DEFAULT_EMAIL
        )
    }
}

설정에는 TimeLimiter가 2초이기때문에 위 코드는 실패할것입니다. 실패할때 TimeoutException이 노출되면서 실패하는것을 아래 로그로 확인할 수 있었습니다.

java.util.concurrent.ExecutionException: java.util.concurrent.TimeoutException: TimeLimiter 'readVersion' recorded a timeout exception.
    at java.base/java.util.concurrent.CompletableFuture.reportGet(CompletableFuture.java:395) ~[na:na]

 

최근에는 CompleteFuture를 사용하기보다는 Reactor 라던지 코틀린의 코루틴을 사용하는 경우가 많습니다. CompleteFuture를 사용해야하는것은 Timelimiter의 제약사항으로 작용할 것으로 보입니다.

마무리

오늘까지 최근 3개의 포스팅을 통해서 resiliece4j를 이렇게 알아보고 사용해보는 시간을 가져보았습니다.

감사합니다.

참조

github_resilience4j

resilience4j_docs

댓글