Resilience4j CircuitBreaker 사용하기

ds_chanin

·

2021. 9. 26. 03:21


들어가며

Resilience4j는 넷플릭스의 히스트릭스에 영감을 받아 개발된 경량화 Fault Tolerance 라이브러리이다.
그 중 Circuit Breaker(이하 서킷브레이커)를 분석하고 적용하였다.

서킷브레이커의 상태

서킷 브레이커는 유한한 개수의 상태를 가질 수 있는 장치인 FSM(finite state machine)으로 세가지 일반적인 상태와 세가지 특수 상태로 나뉜다.

일반적인 상태는 다음과 같다.

  • CLOSED : 서킷브레이커가 닫혀 있는 상태로 서킷브레이커가 감싼 내부의 프로세스로 요청을 보내고 응답을 받을 수 있다.
  • OPEN : 서킷브레이커가 열려 있는 상태로 서킷브레이커는 내부의 프로세스로 요청을 보내지 않는다.
  • HALF_OPEN : 서킷브레이커가 열려 있는 상태지만 내부의 프로세스로 요청을 보내고 실패율을 측정해 상태를 CLOSED 혹은 OPEN 상태로 변경한다.

일반적인 상태는 요청의 성공 실패 metric을 수집하고 그에 따라 상태가 변한다.(상태변이, state transit)
CLOSED는 OPEN으로, OPEN은 HALF_OPEN으로, HALF_OPEN은 metric의 실패율에 따라 CLOSED, OPEN 두 상태로 선택하여 상태변이를 한다.

이를 도식화하면 다음과 같다. 

특수 상태는 강제로 상태변이를 하지 않으면 될 수 없다.

  • DISABLED : 서킷브레이커를 강제로 CLOSED한 상태이다. 하지만 실패율에 따른 상태변화도 없고 후술할 이벤트 발행도 발생하지 않는다.
  • FORCED_OPEN : 서킷브레이커를 강제로 OPEN한 상태이다. DISABLED와 동일하게 상태변화도 없고 이벤트 발행도 하지 않는다.
  • METRICS_ONLY : 서킷브레이커를 강제로 CLOSED한 상태이다. DISABLED과 동일하게 상태변화는 없지만 이벤트 발행을 하고 내부 프로세스의 metric 수집한다.

 

서킷브레이커의 타입

앞서 서킷브레이커가 metric을 수집한다고 했다. 수집한 결과는 Sliding Window로 원형배열에 수집하고 방식은 두가지로 나뉘게 된다.

Count-based sliding window (카운트 기반)

카운트 기반은 n개의 원형배열로 구현된다. 원형 배열의 크기를 10으로 하면 10개의 측정 값을 유지하고 새로운 측정 값이 들어올때마다 가장 오래된 측정 값을 제거한 뒤 총 집계를 갱신한다.

Time-based sliding window (시간 기반)

시간 기반도 동일하게 n개의 원형배열로 구현된다. n은 시간(초, epoch second)단위로 10으로 설정하면 1초씩 10개의 부분 집계 버킷가 생긴다. 동일하게 시간이 흐르면 가장 오래된 부분 집계 버킷이 제거되고 총 집계가 갱신된다.

서킷브레이커의 OPEN 상태전이

앞에서 설명한 두 가지 기반 모두 요청이 실패했다고 취급하는 방식은 같다. 취급이라고 한 이유는 요청이 성공한 경우에도 실패로 측정하는 경우가 있기 때문이다. 먼저 일반적으로 exception이 발생하면 실패라고 측정한다. 그리고 응답이 성공적이었음에도 slow call(느린호출)이라고 인식하면 실패로 측정한다.

예를들어 카운트 기반의 서킷브레이커의 현재 상태가 CLOSED, 설정한 실패율, 느린호출 비율이 임계치가 50퍼센트일때 총 4개의 요청을 보낸다고 가정하자.

각 case는 독립적이다.

  • case1. 1개의 요청에서 exception 발생 ➡️ 상태변이 없음
  • case2. 1개의 요청에서 slow call 발생 ➡️ 상태변이 없음
  • case3. 1개의 요청에서 exception, 1개의 요청에서 slow call 발생 ➡️ 상태변이 없음
  • case4. 2개의 요청에서 exception 혹은 slow call 발생 ➡️ OPEN으로 상태변이
  • case5. 1개의 요청에서 exception, 1개의 요청에서 slow call exception 발생 ➡️ OPEN으로 상태변이

즉, 서킷브레이커의 OPEN 상태전이는 임계치로 지정한 실패율(실패+느린호출 실패) 혹은 느린호출 비율 중 하나만 달성해도 발생한다.

 

서킷브레이커 사용

서킷 브레이커는 직접 객체를 사용하는 방법과 애너테이션을 이용해 AOP 방식으로 사용하는 방법으로 나뉘는데 직접 객체를 사용하는 방법을 택하였다.

