장애 전파 대응을 위한 Resilience4j Circuit Breaker 실습

kindof

·

2023. 11. 6. 22:08

MSA 환경에서 한 서비스에 문제가 생겼을 때 다른 서비스에 영향을 최소화하는 것은 매우 중요합니다.
 
서로 의존성이 있는 두 서비스 중 한 서비스에서 발생하는 네트워크 지연 장애, 캐시 노드 장애, 내부 로직의 장애는 산불처럼 결국 두 서비스 모두에 전파되어 더 큰 장애 상황을 만들기 때문입니다.
 
 

1. Resilience4j

Resilience4j 라이브러리는 CircuitBreaker, RateLimiter, Retry, Bulkhead, TimeLimiter 등과 같은 다양한 장애 대응 패턴을 구현하고 제공합니다.
 

  • Circuit Breaker: Circuit Breaker 패턴을 구현하며, 서비스 호출의 장애를 모니터링하고 지정된 임계값 이상의 실패가 발생하면 서비스 호출을 차단하여 더 많은 장애를 방지합니다.
  • Rate Limiter: Rate Limiter 패턴을 구현하며, 특정 리소스에 대한 요청 속도를 제한하여 과도한 부하로 인한 서비스 장애를 방지합니다.
  • Retry: 재시도 메커니즘을 제공하여 서비스 호출 중 장애가 발생했을 때 자동으로 재시도하고, 지수 백오프 등의 전략을 사용하여 재시도 간격을 조절할 수 있습니다.
  • Bulkhead: Bulkhead 패턴을 구현하여 서비스 호출 간의 격리를 제공하고, 한 서비스 호출의 장애가 다른 서비스 호출에 영향을 미치지 않도록 합니다.
  • TimeLimiter: 서비스 호출의 최대 실행 시간을 제한하여 서비스 호출이 지나치게 길게 실행되는 것을 방지합니다.

 
이와 같이 Resilience4j 에는 여러 옵션이 있는데, 이번 글에서는 Springboot 3.0 + Resilience4j 2.1.0 환경에서 Resilience4j의 Circuit Breaker 를 통한 장애 대응 패턴을 실습해보고자 합니다.
 
참고로 Resilience4j 2는 Java 17 버전 이상을 필요로 합니다.
 
 

2. Resilience4j > CircuitBreaker?

Circuit Breaker는 기본적으로 반복된 실패에 대한 Fallback 대응과 장애 확산 방지를 목표로 합니다.
 
아래 그림은 Circuit Breaker 동작 과정을 간단한 그림으로 그려본 것입니다.
 

Circuit Breaker Mechanism

 
[1] 먼저 닫힌 상태의 Circuit Breaker에서는 기존에 동작하던 로직을 정상적으로 호출합니다.
 
[2] 이 때, 요청이 특정 임계값(횟수)보다 높은 빈도로 실패하게 되면 Open 상태로 진입하게 됩니다.
 
[3] Circuit이 Open 상태라는 것은 지속되는 장애가 있다는 것이므로 지속해서 기존 로직을 호출하는 것이 아닌 Fallback Method 호출을 통해 빈 응답을 내리거나 메서드를 리턴해버리는 작업을 진행할 수 있습니다.
 
[4] 특정 시간이 지나면 Half-Open 상태로 변화해서 기존 호출을 허용하게 되고, 같은 로직으로 장애 상황이 종료되었는지 판단합니다. 정상적인 응답이 돌아온다면 다시 Close 상태로 Circuit을 전환합니다.
 
이제 코드를 통해 실제 Circuit Breaker 동작을 실습해보겠습니다.
 

3. 코드 실습

3-1. build.gradle / application.yml 작성

아래와 같이 resilience4j 라이브러리를 사용하기 위한 의존성을 추가해줄 수 있습니다.

implementation("io.github.resilience4j:resilience4j-spring-boot3:${resilience4jVersion}")
implementation('org.springframework.boot:spring-boot-starter-actuator')
implementation('org.springframework.boot:spring-boot-starter-aop')

 
공식 문서에 따르면, resilience4j는 런타임에 org.springframework.boot:spring-boot-starter-actuator, org.springframework.boot:spring-boot-starter-aop 모듈이 존재하는 것을 기대하므로 위와 같이 두 모듈도 의존성에 추가해주었습니다.
 
