본문 바로가기
Spring

circuitBreaker

by abstract.jiin 2025. 2. 3.

Resilience4j

Netflix Hystrix로부터 영감을 받아 Java 전용으로 개발된 Fault Tolerance Library

Netflix Hystrix는 현재 deprecated 된 상태로 Resilience4j 사용 권장

코어 모듈은 아래 6가지가 있지만, 그 중 CircuitBreaker만 적용했기에, CircuitBreaker에 대해서만 설명하려고 한다.

  1. CircuitBreaker : 장애 전파 방지 기능
  2. Retry : 요청 실패 시 재시도 처리 기능
  3. RateLimiter : 제한치를 넘어서 요청을 거부하거나 Queue 생성하여 처리하는 기능
  4. TimeLimiter : 실행 시간제한 설정 기능
  5. Bulkhead : 동시 실행 횟수 제한 기능
  6. Cache : 결과 캐싱 기능

단, 각 모듈은 동작에 우선순위가 있다는 점을 참고 할 것

Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( BulkHead ( TargetFunction ) ) ) ) )

Circuitbreaker

회로 차단기처럼 서비스 간의 장애 전파를 막는 역할을 한다.

요청이 반복적으로 실패하면 CircuitBreaker를 Open해서 실패하는 요청을 계속하지 않도록 방지하고, 이를 통해 장애 확산을 막고 복구할 시간을 확보한다. 그동안 사용자가 불필요하게 대기하지 않게 미리 처리된 정보를 보여줄 수 있다. 솔루션에서는 이 부분은 fallback 서버로 요청을 전송하여, 에러 메시지를 수신하는 기능으로 사용하고 있다.

요청 실패 기준

  1. slow call : 기준보다 오래 걸린 요청
  2. failure call : 실패 혹은 오류 응답을 받은 요청

slow call 과 failure call 에 대해서 config로 설정할 수 있음

3가지 상태

Closed Open Half Open
정상 장애 Open 상태 이후 일정 요청 횟수/시간이 지나 Open과 Closed 중 어떤 상태로 변경할 지에 대한 판단이 이루어지는 상황
요청이 정해진 횟수/비율만큼 실패할 경우 Open 상태로 변경 요청을 차단하고 처리를 위해 작성한 로직 실행  
에러 발생 혹은 지정한 fallback 메서드를 호출 요청에 대한 처리를 수행하고 실패 → Open 상태  
성공 → Close 상태    

CircuitBreaker Config(property)

property description default
failureRateThreshold 실패 비율 임계치를 백분율로 설정 해당 값을 넘어갈 시 Circuit Breaker 는 Open 상태로 전환되며, 이때부터 호출을 차단한다 50
slowCallRateThreshold 임계값을 백분율로 설정, CircuitBreaker는 호출에 걸리는 시간이 slowCallDurationThreshold보다 길면 느린 호출로 간주,해당 값을 넘어갈 시 Circuit Breaker 는 Open상태로 전환되며, 이때부터 호출을 차단한다 100
slowCallDurationThreshold 호출에 소요되는 시간이 설정한 임계치보다 길면 느린 호출로 계산. 응답시간이 느린 것으로 판단할 기준 시간 (60초, 1000 ms = 1 sec) 60000[ms]
permittedNumberOfCallsInHalfOpenState HALF_OPEN 상태일 때, OPEN/CLOSE 여부를 판단하기 위해 허용할 호출 횟수를 설정 수 10
maxWaitDurationInHalfOpenState HALF_OPEN 상태로 있을 수 있는 최대 시간이다. 0일 때 허용 횟수만큼 호출을 모두 완료할 때까지 HALF_OPEN상태로 무한정 대기 0
slidingWindowType sliding window 타입을 결정한다. COUNT_BASED인 경우 slidingWindowSize 만큼의 마지막 call들이 기록되고 집계된다.TIME_BASED인 경우 마지막 slidingWindowSize초 동안의 call들을 기록하고 집계 COUNT_BASED
slidingWindowSize CLOSED 상태에서 집계되는 슬라이딩 윈도우 크기를 설정한다 100
minimumNumberOfCalls minimumNumberOfCalls 이상의 요청이 있을 때부터 faiure/slowCall rate를 계산.예를 들어, 해당 값이 10이라면 최소한 호출을 10번을 기록해야 실패 비율을 계산할 수 있다.기록한 호출 횟수가 9번 뿐이라면 9번 모두 실패했더라도 circuitbreaker는 열리지 않는다. 100
waitDurationInOpenState OPEN에서 HALF_OPEN 상태로 전환하기 전 기다리는 시간 (60초, 1000 ms = 1 sec) 60000[ms]
recordExceptions 실패로 기록할 Exception 리스트 [] (empty)
ignoreExceptions 실패나 성공으로 기록하지 않을 Exception 리스트 [] (empty)