객체 사용을 선택한 이유는 다음과 같다.

  1. AOP 방식으로 사용하려면 application.yaml에 지정한 서킷브레이커를 비롯해 데코레이팅 할 기능의 애너테이션에 모두 동일하게 이름을 적어줘야 하는데 실수할 여지가 있다.
  2. 데코레이팅 할 기능의 순서를 지정하기 쉽다.
  3. 특정 exception을 블랙리스트, 화이트리스트로 등록하는 기능을 사용하기 위해 AOP 방식을 사용하면 해당 exception의 풀 패키지 경로를 application.yaml에 적어야 하는데 코드로 구현하면 exception을 풀 패키지 경로를 적다가 실수 할 일이 없다.
  4. 서킷브레이커에 기능에 대한 테스트에 스프링 컨텍스트가 필요없다.

 

필요 의존성

api("io.github.resilience4j:resilience4j-spring-boot2:${resilience4j}")
api("io.github.resilience4j:resilience4j-all:${resilience4j}")

resilience4j 버전은 메트릭 수집과정에서 에러가 발생하는 이슈가 있어서 1.3.1을 선택했다.

순서를 지정하고 데코레이팅 기능을 사용하기 위해 all 의존성을 추가하였고 스프링 auto-configuration 기능과 메트릭 수집을 위해 spring-boot2 의존성을 추가하였다.

값 설정

코드에서 지정할 수 있는 값만 다루도록 하겠다. 이외의 값은 공식문서 에서 확인 할 수 있다.

  • name: 서킷브레이커의 이름
  • failureRateThreshold: 실패 비율의 임계치
  • slowCallRateThreshold: 느린 호출의 임계치
  • slowCallDurationThreshold: 느린 호출로 간주할 시간 값
  • slidingWindowType: 서킷브레이커의 타입을 지정한다. TIME_BASED, COUNT_BASED 중 택 1
  • slidingWindowSize: 시간은 단위 초, 카운트는 단위 요청 수
  • minimumNumberOfCalls: 총 집계가 유효해 지는 최소한의 요청 수. 이 값이 1000이라면 999번 실패해도 서킷브레이커는 상태변이가 일어나지 않는다.
  • waitDurationInOpenState: OPEN에서 HALF_OPEN으로 상태변이가 실행되는 최소 대기 시간
  • permittedNumberOfCallsInHalfOpenState: HALF_OPEN 상태에서 총 집계가 유효해지는 최소한의 요청 수. COUNT_BASED로 slidingWindowType이 고정되어 있다.
  • automaticTransition: true라면 waitDurationInOpenState로 지정한 시간이 지났을 때 새로운 요청이 들어오지 않아도 자동으로 HALF_OPEN으로 상태변이가 발생한다.
  • ignoreExceptions: 해당 값에 기재한 exception은 모두 실패로 집계하지 않는다.
  • recordExceptions: 해당 값에 기재한 exception은 모두 실패로 집계한다.

 

설정 및 생성 Sample Code

resilience4j:
  circuitbreaker:
    configs:
      amlConfig:
        registerHealthIndicator: true
        automaticTransitionFromOpenToHalfOpenEnabled: true # open -> half 자동변환 on/off
        slidingWindowSize: 100 # 통계 큐 사이즈
        permittedNumberOfCallsInHalfOpenState: 10 # half 내 통계 큐 사이즈
        waitDurationInOpenState: 300000ms # 5min open 대기시간
        failureRateThreshold: 50
        slidingWindowType: COUNT_BASED
        minimumNumberOfCalls: 20
        slowCallRateThreshold: 50
        slowCallDurationThreshold: 5000ms #5s
    #instances: 인스턴스는 등록하지않고 수동으로 등록하였다.

yaml 파일에 config와 instances 를 설정할수 있는데 instances 를 설정하면 CircuitBreakerRegistry에 바로 객체가 등록이 되고 선택해서 사용할 수 있게된다.

하지만 exception 설정을 조금 더 안전하게 할 수 있도록 configs 만 설정하도록 하였다.

object CircuitBreakerBuilderFactory {
     
    fun CircuitBreakerProperties.createCustomCircuitBreakerConfig(
        backendName: String,
        configName: String,
        ignoreExceptions: List<Class<out Throwable>> = emptyList(),
    ): CircuitBreakerConfig {
        val amlCircuitBreakerProperties = this.configs[configName]
        amlCircuitBreakerProperties!!.ignoreExceptions = ignoreExceptions.toTypedArray()


        this.instances[backendName] = amlCircuitBreakerProperties // for health indicator


        return this.createCircuitBreakerConfig(
            backendName,
            amlCircuitBreakerProperties,
            CompositeCustomizer(
                listOf(
                    CircuitBreakerConfigCustomizer.of(backendName) { }
                )
            )
        )
    }
}

위에서 정의한 yaml파일에 의해 CircuitBreakerProperties에 configs에 설정한 값이 존재한다. 해당 값을 이용해서 backendName, configName을 이용해서 CircuitBreakerConfig를 구성하는 InstanceProperties를 가져와서 ignoreException 등록을 해주었다.

그리고 instances에 InstanceProperties를 똑같이 등록해주는데 이 작업은 메트릭 수집을 할때 필요하다.

