JVM/SpringCloud

Resilience4J - CircuitBreaker

kyoulho 2024. 1. 2. 19:29

Circuit Breaker


서킷 브레이커는 연결의 성공/실패를 카운트하여 실패율이 임계치를 넘어섰을 때 자동적으로 접속을 차단하는 시스템이다. 마이크로서비스 아키텍처에서 서비스 간 통신에서 발생할 수 있는 장애와 지연으로부터 전체 시스템의 안정성을 유지하는데 도움이 되는 중요한 디자인 패턴이다.

Sliding Window

서킷 브레이커는 슬라이딩 윈도우를 통해 호출 결과를 저장하고 집계한다. 슬라이딩 윈도우의 방식은 마지막 N개의 호출 결과를 집계하는 카운트 기반과 마지막 N초 간 일어난 호출 결과를 집계하는 시간 기반이 있다.

 

 

Circuit Breaker State


 

닫힌(Closed) 상태

초기 상태로, 모든 호출이 허용된다. 서비스 호출 중 에러 비율이 설정된 임계값을 넘어가면 서킷 브레이커는 열린 상태로 전환된다. 느린 호출의 비율이 설정된 임계값을 넘어가도 열린 상태로 전환된다.

 실패율과 느린 호출 비율의 값은 지정된 최소 호출 횟수 이후부터 측정된다.

열린(Open) 상태

서비스 호출이 차단된다. 모든 호출은 Fallback 메커니즘 또는 CallNotPermittedException를 반환하도록 처리된다. 열린 상태에서의 대기 시간(wait-duration-in-open-state)이 지난 후, 서킷 브레이커는 반만 열린 상태로 전환된다.

반만 열린(Half-Open) 상태

서비스 호출이 허용되는 상태지만 서비스 호출 중 에러가 발생하면 다시 열린 상태로 전환된다. 호출이 성공하면 닫힌 상태로 전환된다

 

 

Resilience4J


Resilience4J는 함수형 프로그래밍으로 설계된 경량(lightweight) 장애 허용(fault tolerance) 라이브러리로, 서킷브레이커 패턴을 위해 사용된다. Resilience4j는 Circuit Breaker, Rate Limiter, Retry 또는 Bulkhead를 사용하여 함수형 인터페이스, 람다식, 또는 참조형 메서드를 향상하는 데코레이터를 제공한다. 한 번에 둘 이상의 데코레이터를 쓸 수 있으며, 필요한 데코레이터만 작동하게 끔 설정할 수 있다. 참고로 자바 진영의 서킷 브레이커 라이브러리로는 Hystrix도 있지만 deprecated 되었으므로 Resilience4J를 사용하자.

 

라이브러리

implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")

 

Circuit Breaker 설정 및 인스턴스 

resilience4j:
  circuitbreaker:
    configs:
      default:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 5
        minimum-number-of-calls: 10
        permitted-number-of-calls-in-half-open-state: 2
        wait-duration-in-open-state: 5000
        failure-rate-threshold: 50
        automatic-transition-from-open-to-half-open-enabled: true
        record-failures: true
        record-successes: true
        ignore-exceptions:
          - java.io.IOException
          - java.net.SocketTimeoutException
    instances:
      myCircuitBreaker:
        base-config: default
        register-health-indicator: true
        event-consumer-buffer-size: 10