그리고 아래와 같이 Circuit Breaker 실습을 위한 환경변수를 application.yml 파일에 작성합니다.

resilience4j.circuitbreaker:
    configs:
      default:                                      # 모든 CircuitBreaker 인스턴스에 공통 적용
        slidingWindowType: COUNT_BASED              # 호출 수 기반의 윈도우 사용
        slidingWindowSize: 10                       # 마지막 100개 호출에 대한 통계 사용
        permittedNumberOfCallsInHalfOpenState: 10   # Half-Open 상태에서 허용되는 호출 수
        waitDurationInOpenState: 10000              # Open 상태에서 대기하는 시간(ms). 이 시간동안 호출이 차단된다.
        failureRateThreshold: 30                    # CircuitBreaker가 Open되는 장애 비율 임계값
        eventConsumerBufferSize: 10                 # 이벤트 처리를 위한 버퍼 크기 설정
        registerHealthIndicator: true               # CircuitBreaker 상태를 Health Indicator로 등록할 지 여부
        ignore-exceptions:
          - java.lang.IllegalStateException         # 예상 가능한 예외는 실패로 간주하지 않음.
          
    instances:
      testApi:                  # CircuitBreaker 인스턴스의 이름을 정의
        baseConfig: default     # 인스턴스에서 사용할 구성을 참조, 위에서 정의한 default 구성 사용

management:
  endpoints:
    web:
      exposure:
        include:
          - "*"                 # 테스트를 위해 actuator 전체 노출

  health:
    circuitbreakers:
      enabled: true             # circuitbreakers 정보 노출

 
주석으로 작성한 것처럼 현재 Circuit Breaker는 마지막 10개 호출에 대해 30% 이상의 호출이 실패하면 Open 상태가 되고 10초동안 열린 상태를 유지합니다. 호출 회수가 10회 미만이라면 모든 요청이 실패해도 Open 상태로 전환되지 않는다는 것에 주의해주세요.
 
Open 상태 이후에는 10초가 지나면 Half-Open 상태가 되고, 10개 호출에 대해 기존 로직 장애에 대한 복구 여부를 판단하여 다시 Open, Close 상태로의 전환 여부를 결정합니다.
 
개발자가 의도하거나 예상할 수 있는 예외는 ignore-exceptions로 제외할 수 있습니다.
 
설정은 testApi 라는 인스턴스에 적용되도록 설정했습니다.
 

3-2. actuator 관찰하기

먼저 Circuit Breaker가 정상적으로 올라와있는지 체크할 수 있는 몇 가지 메서드를 호출해보겠습니다.
 

/actuator/circutbreakers

 
/actuator/circuitbreakers 호출은 현재 Circut Breaker가 적용된 인스턴스들의 목록과 상태를 보여줍니다. 설정 파일에 기재한대로의 내용이 응답으로 확인됩니다.
 

/actuator/circutbreakerevents

/actuator/circuitbreakerevents 호출은 Circuit Breaker 인스턴스의 최근 이벤트 발생 목록을 보여줍니다.
 
현재 아무런 호출도 하지 않은 상태이기 때문에 Circuit Breaker의 어떠한 Open, Close 등의 이벤트도 일어나지 않은 상태입니다.
 
 

3-3. CircuitBreaker 테스트 

이제 간단한 코드를 통해 Circuit Breaker 동작을 검증해보겠습니다.
 

[TestController.java]

@RestController
@RequestMapping("/api/")
@RequiredArgsConstructor
public class TestController {

    private final TestService testService;

    @GetMapping("/test")
    public ResponseEntity<String> test(@RequestParam Boolean isFaultRequest,
                                       @RequestParam Boolean isIllegalStateException) {

        String response = testService.testApi(isFaultRequest, isIllegalStateException);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

}

 
요청을 받을 간단한 Controller 입니다. 파라미터로 isFaultRequest 변수를 받아 고의적인 실패 상황을 만들 수 있도록 합니다. 또한, isIllegalStateException 파라미터를 통해 ignore-exceptions 설정이 잘 동작하는지 확인하겠습니다.
 

[TestService.java]

@Service
@Slf4j
public class TestService {