일반적인 상황에서 CircuitBreaker 적용

Property 등록

property의 등록은 Bean으로 등록하는 방법과 yaml 로 등록하는 방법이 있다.

application.yaml 예시

resilience4j.circuitbreaker:
  configs:
    default:
      slidingWindowType: COUNT_BASED
      minimumNumberOfCalls: 7                                   # 최소 7번까지는 무조건 CLOSE로 가정하고 호출.
      slidingWindowSize: 10                                     # (minimumNumberOfCalls 이후로는) 10개의 요청을 기준으로 판단.
      waitDurationInOpenState: 10s                              # OPEN 상태에서 HALF_OPEN으로 가려면 얼마나 기다릴 것인가?

      failureRateThreshold: 40                                  # slidingWindowSize 중 몇 %가 recordException이면 OPEN으로 만들 것인가?

      slowCallDurationThreshold: 3000                           # 몇 ms 동안 요청이 처리되지 않으면 실패로 간주할 것인가?
      slowCallRateThreshold: 60                                 # slidingWindowSize 중 몇 %가 slowCall이면 OPEN으로 만들 것인가?

      permittedNumberOfCallsInHalfOpenState: 5                  # HALF_OPEN 상태에서 5번까지는 CLOSE로 가기위해 호출한다.
      automaticTransitionFromOpenToHalfOpenEnabled: true        # OPEN 상태에서 자동으로 HALF_OPEN으로 갈 것인가?

      eventConsumerBufferSize: 10                               # actuator를 위한 이벤트 버퍼 사이즈

      recordExceptions:
        - com.example.resilience4jdemo.exception.RecordException #실패로 기록할 exception
      ignoreExceptions:
        - com.example.resilience4jdemo.exception.IgnoreException #실패로 기록하지는 않지만 fallback은 실행함,
  instances:
    simpleCircuitBreakerConfig:
      baseConfig: default

bean등록 예시

@Configuration
public class MyCircuitBreakerConfiguration {

    @Bean
    public CircuitBreakerConfig myCircuitBreakerConfig() {
        return CircuitBreakerConfig.custom()
            .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
            .slidingWindowSize(10)
            .failureRateThreshold(50)
            .build();
    }

CircuitBreaker Target 지정 및 실행

Circuitbreaker는 aop 기반으로 동작하며, target 메서드에 어노테이션을 사용해서 지정할 수 있다.

    @CircuitBreaker(name="customCircuitBreaker",fallbackMethod = "fallback")
    public String target() {
        Random r = new Random();
        int number = r.nextInt(10);

        if (number % 2 == 0) {
            throw new RuntimeException("Fail !!");
        }
        return "Success";
    }

    public String fallback(Throwable e) {
        return "Fallback Method Running and Error is "+e.getMessage();
    }