Property 설명 기본 값
failure-rate-threshod 실패율 임계값
실패율이 임계값보다 크거나 같다면 상태를 OPEN으로 변경하고 모든 호출 차단
50%
slow-call-duration-threshold 호출 시간 임계값
해당 시간보다 호출 시간이 길어진다면, 서킷브레이커는 해당 호출을 느린 호출로 판단
60000ms
slow-call-rate-threshold 느린 호출 임계값
느린 호출 비율이 임계값보다 크거나 같다면 상태를 OPEN으로 변경하고 모든 호출을 차단
100%
permitted-number-of-calls-in-half-open-state HALF_OPEN일 때, 허용되는 호출 수 10
max-wait-duration-in-half-open-state 서킷브레이커가 HALF_OPEN 상태에서 가장 오래 대기하는 시간.
해당 시간 이후 OPEN으로 변경. 값이 0이라는 것은 서킷브레이커가 허용된 모든 호출이 완료될때까지 HALF_OPEN상태에서 대기하는 것을 의미
0ms
sliding-window-type 슬라이딩 윈도우에서 서킷브레이커가 CLOSED인 경우 값을 집계할 때의 방식. 
COUNT_BASED ,TIME_BASED
COUNT_BASED
sliding-window-size 슬라이딩 윈도우의 크기. 해당 값은 서킷브레이커가 CLOSED인 경우 값을 집계할 때 사용 100
minumun-number-of-calls 느린 호출율을 계산하기위해 필요한 최소 호출 수 100
wait-duration-in-open-state OPEN에서 HALF_OPEN으로 변경시키기 전까지 대기하는 시간 60000ms
automatic-transition-from-open-to-half-open-enabled OPEN에서 HALF_OPEN으로 넘어갈 때, 자동으로 넘어갈 지 여부
false라면, wait-duration-in-open-state 시간이 지나가더라도 어떠한 호출이 일어나지 않으면 상태 변경이 일어나지 않는다.
false
record-exceptions 실패로 측정될 exception 리스트
만약 어떤 exception들을 지정하게 된다면,
해당 excpetion들을 제외한 모든 exception들은 성공으로 측정
empty
ignore-exceptions 실패와 성공 둘 다로 측정되지 않을 exception 리스트
해당 exception과 일치하거나 상속하는 exception은 실패 또는 성공으로 측정되지 않는다.
empty
record-failure-predicate 특정 exception이 실패로 측정되도록 하는 Custom Predicate
Predicate은 실패로 측정되고자 하는 exception은 true로, 성공으로 측정되고자 하는 경우는 false를 리턴해야 한다. 기본값에서는 모든 exception이 실패로 기록
throwable-> true
ignore-exception-predicate 특정 exception이 실패 또는 성공으로 측정되지 않도록 하는 Custom Predicate
Predicate은 무시해야 하는 exception은 true로, 실패로 측정되고자 하는 경우는 false를 리턴해야 합니다. 기본값에서는 어떤 exception도 무시되지 않는다.
throwable -> true

 

직접 인스턴스 생성하여 사용

@Service
class UserService(
    private val userRepository: UserRepository,
    private val orderClient: OrderClient,
    private val circuitBreakerFactory: Resilience4JCircuitBreakerFactory,
) {

    fun getUserByAll(): List<ResponseUser> {
        return userRepository.findAll().map {
            val responseUser = ResponseUser.from(it)
            val circuitBreaker = circuitBreakerFactory.create("circuitBreaker")

            responseUser.orders =
                circuitBreaker.run(
                    { orderClient.getOrders(responseUser.userId) },
                    { listOf() }
                )
            responseUser
        }
    }
}

 

@CircuitBreaker 애노테이션 사용

@Service
class UserService(
    private val userRepository: UserRepository,
    private val orderClient: OrderClient,
) {
    @CircuitBreaker(name = "myCircuitBreaker", fallbackMethod = "getUserByAllFallback")
    fun getUserByAll(): List<ResponseUser> {
        return userRepository.findAll().map {
            val responseUser = ResponseUser.from(it)
            responseUser.orders = orderClient.getOrders(responseUser.userId)
            responseUser
        }
    }
    
    private fun getUserByAllFallback() :List<ResponseUser>{
        return userRepository.findAll().map {
            ResponseUser.from(it)
        }
    }
}

 

FeignClient와 CircuitBreaker


CircuitBreakerNameResolver

CircuitBreaker 인스턴스는 여러 개로 관리될 수 있다.  서로 다른 서버들이 하나의 CircuitBreaker 인스턴스로 관리되면 문제가 발생될 수 있다. 그래서 이를 처리하기 위한 CircuitBreaker 인스턴스를 지정해주어야 하는데, OpenFeign은 해당 FeignClient가 어떤 인스턴스를 적용할지 식별할 수 있는 CircuitBreakerNameResolver 인터페이스를 제공해 준다. 해당 인터페이스를 구현하지 않으면 기본적으로 FeignClient의 이름과 메서드를 조합하여 사용하는 DefaultCircuitBreakerNameResolver가 사용된다. 만약 숫자와 알파벳만으로 설정을 하고 싶다면 alphanumeric-ids 옵션을 주면 된다. 참고로 이때 CircuitBreaker 인스턴스를 찾지 못한다면 인메모리에 새로운 인스턴스를 생성하게 된다.

@Component
class FeignClientNameCircuitBreakerNameResolver : CircuitBreakerNameResolver {
    
    override fun resolveCircuitBreakerName(feignClientName: String, target: Target<*>, method: Method): String {
        return feignClientName
    }
}

 

RecordFailurePredicate