    @Transactional(readOnly = true)
    @CircuitBreaker(name = "testApi", fallbackMethod = "fallback")
    public String testApi(Boolean isFaultRequest, Boolean isIllegalStateException) {
        if (isIllegalStateException) {
            throw new IllegalStateException("IllegalStateException!");
        }
        if (isFaultRequest) {
            throw new RuntimeException("RuntimeException...");
        }

        return "OK";
    }

    private String fallback(IllegalStateException illegalStateException) {
        return "IllegalStateException Response!";
    }

    private String fallback(CallNotPermittedException callNotPermittedException) {
        return "Reject Call!!";
    }

    private String fallback(Exception exception) {
        return "Empty Response!";
    }
}

Circuit Breaker 인스턴스로 지정한 testApi 메서드를 작성했습니다. isIllegalStateException 파라미터가 true일 때는 IllegalStateException을 발생시키고, 그 외에 잘못된 요청에서는 RuntimeException을 발생시키도록 했습니다.
 
@CircuitBreaker의 fallbackMethod 인자에는 fallback 메서드로 실행될 메서드 이름을 지정하고, 해당 메서드의 파라미터로는 발생할 예외를 지정합니다.
 
먼저 IllegalStateException을 true로 하여 요청을 보내보겠습니다.

$ curl http://localhost:8080/api/test\?isFaultRequest\=true\&isIllegalStateException\=true
IllegalStateException Response!

## /actuator/circuitbreakers
{
    "circuitBreakers": {
        "testApi": {
            "failureRate": "-1.0%",
            "slowCallRate": "-1.0%",
            "failureRateThreshold": "30.0%",
            "slowCallRateThreshold": "100.0%",
            "bufferedCalls": 0,
            "failedCalls": 0,
            "slowCalls": 0,
            "slowFailedCalls": 0,
            "notPermittedCalls": 0,
            "state": "CLOSED"
        }
    }
}

ignore-exceptions 설정이 IllegalStateException을 배제하므로 failedCalls 값이 증가하지 않습니다. 다만, fallback 메서드가 동작하여 IllegalStateException Response! 라는 메시지를 리턴했습니다.
 
이제 RuntimeException을 발생시켜보겠습니다.

$  curl http://localhost:8080/api/test\?isFaultRequest\=true\&isIllegalStateException\=false
Empty Response!

## /actuator/circuitbreakers
{
    "circuitBreakers": {
        "testApi": {
            "failureRate": "-1.0%",
            "slowCallRate": "-1.0%",
            "failureRateThreshold": "30.0%",
            "slowCallRateThreshold": "100.0%",
            "bufferedCalls": 1,
            "failedCalls": 1,
            "slowCalls": 0,
            "slowFailedCalls": 0,
            "notPermittedCalls": 0,
            "state": "CLOSED"
        }
    }
}

실패한 호출 횟수가 1로 증가한 것을 볼 수 있습니다.
 
이제 총 호출 횟수가 10회 이상, 30% 이상의 호출이 실패하도록 계속해서 예외를 발생시켜보겠습니다.

{
    "circuitBreakers": {
        "testApi": {
            "failureRate": "100.0%",
            "slowCallRate": "0.0%",
            "failureRateThreshold": "30.0%",
            "slowCallRateThreshold": "100.0%",
            "bufferedCalls": 10,
            "failedCalls": 10,
            "slowCalls": 0,
            "slowFailedCalls": 0,
            "notPermittedCalls": 1,
            "state": "OPEN"
        }
    }
}

이후에 /actuators/circuitbreakers 응답을 보면 Circuit Breaker의 상태가 OPEN인 것을 볼 수 있습니다.

 

그렇다면, Circuit Breaker가 OPEN 상태에서 정상 요청을 보내보겠습니다.

$ curl http://localhost:8080/api/test\?isFaultRequest\=false\&isIllegalStateException\=false
Reject Call!

{
    "circuitBreakers": {
        "testApi": {
            "failureRate": "100.0%",
            "slowCallRate": "0.0%",
            "failureRateThreshold": "30.0%",
            "slowCallRateThreshold": "100.0%",
            "bufferedCalls": 10,
            "failedCalls": 10,
            "slowCalls": 0,
            "slowFailedCalls": 0,
            "notPermittedCalls": 1,
            "state": "OPEN"
        }
    }
}

notPermittedCalls 값이 1로 증가했는데, OPEN 상태의 CircuitBreaker는 모든 요청을 CallNotPermittedException을 던지며 거부합니다.

 

Failure rate and slow call rate thresholds

 

이에 따라 아래 fallback 메서드가 실행되며 Reject Call!! 이라는 응답이 리턴된 것을 볼 수 있습니다.

private String fallback(CallNotPermittedException callNotPermittedException) {
    return "Reject Call!!";
}

 

 

10초가 지난 후 1회의 실패 요청을 보낸 뒤 상태를 확인해보면 HALF_OPEN 상태의 Circuit Breaker를 확인해볼 수 있습니다.

#### 10초 경과 ####

#### 실패 요청 ####
curl http://localhost:8080/api/test\?isFaultRequest\=true\&isIllegalStateException\=false
Empty Response!
	
{
    "circuitBreakers": {
        "testApi": {
            "failureRate": "-1.0%",
            "slowCallRate": "-1.0%",
            "failureRateThreshold": "30.0%",
            "slowCallRateThreshold": "100.0%",
            "bufferedCalls": 1,
            "failedCalls": 1,
            "slowCalls": 0,
            "slowFailedCalls": 0,
            "notPermittedCalls": 0,
            "state": "HALF_OPEN"
        }
    }
}

 

HALF_OPEN 상태에서 다시 OPEN 상태로 가기 위해서는 다시 10번의 호출 중에서 정상 응답의 비율이 임계치보다 낮아야 합니다.

$ curl http://localhost:8080/api/test\?isFaultRequest\=false\&isIllegalStateException\=false
OK
$ curl http://localhost:8080/api/test\?isFaultRequest\=false\&isIllegalStateException\=false
OK
$ curl http://localhost:8080/api/test\?isFaultRequest\=false\&isIllegalStateException\=false
OK
$ curl http://localhost:8080/api/test\?isFaultRequest\=false\&isIllegalStateException\=false
OK
$ curl http://localhost:8080/api/test\?isFaultRequest\=false\&isIllegalStateException\=false
OK
$ curl http://localhost:8080/api/test\?isFaultRequest\=false\&isIllegalStateException\=false
OK
$ curl http://localhost:8080/api/test\?isFaultRequest\=false\&isIllegalStateException\=false
OK
$ curl http://localhost:8080/api/test\?isFaultRequest\=false\&isIllegalStateException\=false
OK
$ curl http://localhost:8080/api/test\?isFaultRequest\=false\&isIllegalStateException\=false
OK

# /actuator/circuitbreakers
{
    "circuitBreakers": {
        "testApi": {
            "failureRate": "-1.0%",
            "slowCallRate": "-1.0%",
            "failureRateThreshold": "30.0%",
            "slowCallRateThreshold": "100.0%",
            "bufferedCalls": 0,
            "failedCalls": 0,
            "slowCalls": 0,
            "slowFailedCalls": 0,
            "notPermittedCalls": 0,
            "state": "CLOSED"
        }
    }
}

정상 요청을 9번 더 보내면 총 10번의 응답 중에서 정상 응답이 대다수를 차지하므로 Circuit은 CLOSED 상태로 전환됩니다.
 
 

4. 정리 / Reference

이번 글에서는 Resilience4j의 Circuit Breaker를 활용한 장애 전파 대응 실습을 해봤습니다.
 
간단한 옵션과 테스트 코드로 실습했지만 Circuit Breaker가 전반적으로 어떻게 동작하는지 이해할 수 있으리라 생각합니다.
 
서비스 환경에서 발생할 수 있는 예외 상황에 대한 적절한 Fallback 정의, Circuit Breaker를 열고 닫는 적절한 임계값과 기준 등을 설정해서 장애 상황을 미리 예방해볼 수 있겠습니다.
 
- https://resilience4j.readme.io/docs/circuitbreaker
- https://resilience4j.readme.io/docs/getting-started-3
- https://oliveyoung.tech/blog/2023-08-31/circuitbreaker-inventory-squad/
- https://cheese10yun.github.io/resilience4j-basic/