    public String fallback(CallNotPermittedException e) {
        return "Fallback Method Running and Error is "+e.getMessage();

CallNotPermittedException 는 CircuitBreaker가 Open 되었을 때 발생하는 Exception으로

CircuitBreaker Open 발생 시 구현할 로직을 fallback(CallNotPermittedException e) 메서드에 작성하면 된다.

솔루션 내 도입된 CircuitBreaker

일반적인 방법으로는 요청 API에 따라 CircuitBreaker가 동적 할당을 하게끔 하기에는 여러 제약이 있다.

기능 1 : API에 따라 CircuitBreaker 동적 할당

1) 제약사항 1 : 어노테이션 방식을 사용할 수 없다.

타겟 메서드에 @CircuitBreaker(name="customCircuitBreaker", fallbackMethod = "fallback")

이렇게 어노테이션을 사용할 경우, AOP를 기반으로 자동적으로 circuitbreaker의 이름과 fallback 메서드를 지정할 수 있다. 하지만 name 값이 변수가 아니라 상수이기 때문에 name 값을 동적 지정할 수 없었다.

Try.ofSupplier 와 .recover를 사용해서, fallbackService에 지정한 fallback 메서드를 호출 했다.

호출할 로직[BackendService.doSomething()]을 decorate하고, decorate한 supplier를 실행해서 모든 예외를 복구하는 .recover 를 응용했다.

Supplier<String> decoratedSupplier = CircuitBreaker
    .decorateSupplier(circuitBreaker, backendService::doSomething);

String result = Try.ofSupplier(decoratedSupplier)
    .recover(throwable -> "Hello from Recovery").get();

위 내용을 적용한 Controller

@RequestMapping(value = "/**")
   public ResponseEntity<?> methodRouter(HttpServletRequest httpServletRequest,
                                           HttpServletResponse httpServletResponse,
                                           @RequestBody(required = false) byte[] body) {

        //CircuitBreaker 생성하는 서비스 실행
        CircuitBreaker circuitBreaker= customCircuitBreaker.create();


                /*Target Logic*/

                //Try.ofSupplier 를 사용해서, fallbackService에 지정한 fallback 메서드 호출 
        Try<ResponseEntity> responseEntities = Try.ofSupplier(supplier).recover(fallbackService::fallback);

        return responseEntities.get();
    }

2) 제약사항 2 : 일반적으로는 CircuitBreakerRegistry.of 를 사용해서, global config 로 등록해 모듈 별로 CircuitBreaker를 하나로 사용한다.

여러 개의 CircuitBreaker와 각각의 CircuitBreaker의 Config값이 모두 각각 적용되어야 하기 때문에,

CircuitBreakerRegistry.addConfiguration(configName,circuitBreakerConfig);

를 적용해서 Registry에 Config를 각각 추가 한 뒤에, 호출했다. 아래 1개의 CircuitBreaker를 호출 할 때와, Registry에 등록해서 각각 호출할 때를 비교해보았다.

일반적인 CircuitBreaker config 생성과 등록 과정

// (1) CircuitBreaker config 생성
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)
    .waitDurationInOpenState(Duration.ofMillis(1000))
    .permittedNumberOfCallsInHalfOpenState(2)
    .slidingWindowSize(2)
    .recordExceptions(IOException.class, TimeoutException.class)
    .ignoreExceptions(BusinessException.class, OtherBusinessException.class)
    .build();

// (2) CircuitBreaker config 를 global config 로 등록
CircuitBreakerRegistry circuitBreakerRegistry 
    = CircuitBreakerRegistry.of(circuitBreakerConfig);

// (3) global config 로 등록된 config 값으로 CircuitBreaker 생성 
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("ConfigName");

솔루션에서의 config 생성과 등록 과정

// (1) CircuitBreaker config 생성
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
    .failureRateThreshold(50)
    .waitDurationInOpenState(Duration.ofMillis(1000))
    .permittedNumberOfCallsInHalfOpenState(2)
    .slidingWindowSize(2)
    .recordExceptions(IOException.class, TimeoutException.class)
    .ignoreExceptions(BusinessException.class, OtherBusinessException.class)
    .build();

// (2) Registry에 Config 등록
CircuitBreakerRegistry.addConfiguration(configName,circuitBreakerConfig);

// (3) Registry에서 꺼내서 CircuitBreaker 생성 
CircuitBreakerRegistry.circuitBreaker(circuitBreakerName,configName);

기능 2 : DB의 내용이 변경 될 경우 Cache refresh를 통해서 재기동 없이 config 값을 변동

refresh 요청

http://localhost:9898/cache/refresh

제약사항 1 : refresh 할 경우, 기동 시에 생성 한 Bean의 CircuitBreaker와 Config 까지 사라지기 때문에 이 부분 보완이 필요했다.

*cacheAllDelresetConfigRegistrycachePutConfigInfoaddRegistryConfigdefaultconfig circuitBreaker 생성*

*resetConfigRegistry*

Cache refresh 했을 때, Cache생성 했던 것만 삭제하는 것으로는 삭제가 다 안되고, 생성했던 circuitBreakerRegistry 에 등록된 모든 config를 생성된 configname을 추출해서 전부 삭제 해 줘야 한다.