recordFailurePredicate는 어떤 예외를 Fail로 기록할 것인지를 결정하기 위한 클래스이다. 해당 클래스에서 true를 반환하면 요청 실패로 기록되며, 실패가 쌓이면 서킷이 OPEN 상태로 변경되게 된다. OpenFeign과 연동하는 상황에서는 기본적으로 아래와 같이 작성할 수 있으며, 상황에 따라 커스터마이징 해주면 된다. 만약 timeLimiter에 설정한 연결 시간을 초과하거나 커넥션에 실패했다면 TimeoutException이 발생하는데, 해당 경우에는 서킷을 열어서 요청을 차단해야 하므로 true를 반환하도록 하였다. 또한 RetryableException은 Feign에서 던지는 Retry 가능한 예외인데, 해당 예외도 true로 반환하도록 하였다. 이는 상황에 따라 달라질 수 있으므로 false로 반환이 필요하다면 수정해 주도록 하자. 그리고 그 외에 FeignException 중에서 FeignServerException이라면 true를 반환하도록 되어있다. 해당 Predicate 클래스를 적용하려면 yaml 설정 파일에 recordFailurePredicate 내용을 추가해주어야 한다.

class DefaultExceptionRecordFailurePredicate : Predicate<Throwable> {
    override fun test(t: Throwable): Boolean {
        return when (t) {
            is TimeoutException -> {
                true;
            }
            // occurs in @OpenFeign
            is RetryableException -> {
                true;
            }
            else -> t is FeignException.FeignServerException
        }
    }
}

application.yml

spring:
  cloud:
    openfeign:
      circuitbreaker:
        enabled: true

resilience4j:
  circuitbreaker:
    configs:
      default:
        waitDurationInOpenState: 30s
        slowCallRateThreshold: 80
        slowCallDurationThreshold: 5s
        registerHealthIndicator: true
        recordFailurePredicate: com.example.userservice.config.DefaultExceptionRecordFailurePredicate

    instances:
      default:
        baseConfig: default
        
  timelimiter:
    configs:
      default:
        timeoutDuration: 6s 
        cancelRunningFuture: true

 

TimeLimiter의 Timeout 설정
기본적으로 TimeLimiter의 timeout 값은 CircuitBreaker의 slowCallDurationThreshold와 OpenFeign의 connectionTimeout, readTimeout 보다는 크게 설정되어야 한다. TimeLimiter의 timeout은 클라이언트로 작업을 위임하는 시간이라고 보면 되는데, 그래서 해당 설정 값은 상당히 중요하다. 예를 들어 timeout 값이 3초로 되어 있고, API 응답이 4초가 걸린다고 하자. 그러면 아래와 같은 상황이 발생할 수 있다.
작업 처리 시간을 3초로 설정함(TimeLimiter)-> 해당 요청 처리가 일시적으로 4초로 지연됨 -> 작업 처리 시간이 만료되어 Fallback 처리 또는 예외 발생작업 처리 -> 시간이 만료되었으므로 Retry 등도 처리하지 않음

TimeLimiter의 timeout은 전체 작업 처리 시간의 timeout에 해당한다. 그러므로 위와 같은 경우라면 작업 처리 시간이 만료되었으므로 Retry 등도 시도하지 않을 것이다. 이러한 상황이 생기지 않도록 timeout 값은 신중히 설정되어야 한다.
 그래야 응답이 조금 오래 걸리는 상황에서도 정상적으로 처리가 가능하다. 그 외에 slowCall에 대해서도 재시도를 고려한다면 조금 더 큰 값으로 설정해 줄 수도 있을 것이다.

Feign Client

@FeignClient(name = "ORDER-SERVICE", fallback = OrderClientFallback::class)
interface OrderClient {

    @GetMapping("/{userId}/orders")
    fun getOrders(@PathVariable userId: String): List<ResponseOrder>

}

@Component
class OrderClientFallback : OrderClient {
    override fun getOrders(userId: String): List<ResponseOrder> {
        return listOf()
    }
}

CallNotPermittedException 예외 처리

서킷이 OPEN 상태로 바뀐 상태에서 Fallback 함수가 없다면 요청을 차단하고 바로 CallNotPermittedException을 발생시킨다. 때문에 ControllerAdvice에서 예외 처리를 해줘야 할 경우가 있다.

@ExceptionHandler(CallNotPermittedException.class)
fun handleCallNotPermittedException(CallNotPermittedException e): ResponseEntity<?> {
     return ResponseEntity.internalServerError()
             .body(Collections.singletonMap("code", "InternalServerError"))
}