11월에 운 좋게 티켓팅에 성공해서 우아콘에 참가하게 되었고 강연을 듣던 중 회로 차단기 기술에 대해서 알게 되었다. 그걸 듣고 "내가 하고 있는 프로젝트에도 사용할 수 있겠는데?" 라는 생각으로 회로 차단기 기술을 프로젝트에 적용하기로 하였다.
회로에서 스위치가 닫힘(CLOSED) 상태면 전기가 흐르고 열림(OPEN) 상태면 전기가 끊기는 것처럼
여기서 사용한 회로 차단기라는 것도 처음엔 CLOSED 상태로 외부 서버와 통신을 하는데 만약 외부 서버에 문제가 발생했다면 OPEN 상태로 전환해서 외부 서버에게 더 이상 요청을 보내지 않게 하는 기술이다. 그러면 서버의 리소스 낭비를 막을 수 있고 장애가 발생한 외부 서버와의 네트워크 통신 비용도 절약 가능하다는 장점이 있다.
대표적인 라이브러리로 Resilience4j와 Hystrix이 있지만 Hystrix는 18년 이후로 개발이 진행되고 있지 않아서 Resilience4j를 사용하기로 하였다.
- CLOSED 상태: 요청이 정상적으로 처리되는 상태로 만약 일정 횟수의 실패가 발생하면 OPEN 상태로 전환된다.
- OPEN 상태: 이 상태에서는 모든 요청이 실패로 처리되고, 일정 시간이 지난 후 HALF-OPEN 상태로 전환된다. 리소스의 낭비를 막기 위해 실패한 서비스를 일시적으로 사용할 수 없게 만든다.
- HALF-OPEN 상태: OPEN에서 일정 시간이 지나면 HALF-OPEN 상태로 넘어가는데 이 상태에서는 일부 요청만 허용되며 이 요청들이 성공하면 CLOSED 상태로 돌아가고 실패하면 다시 OPEN 상태가 된다.
CLOSED 와 OPEN 으로 회로의 상태를 나타내는데 충분한데 왜 HALF_OPEN 상태도 있을까?
만약 OPEN 에서 일정 시간이 지나고 외부 서버가 아직 정상 동작하지 않는데 바로 CLOSED 로 된다면 CLOSED 에서 걸어둔 일정 횟수의 실패동안 요청을 보내야 되는 상황이 발생한다.
그래서 중간에 HALF_OPEN 에서 CLOSED 보다는 적은 횟수로 요청을 보내서 외부 서버가 정상적으로 복구되었는지를 점검하여 리소스 낭비를 줄일 수 있게 해준다.
외부 서버에 요청을 하기 전 요청을 할 수 있는지 CircuitBreaker 로 확인한다.
허가해주면 외부 서버로 요청을 보낸다.
만약 요청이 실패한다면 실패율을 집계하고 일정 실패율을 넘어간다면 회로는 CLOSED 상태가 된다.
그 후 요청은 CallNotPermittedException 이 발생하여 외부 서버에게 요청을 보내지 않아도 사용이 불가능하다는 것을 빠른 시간에 알 수 있게 된다.
실패율을 계산하는 방법에 대한 기술이다. 2가지 중 하나를 선택해서 상황에 맞게 사용하면 된다.
- 개수 기반 슬라이딩 윈도우(Count-based sliding window)
N 크기의 배열에 요청에 대해 성공과 실패 결과를 기록하고 그 배열에서 실패율을 계산하여 회로 상태를 판단하는 방법이다. 만약 배열이 꽉 찬 상태에서 결과 값이 또 들어오면 Queue 와 유사하게 가장 오래된 측정값이 제거되고 그 순간 또 실패율을 계산하는 형태라고 보면 된다.
- 시간 기반 슬라이딩 윈도우(Time-based sliding window)
지정한 시간 이내에 최소 호출 수(minimumNumberOfCalls) 만큼 실패율을 계산하여 회로 상태를 판단하는 방법이다. 만약 지정한 시간은 3초, 최소 호출 수은 5 그리고 실패율은 40%에 회로가 OPEN 상태로 변한다고 한다면 2개의 호출만 실패한다면 회로가 OPEN 상태로 변한다고 생각하기 쉽지만 제일 먼저 3초 이내에 최소 호출 수만큼 결과값이 생겨야 실패율을 계산하기 때문에 실패한 2개 호출만 들어왔다고 OPEN 상태로 변하지 않는다.
개수 기반 슬라이딩 윈도우는 호출 패턴이 일정하고 예측 가능할 때 적합한 것 같고,
시간 기반 슬라이딩 윈도우는 호출 패턴이 불규칙할 때 적합한 것 같다.
자바17 부터는 Resilience4j 2버전을 사용하면 되지만 나는 자바11 를 사용 중이므로 1버전을 사용했다.
implementation 'io.github.resilience4j:resilience4j-reactor:1.7.1'
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:1.7.1'
현재 프로젝트에서는 WebClient 비동기 통신을 하기 때문에 resilience4j-reactor 라이브러리도 추가하였다.
https://resilience4j.readme.io/docs/circuitbreaker#create-and-configure-a-circuitbreaker 공식 사이트에 설정부터 생성까지 아주 자세히 나와 있어서 이걸 참고하면서 적용하였다.
설정값에 대해 잘 이해했다면 적용은 어렵지 않으니 내 프로젝트에 적용한 설정을 가지고 정리하였다.
우선 사용자가 외부 서버로 요청을 보내는 수가 한번에 1~1000개라서 시간 기반 슬라이딩 윈도우를 사용하는 것이 적합하겠지만 비동기 통신이라 중간에 예외가 발생하면 더 이상 요청을 안 보내고 중단하기 때문에 실패율을 제대로 계산하기가 어려웠다. 그래서 개수 기반 슬라이딩 윈도우로 적용하였다. (개수 기반은 default이므로 아무런 설정을 안 해도 된다.)
다음은 설정에 대한 설명이다.
편의상 매개변수를 N이라고 쓰겠음.
ex) FAILURE_RATE_THRESHOLD => N
- failureRateThreshold( N )
SLIDING_WINDOW_SIZE 수에서 N% 실패하면 OPEN으로 전환
- slowCallRateThreshold( N )
느린 호출 비율이 SLDING_WINDOW_SIZE 수에서 N% 비율일 경우 OPEN으로 전환
- slowCallDurationThreshold( N )
느린 호출의 시간 기준 N초
- waitDurationInOpenState( N )
N초 후 OPEN -> HALF-OPEN이 됨
- permittedNumberOfCallsInHalfOpenState( N )
HALF-OPEN에서 N개 요청을 허용함. 여기서 다시 실패율을 계산하고 회로의 상태를 판단
- slidingWindowSize( N )
본 프로젝트는 Count-based 이므로 N개의 배열 안에서 요청에 대한 성공과 실패 결과값을 저장하고 그 안에서 실패율을 계산함
- recordExceptions(WebClientException.class, IOException.class, TimeoutException.class)
이런 예외일 경우 실패로 간주함.
실제로 적용한 값들로 설명하자면 다음과 같다.
1. 4개(SLIDING_WINDOW_SIZE) 요청 모두 100%(FAILURE_RATE_THRESHOLD) 실패하면 OPEN 상태로 전환됨.
2. 3분(WAIT_DURATION_IN_OPEN_STATE_MILLIS) 후에 자동으로 OPEN -> HALF-OPEN 상태로 전환됨.
3. HALF-OPEN 상태에서 1번(PERMITTED_NUMBER_OF_CALLS_IN_HALF_OPEN_STATE) 요청이 성공한다면 HALF-OPEN -> CLOSED 상태로 전환됨.
4. 만약 처리 속도가 7초(SLOW_CALL_DURATION_THRESHOLD_SECONDS) 를 넘어가면 실패로 간주하는데 4개(SLIDING_WINDOW_SIZE) 의 요청 중 10%(SLOW_CALL_RATE_THRESHOLD) 가 그런다면, 즉 4번의 요청 중 1번이라도 처리 속도가 30초를 넘어간다면 다시 OPEN 상태로 전환됨.
설정이 완료된 회로 차단기를 위와 같이 transformDeferred(CircuitBreakerOperator.of(circuitBreaker)) 로 통신 데이터 스트림에 구독 권한을 획득할 수 있는 회로 차단 메커니즘을 추가한다.
그렇다면 어떻게 transformDeferred() 이 설정 하나로 acquirePermission()을 확인하고 비동기 통신을 수행한다음 결과값을 계산할 수 있는거지? 의문이 들었다.
우선 transformDeferred() 는 데이터 스트림 발행을 지연시켜 Mono(Reactor 라이브러리에서 제공되는 데이터 스트림을 표현한 클래스) 를 다른 형태를 가진 새로운 Mono로 변경해 주는 메소드이다.
메소드를 들여다보면 초록색 사각형에서
Function<? super Mono<T>, ? extends Publisher<V>> transformer
이런 이상한 타입의 매개변수를 받는데 Function<T, R>은 함수형 인터페이스로 반환값이 T 타입이고 매개변수는 R 타입인 형태이다. 그래서 transformDeferred() 의 반환 타입은 Mono이며, 파라미터로 CircuitBreakerOperator.of()를 받는데
public class CircuitBreakerOperator<T> implements UnaryOperator<Publisher<T>>
CircuitBreakerOperator는 UnaryOperator<Publisher>을 구현하고 있으며 UnaryOperator는 Function 함수형 인터페이스를 상속받고 있다.
빨간색 사각형에서 this는 처음에 데이터 스트림을 발행한 WebClient이다.
따라서 CircuitBreaker가 WebClient를 감싼 형태로 새로운 Mono 로 변경하여 실행하게 된다 정도로만 이해하였다.
이러면 제일 먼저 acquirePermission()을 수행하여 회로의 상태를 파악할 수 있게 된다.
https://stackoverflow.com/questions/67695992/resilience4j-time-based-circuit-breaker-behaves-as-count-based?rq=3
https://stackoverflow.com/questions/71098633/difference-between-sliding-window-size-and-minimum-number-of-calls
https://resilience4j.readme.io/docs/getting-started