// registry에 등록된 config 정보 모두 삭제
    private void resetConfigRegistry(){

        circuitBreakerRegistry.getAllCircuitBreakers()
                .iterator()
                .forEachRemaining( // 원래 있던 registry name 가져와서 지우기
                        circuitBreaker ->  circuitBreakerRegistry.remove(circuitBreaker.getName()));

    }

*defaultconfig circuitBreaker*

//cacheAllDel 로 사라진 defaultCircuit config로 circuitBreaker 재생성
        CircuitBreakerConfig customCircuitBreakerConfig =
                getCircuitBreakerCache.getConfigInfo("defaultCircuit").ToCircuitBreakerConfig();
        circuitBreakerRegistry.addConfiguration("defaultCircuit",customCircuitBreakerConfig);
        circuitBreaker = circuitBreakerRegistry.circuitBreaker("defaultCircuit", customCircuitBreakerConfig);

현재 버전의 솔루션에서의 CircuitBreaker 설명

1) 기동과 동시에 DB에 저장된 “defaultCircuit” 이름의 config 값을 “defaultCircuit” 이름의 CircuitBreaker로 등록한다.

*DB에서 circuitBreakerRegistry로 addConfiguration 을 할 때, “default”라는 이름을 사용 할 수 없다.

2) DB에 들어가있는 config 설정 값을 api source_uri 이름으로 DB에서 Cache로 전체 등록한다.

3) 요청이 들어오면 Cache에 source_uri 를 확인하고 해당 source_uri 이름의 CircuitBreaker 로 등록한다.

  • API 등록 O : TB_GTW_JOB_INF에 등록 되어 있는 경우
  • Config 등록 O : TB_GTW_CIRCUIT_ING에 등록 되어 있는 경우

[1번] API 등록 O, Config 등록 O

→ Cache에서 source_uri 이름의 Config값을 적용해서 source_uri 이름의 CircuitBreaker 생성

[2번] API 등록 O, Config 등록 X

→ defaultCircuit 의 Config 값을 적용한, source_uri 이름의 CircuitBreaker 생성

단 호출 2회 차부터는 registry에 config가 생성 되어 [1번] 을 실행한다.

[3번] API 등록 X, Config 등록 X

→ defaultCircuit 의 Config 값을 적용한 source_uri 이름의 CircuitBreaker 생성, API 호출 될 때마다 exception 발생해서 fallback

4) CircuitBreaker 의 fallback 메서드 호출은 Try.ofSupplier 를 활용한다.

1) 기동과 동시에 DB에 저장된 “defaultCircuit” 이름의 config 값을 “defaultCircuit” 이름의 CircuitBreaker로 등록한다. CircuitBreaker 동적 할당을 위해서 Bean등록 방식으로 등록했다.

@Configuration
@Slf4j
public class Resilience4JConfiguration {

    @Autowired
    private CircuitBreakerBuilder circuitBreakerBuilder;

    @Bean
    public CircuitBreakerConfig circuitBreakerConfig() {
        return  circuitBreakerBuilder.getConfigFromDB("defaultCircuit");
    }

    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry(
            CircuitBreakerConfig circuitBreakerConfig){
        return CircuitBreakerRegistry.of(circuitBreakerConfig);
    }

    @Bean
    public CircuitBreaker circuitBreaker() {
        return circuitBreakerRegistry(circuitBreakerConfig())
                .circuitBreaker("defaultCircuit", circuitBreakerConfig());
    }
@Service
@Slf4j
@RequiredArgsConstructor
public class CircuitBreakerBuilderImpl implements CircuitBreakerBuilder {
    private final TbGtwCurcuitInfRepository tbGtwCurcuitInfRepository;

    @Override
    public CircuitBreakerConfig getConfigFromDB(String circuitBreakerName) {
        TbGtwCircuitInf entity = (TbGtwCircuitInf) tbGtwCurcuitInfRepository.findByCircuitBreakerName(circuitBreakerName)
                .orElseThrow(() -> new EngineException(EngineCode.CACHE_UNREGISTERED_API,"[" + circuitBreakerName + "]"));

        // CircuitBreakerConfig 객체로 변환하여 반환
        return CircuitBreakerConfig.custom()
                .minimumNumberOfCalls(entity.getMinimumNumberOfCalls())
                .failureRateThreshold(entity.getFailureRateThreshold())
                .waitDurationInOpenState(Duration.ofMillis(entity.getWaitDurationInOpenState())) ///???
                .permittedNumberOfCallsInHalfOpenState(entity.getPermittedNumberOfCallsInHalfOpenState())
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.valueOf(entity.getSlidingWindowType()))
                .slidingWindowSize(entity.getSlidingWindowSize())
                .slowCallDurationThreshold(Duration.ofMillis(entity.getSlowCallDurationThreshold()))
                .slowCallRateThreshold(entity.getSlowCallRateThreshold())
                .build();
    }

}

