Resilience4J 는 서비스의 가용성을 위해 MSA 가용성 향상을 위한 장애 전파 문제를 방지하기 위한 라이브러리이다.
Circuit Breaker 를 포함하여 아래와 같은 장애 전파 패턴을 제공한다.
resilience4j-circuitbreaker
회로차단기, 일정 횟수 이상 실패하면 해당 서비스 호출을 잠시 차단하고, 일정 시간이 지나면 재시도.
resilience4j-bulkhead
격벽, 서비스 자원을 분리해 장애가 한쪽 풀(pool)에서만 발생하도록 제한.
resilience4j-ratelimiter
속도제한기, 시간별 허용 요청 수를 제한.
resilience4j-retry
재시도, 실패시 자동으로 재시도, 네트워크 지연이나 일시적 장애에 효과적.
resilience4j-timelimiter
타임아웃, 특정 시간 안에 응답이 없으면 강제로 실패 처리.
환경에 맞춰 2가지 이상의 패턴을 조합하여 사용하는것이 일반적이다.
TimeLimiter + Retry: 느린 서비스가 일시적으로 실패할 때 재시도 CircuitBreaker + Fallback: 계속 장애 시 차단 후 대체 로직 Bulkhead + RateLimiter: 리소스 보호 + 과도한 요청 제한
Resilience4J 라이브러리도 결국은 메서드의 겉을 try..catch 문으로 감싸고 개발자가 정의한 로직대로 움직이도록 지원하는 정교한 라이브러리일 뿐이며 각종 람다식과 데코레이터 패턴을 구성하여 좀더 유지보수하기 쉽고 간결하게 구현하였을 뿐이다.
위의 Resilience4J 의 코어 라이브러리 별 역할을 알아보고 어떻게 우리가 정의한 메서드에 적용시킬 수 있는지 알아본다.
CircuitBreaker
MSA 환경의 깊숙한 서비스에서 에러가 발생할 경우 해당 에러는 다시 최 전방으로 응답된다. 그 사이의 모든 서비스들이 4XX, 5XX 에러로그가 출력되고 에러를 응답하게 된다.
하나의 서비스 장애가 결국 다른 서비스로의 장애 전파로 이뤄지기 때문에 서비스의 장애가 발견되면 개발자의 장애 대응 로직으로 전환될수 있도록 해야한다. 즉 Cacading Failure 를 방지하기 위해 Circuit breaker 를 사용한다 할 수 있다.
장애를 발견하고 대응 로직으로 이동시키는 것을 Circuit breaker 라 한다.
그림과 같이 connection problem 이 발생해 2번 이상 time out 에러가 발생하면 circuit breaker 을 동작시킨다.
Resilience4J 에서 Circuit breaker 는 3가지 상태를 할당받는 회로차단기이자 state machine 이다.
closed
open
half-open
함수 호출의 실패율이 임계값을 넘으면 closed 에서 open 으로 변경된다. open 상태일 때 메서드 호출을 거부하고 설정한 대기시간이 지나면 half-open 한다. half-open 상태에서 메서드 호출을 허용하고 설정한 실패 임계치에 따라 다시 open 할지 closed 할지 결정한다.
임계치를 통한 상태의 결정은 sliding window 를 통해 이루어지며 설정한 sliding window 개수 안에 임계치가 넘는 장애가 발생하면 closed 에서 open 으로 상태가 변경된다.
sliding window 는 time based, count based 가 있으며 기본값은 COUNT_BASED 이다.
실패 비율 및 각종 설정을 할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// Create a custom configuration for a CircuitBreaker CircuitBreakerConfigcbc= CircuitBreakerConfig.custom() .failureRateThreshold(50) // 실패 비율 임계치 백분율, 상태의 전환점, default 50 .slowCallRateThreshold(50) // 느린 호출 임계치 백분율, 상태의 전환점, default 100 .slowCallDurationThreshold(Duration.ofSeconds(2)) // 느린호출 판단 임계치 .waitDurationInOpenState(Duration.ofMillis(1000)) // open에서 half-open으로 전환하기 전 기다리는 시간 .permittedNumberOfCallsInHalfOpenState(3) // half-open 시 허용 호출 수, default 10 .minimumNumberOfCalls(10) // slide window 를 위한 최소 호출 수, default 100 .slidingWindowSize(5) // close 상태에서 호출 결과를 기록할 때 쓸 window 크기, defualt 100 .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.TIME_BASED) // default COUNT_BASED .recordExceptions(IOException.class, TimeoutException.class) // 실패로 기록할 예외 .recordException(e -> true) // 모든 예외를 실패로 기록, 커스텀하여 false 반환하면 기록하지 않음 .ignoreExceptions(BusinessException.class, OtherBusinessException.class) // 실패, 성공으로 기록하지 않음 .build();
정의한 설정대로 CircuitBreakerRegistry 를 생성하고 CircuitBreakerRegistry 를 사용해 CricuitBreaker 인스턴스를 생성한다.
1 2 3 4 5 6 7 8 9 10
// config CircuitBreakerConfigcbc1= CircuitBreakerConfig.ofDefaults(); CircuitBreakerConfigcbc2= CircuitBreakerConfig.ofDefaults(); // registry CircuitBreakerRegistryregistry= CircuitBreakerRegistry.of(cbc1); // "default" key 로 들어감 registry.addConfiguration("demo-cbc", cbc2); // instance CircuitBreakercb1= registry.circuitBreaker("default-cb"); CircuitBreakercb2= registry.circuitBreaker("demo-cb", "demo-cbc"); CircuitBreakercb_default= CircuitBreaker.of("my-db", cbc1); // registry 안통하고 바로 생성 가능
CircuitBreakerRegistry 에서 CircuitBreakerConfig 를 HashMap 으로 관리하며 여러개 저장해두 었다 적재적소에 꺼내어 CircuitBreaker 인스턴스 생성이 가능하다.
앞으로 나올 Resilience4J 의 다른 core 라이브러리도 동일한 registry-config-instance 생성 구조를 가졌으니 참고
CircuitBreaker 의 유일한 구현체인 CircuitBreakerStateMachine 가 회로차단기로써 각종 이벤트들을 처리한다.
onSuccess: 메서드 정상완료시 발생
onError: 메서드 실패시 발생
onIgnoredError: 무시되는 예외일경우 발생
onReset: 초기화시 발생
onStateTransition: state 변경시 발생
onSuccess, onError, onIgnoredError 이벤트의 경우 메서드 호출시 발생하며 onReset 의 경우 의도적으로 reset 하지않는이상 발생할 일이 없다.
state 변경은 CircuitBreaker 의 핵심으로 config 에 설정한 임계값에 도달할 경우 state 가 변경되면서 onStateTransition 가 호출된다. 임계치 감시는 CircuitBreakerMetrics 클래스를 통해 이루어지며 처음 closed 상태에서 생성되었다가 CircuitBreaker 상태가 변경될 때 마다 새로 생성된다. onSuccess, onError 가 발생할 때 마다 이벤트가 호출되면서 CircuitBreakerMetrics 를 업데이트하는 구조이다.
서비스의 과도한 요청은 해당 서비스를 소비하는 다른 서비스의 장애를 야기함으로 MSA 환경에서 사용되는 패턴으로 타 서비스에 접근하는 동시 실행 수를 제한하는 패턴을 격벽패턴(Bulkhead) 이라 한다.
SemaphoreBulkhead: 세마포어 사용
FixedThreadPoolBulkhead: 고정된 스레드 풀을 사용
성능상 FixedThreadPoolBulkhead 사용을 권장
1 2 3 4 5 6 7
// SemaphoreBulkhead BulkheadConfigconfig= BulkheadConfig.custom() .maxConcurrentCalls(150) // 허용할 병렬 실행 수, default 25 .maxWaitDuration(Duration.ofMillis(500)) // 포화상태일 때 block 시간, default 0 .build(); BulkheadRegistryregistry= BulkheadRegistry.of(config); Bulkheadb1= registry.bulkhead("name1");
1 2 3 4 5 6 7 8 9 10 11 12
// FixedThreadPoolBulkhead ThreadPoolBulkheadConfigconfig= ThreadPoolBulkheadConfig.custom() .maxThreadPoolSize(10) // 최대 스레드 풀 크기, default availableProcessors .coreThreadPoolSize(2) // 코어 스레드 풀 크기, default availableProcessors - 1 .queueCapacity(20) // 대기열의 용량, default 100 .build(); ThreadPoolBulkheadRegistrytpbr= ThreadPoolBulkheadRegistry.of(config); // "default" key 로 들어감 ThreadPoolBulkheadtpb= tpbr.bulkhead("name1"); ThreadPoolBulkheadConfigtpbc= ThreadPoolBulkheadConfig.custom() .maxThreadPoolSize(5) .build(); ThreadPoolBulkheadbulkheadWithCustomConfig= tpbr.bulkhead("name2", tpbc);
RateLimiter
Rate limiting 은 서비스의 고가용성과 안정성을 확립하기 위해 API 요청(트래픽) 제한치를 넘어간 것을 감지했을 때의 동작, 제한 할 요청 타입 등을 정의할 수 있다. 간단히 제한치를 넘어선 요청을 거부하거나, 큐를 만들어 나중에 실행할 수도 있고, 어떤 방식으로든 두 정책을 조합해도 된다.
limit 감지는 아래 그림처럼 이루어 진다.
매 cycle 마다 갱신되는 period 가 있고 period 가 0일 때 접근시 정지(park) 했다가 다시 접근하는 구조이다.
onSuccess invoked, 2023-05-02T17:42:36.286077+09:00[Asia/Seoul]: CircuitBreaker 'backendService' recorded a successful call. Elapsed time: 1023 ms hello 2023-05-02T17:42:36.266595
CircuitBreaker, Retry, Bulkhead 모두 한 supplier 람다 메서드에서 동작해야 할 경우 아래와 같이 데로레이트 패턴으로 감싸면 된다.
위의 데코레이트 패턴으로 Resilience4j 를 적용할 때도 있겠지만 대부분의 경우 수작업으로 [CircuitBreakerir, Retry, Bulkhead] 객체를 생성해서 데코레이트 패턴으로 감쌓는 작업을 하지않는다. 아래와 같이 Spring Boot AOP 를 사용해 어노테이션으로 데코레이트 패턴을 구현한다.
@Override @CircuitBreaker(name = BACKEND_A) @Bulkhead(name = BACKEND_A) @Retry(name = BACKEND_A) public String failure() { thrownewHttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR, "This is a remote exception"); }
@Override @CircuitBreaker(name = BACKEND_A) @Bulkhead(name = BACKEND_A) public String ignoreException() { thrownewBusinessException("This exception is ignored by the CircuitBreaker of backend A"); } ... }
CircuitBreakerRegistry 에 CircuitBreakConfig 를 설정하고 위의 @CircuitBreakername 속성과 매핑해서 사용할 수 있다.
java config 보다 properties 기반 구성이 가독성이 좋음으로 properties 사용을 권장
Resilience4j AOP 어노테이션을 사용하는 경우 미리 생성해둔 CircuitBreaker 인스턴스를 부여하는 방식이기 때문에 RegistryStore 에 사전에 객체를 생성해서 저장해두어야 한다. 동일한 CircuitBreaker 인스턴스를 사용할 경우 state 가 open 으로 변경되면 해당 CircuitBreaker 인스턴스를 사용하는 모든 메서드는 예외처리됨으로 인스턴스를 생성하고 등록하는 과정을 꼭 거쳐야한다.
비단 CircuitBreaker 뿐 아니라 Bulkhead, Retry 모두 Register 구조를 따르고 있음으로 동일하게 구성하면 된다.
어노테이션과 AOP 를 사용하면 자동 생성되는 코드는 아래와 같은 순서로 데코레이트된다. properties 를 사용해 순서를 변경 가능함으로 참고
public String failureFallback(Exception e) { log.info("failureFallback invoked, error type:{}, msg:{}", e.getClass().getSimpleName(), e.getMessage()); return"failure invoked but return string"; }
Retry 의 경우 본 메서드 실패시 Fallback 메서드 실행된다(Fallback 메서드가 실패하면 다시 Retry 의 반복) CircuitBreaker 의 경우 state open 되어야 Fallback 메서드가 실행된다. RateLimiter 의 경우 지정된 period 한계를 넘었을 때 Fallback 메서드가 실행된다. Bulkhead 의 경우 세마포어 혹은 스레드 풀이 다 차서 호출할 수 없을 때 Fallback 메서드가 실행된다.
메서드 실행시 단순 예외 발생으로 인해 Fallback 호출하는 경우는 Retry 의 fallback 속성밖에 없다.
라이브러리가 다르기에 위에있는 Decorators 가 아닌 FallbackDecorators 를 사용해 구현하였지만 근본적으로 데코레이트한 메서드에서 예외가 발생하면 예외 종류별로 지정해둔 핸들러메서드를 호출하는 구조는 동일하다.
Retry, CircuitBreaker, RateLimiter, Bulkhead 별로 발생하는 예외의 종류가 다르기에 별도로 Fallback 에 헨들러메서드를 등록해두는 것 뿐이다.
fallback with openfeign
@FeignClient 에도 fallback 속성이 있지만 자체적인 데코레이트 코드를 사용하는 것이 아니고 Resilience4j 에 의존적인 코드이다.
CircuitBreakerFactoryBean 의 존재여부에 따라 DefaultTargeter 를 통해 일반적인 Feign 객체를 만들지, FeignCircuitBreakerTargeter 를 통해 CircuitBreaker 로 데코레이트된 Feign 객체를 만들지 결정된다.
CircuitBreakerFactory, CircuitBreaker 모두 interface 로 Spring Cloud 라이브러리에 구현체는 존재하지 않는다. 따라서 직접 구현하거나 Resilience4j 에 정의되어 있는 CircuitBreakerFactory, CircuitBreaker 구현체를 사용해야 한다.
당연히 Resilience4j 를 사용하는 것을 권장…
Resilience4j 라이브러리를 의존하면서 자연스럽게 모든 Feign 객체들은 FeignCircuitBreakerTargeter 를 통해 생성될 것이고 fallback 속성또한 데코레이트 된다.
feign 의 fallback 속성은 모든 예외에 대해 fallback 메서드로 전달된다.
위에서 나온 FeignDecorators.builder 로도 fallback 구성이 가능하다.