이렇게 생성한 CircuitBreakerConfig를 이용해서 CircuitBreakerRegistry에서 새로운 CircuitBreaker를 생성하고 등록하도록 한다. CircuitBreakerRegistry는 spring-boot2 의존성에 의해 미리 Bean으로 등록되어있다.

val circuitBreaker = circuitBreakerRegistry.circuitBreaker(backendName, circuitBreakerConfig)

그리고 위와같이 생성된 서킷브레이커는 그대로 사용하지 않고 all 의존성에 있는 Decorators를 이용해서 데코레이팅하여 사용하였다.

class BaseCircuitBreaker(
    private val delegate: CircuitBreaker,
    fallbackThrowableCollection: Collection<Class<out Throwable>> = emptyList(),
) {
    private val fallbackThrowableCollection =
        listOf(CallNotPermittedException::class.java, *fallbackThrowableCollection.toTypedArray())


    fun <T> execute(
        method: () -> T,
        fallbackMethod: ((e: Throwable) -> T)? = null,
    ): T {
        val decorators = Decorators.ofCallable(method)
            .withCircuitBreaker(delegate)


        fallbackMethod?.run {
            decorators.withFallback(fallbackThrowableCollection, this)
        }


        return decorators.decorate()
            .call()
    }


}

위 코드에서는 서킷 브레이커가 open 되었을때 실행되는 fallbackMethod만 데코레이팅 하여 사용하고 있지만 상황에 따라 메소드 인터페이스를 수정하면 resilience4j의 다른 기능을 데코레이팅해서 사용할 수 있다.

example

class AmlFeignClient(
    private val amlFeignApi: AmlFeignApi,
    private val circuitBreaker: BaseCircuitBreaker,
) : AmlClient {
    private val log = logger()


    override fun verifyCdd(request: CddVerificationRequestDto): CddVerificationResponseDto {
        return circuitBreaker.execute(
            method = {
                val response = amlFeignApi.verifyCdd(request)


                response.checkThrowable()


                response.toResponseDto()
            },
            fallbackMethod = {
                log.warn { "AML CircuitBreaker Opened." }
                CddVerificationResponseDto.byPass()
            }
        )
    }


}

서킷브레이커 Decorate

Resilience4j는 서킷브레이커외에 여러가지 기능을 제공한다.

  • Bulkhead : 동시 실행 횟수를 제한한다.
    1. 세마포어 활용 방식
    2. 유한(Bounded) 큐와 스레드 풀을 사용하는 방식
  • RateLimiter : 일정시간동안 요청수의 최대치를 제한한다.
  • Retry : 요청 실패에 따른 재시도를 관리한다.
  • TimeLimiter : Future와 함께 사용할때 timeout을 할 수 있게 해주고 timeout 발생시 cancel을 할 수 있도록 한다.
  • Cache : Decorator에 캐시 인스턴스를 제공하여 사용한다.

Decorator에서 체이닝한 순서의 반대로 적용되기 때문에 순서에 유의해야한다. 그리고 CLOSED, HALF_OPEN 상태일때만 실행된다.

이벤트 발행

이벤트 발행은 서킷브레이커의 eventPublisher를 통해 등록할 수 있다.

이벤트 등록을 할 때 주의할 점은 List에 add되는 구조이므로 동일한 이벤트를 두번 등록하면 두번 이벤트 발행이 이루어진다. 이벤트 등록을 유니크하게 하도록 주의해야한다.

circuitBreaker.eventPublisher.onStateTransition { } // 상태변이
circuitBreaker.eventPublisher.onCallNotPermitted { } // 서킷브레이커가 OPEN 일때 요청이 반려되는 경우
circuitBreaker.eventPublisher.onError { } // exception 발생시
circuitBreaker.eventPublisher.onFailureRateExceeded { } // 실패율 임계치 초과시
circuitBreaker.eventPublisher.onIgnoredError { } // white 리스트로 등록한 exception 발생시
circuitBreaker.eventPublisher.onReset { } // reset 메서드를 이용해서 CLOSED 상태변이를 발생시킬 때
circuitBreaker.eventPublisher.onSlowCallRateExceeded { } // 지연시간비율 임계치 초과시
circuitBreaker.eventPublisher.onSuccess { } // 내부 프로세스 성공시
circuitBreaker.eventPublisher.onEvent { } // 모든 이벤트 발생시

메트릭 수집

management:
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
        resilience4j.circuitbreaker.calls: true
  health:
    circuitbreakers:
      enabled: true
  endpoint:
    health:
      show-details: always
  endpoints:
    web:
      exposure:
        include: prometheus, health, metrics

actuator 의존성을 추가한 뒤 서킷브레이커 상태로 확인을 위해서라면 management.health.circuitbreakers.enabled=true 와 management.endpoints.web.exporsure.include=health, metrics 만 필요하지만

프로메테우스로 메트릭 수집후 확인하기 서킷브레이커 관련 메트릭을 확인을 위해 application.yml 에 위와 같이 설정해주도록 합니다.

'스터디 > Kotlin' 카테고리의 다른 글

코틀린 초간단 sha-256 만들기  (2) 2023.06.14
[오늘의 에러] Kotlin Internal Test 에러 해결  (0) 2021.02.10