2) DB에 들어가 있는 config 설정 값을 source_uri 이름으로 DB에서 Cache로 미리 등록한다.

*cache 등록 자체는 circuitBreaker와 큰 연관이 없어서, 예시 코드는 제외함.

3) 요청이 들어오면 Cache에 source_uri 를 확인하고 해당 source_uri 이름의 CircuitBreaker 로 등록한다.

@Service
@Slf4j
@AllArgsConstructor
public class CustomCircuitBreakerImpl implements CustomCircuitBreaker {

    private final HttpServletRequest httpServletRequest;
    private CircuitBreaker circuitBreaker;
    private CircuitBreakerRegistry circuitBreakerRegistry;
    private CircuitBreakerConfig circuitBreakerConfig;
    private final GetCircuitBreakerCache getCircuitBreakerCache;

    @Override
    public CircuitBreaker create() {

        String source_uri =  httpServletRequest.getRequestURI();
        try{
            // API 등록 O, Config 등록 O, 미리 생성된 config가 있기 때문에 해당 circuitBreaker 생성[1번]
            circuitBreaker = circuitBreakerRegistry.circuitBreaker(source_uri, source_uri);

        } catch (ConfigurationNotFoundException e) {
            //  [2번] API 등록 O, Config 등록 X, registry에 config값 등록 [2번]
                        // 단 호출 2회차 부터는 registry에 config가 미리 생성 되어, [1번] 로직을 탐

            circuitBreakerConfig
                    = getCircuitBreakerCache
                    .getConfigInfo("defaultCircuit")
                    .ToCircuitBreakerConfig();

            circuitBreakerRegistry.addConfiguration(source_uri, circuitBreakerConfig);

            //  circuitBreaker 생성
            circuitBreaker
                    = circuitBreakerRegistry.circuitBreaker(source_uri, source_uri);
        }

        return circuitBreaker;
    }

}

4) CircuitBreaker 의 fallback 메서드 호출은 Try.ofSupplier 를 활용한다.

@RequestMapping(value = "/**")
   public ResponseEntity<?> methodRouter(HttpServletRequest httpServletRequest,
                                           HttpServletResponse httpServletResponse,
                                           @RequestBody(required = false) byte[] body) {

        //CircuitBreaker 생성하는 서비스 실행
        CircuitBreaker circuitBreaker= customCircuitBreaker.create();


                /*Target Logic*/

                //Try.ofSupplier 를 사용해서, fallbackService에 지정한 fallback 메서드 호출 
        Try<ResponseEntity> responseEntities = Try.ofSupplier(supplier).recover(fallbackService::fallback);

        return responseEntities.get();
    }

CircuitBreaker actuator

아래와 같이 actuator 설정을 통해 circuitbreaker의 상태를 확인 할 수 있다.

failedCalls과 slowCalls 값이 올라가는 지 확인하고, state 변경되는 내용을 확인 할 수 있다.

/actuator/circuitbreakers 호출

GET http://localhost:9898/actuator/circuitbreakers

application yml

management:
  endpoints:
    web:
      exposure:
        include: "*"
  health:
    circuit-breakers:
      enabled: true

resilience4j.circuitbreaker:
  configs:
    default:
      registerHealthIndicator: true

response 예시

{
  "circuitBreakers": {
    "/echo/test": {
      "failureRate": "-1.0%",
      "slowCallRate": "-1.0%",
      "failureRateThreshold": "40.0%",
      "slowCallRateThreshold": "60.0%",
      "bufferedCalls": 1,
      "failedCalls": 0,
      "slowCalls": 1,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "CLOSED"
    },
    "defaultCircuit": {
      "failureRate": "-1.0%",
      "slowCallRate": "-1.0%",
      "failureRateThreshold": "60.0%",
      "slowCallRateThreshold": "60.0%",
      "bufferedCalls": 0,
      "failedCalls": 0,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "CLOSED"
    }
  }
}