본문 바로가기
프로젝트

[Project] Resilience4j 로 Circuit Breaker 구현하기 (2)

by jaee_ 2022. 1. 28.

들어가며

이전 글에서 Circuit break pattern 에 대해서 알아봤습니다. 이번엔 이를  resilience4j를 사용하여 적용하는 과정에 대해서 적어보도록 하겠습니다. 


Resilience4j이란?

Netflix Hystrix 영감을 받아 만들어진 라이브러리로 Hystrix 보다 가볍고 사용하기 쉬운 내결함성 라이브러리입니다. 

 

Netflix의 Hystrix을 사용하지 않고 Resilience4j 을 사용하는 이유는 Hystrix는 더이상 개발이 되지 않는 유지보수 상태이며  Resilience4j 보다 많은 외부 종속성을 가지고 있습니다. 그에 비해 Resilience4j 는 외부 종속성이 없고, 자바 함수형 프로그래밍을 위해 설계되었습니다. 따라서 전 외부 의존성이 적고, 현재 Spring-cloud 에도 내장되어있는 Resilience4j 을 사용하겠다고 결심했습니다.

 

Resilience4j 는 다음과 같은 핵심 모듈을 제공합니다. 

  • resilience4j-circuitbreaker: 회로 차단기
  • resilience4j-ratelimiter: Rate limiting
  • resilience4j-bulkhead: Bulkheading
  • resilience4j-retry: 실패한 실행을 반복합니다. 
  • resilience4j-timelimiter: Timeout handling
  • resilience4j-cache: Result caching

저는 회로 차단기에 대해서만 알아볼 것이지만, 다른 유용한 모듈도 많으니 다른 것에 관심이 있으신 분은 공식 홈페이지 방문을 추천드립니다.


적용해보기

1. 의존성 추가

이번 예제에서 프로젝트 환경은 다음과 같습니다.

  • Spring-Boot : 2.6.x
  • Java : 11

resilience4j를 사용하기 위해선 의존성 추가가 필요합니다. 의존성 추가는 두 가지 방법으로 할 수 있습니다. 

  1.  스프링 클라우드 스타터를 이용합니다.
    • org.springframework.cloud:spring-cloud
  2. Resilience4j의 Spring Boot 2 Starter를 컴파일 종속성에 추가합니다. 
    • io.github.resilience4j:resilience4j-spring-boot2

 

우선 첫번째 방법인 스프링 클라우드 스타터를 추가하는 방식은 다음과 같이 의존성을 추가하면 됩니다. 주의해야할 점은 Spring-boot 사용시 Spring-cloud version과 호환되는지 확인해야 합니다. 가장 쉬운 방법은 맨 처음 프로젝트 생성시 의존성을 추가해주는게,, 깔끔하게 에러없이 실행됩니다 ㅎㅎ 저는 나중에 의존성을 추가해서 고생했습니다.. ㅎㅎ 해결 방법은 이전 글 에 게시해놨으니 궁금하신 분들은 참고하시면 될 것 같습니다. 

ext {
    set('springCloudVersion', "2021.0.0")
}

dependencies {
    compile "io.github.resilience4j:resilience4j-spring-cloud2:${resilience4jVersion}"
    compile('org.springframework.boot:spring-boot-starter-actuator')
    compile('org.springframework.boot:spring-boot-starter-aop')
    compile('org.springframework.cloud:spring-cloud-starter-config')  
}
dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

 

두번째 방법인 Resilience4j의 Spring Boot 2 Starter를 컴파일 종속성에 추가하는 방식은 다음과 같이 추가하면 됩니다. 

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

 

여기서 주의해야 할 점은 Spring AOP가 추가되어야 합니다. Spring Boot에서 사용할 때 메소드 레벨의 어노테이션을 사용하는데 이 때 AOP 가 사용됩니다. 전 이 AOP를 추가 안해서 정말 하루동안 왜 안되는걸까 하면서 헤맸습니다.. 이 글을 읽는 사람들은 저처럼 삽질하지 말고 꼭 추가하셔서 사용하시기 바랍니다 ㅎㅎㅎ 

 

2. 설정 생성과 실행

설정값을 지정하기 전에 어떤 설정들이 있는지 구성 요소들을 살펴보겠습니다. 

  • failureRateThreshold (50) : 실패율 임계값(백분율)을 구성합니다. 지정한 임계값 이상이면 CircuitBreaker는 OPEN 상태가 됩니다. 
  • slidingWindowType (COUNT_BASE) : CircuitBreaker를 닫을 때 호출 결과를 기록하는 데 사용되는 슬라이딩 창의 유형을 구성합니다. 
  • slidingWindowSize (100) : CircuitBreaker를 닫을 때 호출 결과를 기록하는 데 사용되는 슬라이딩 창의 크기를 구성합니다.
  • minimumNumberOfCalls (100) : 최소 호출 수를 의미합니다. CircuitBreaker 가 임계값을 측정하기 위해 최소 호출할 개수를 지정합니다. 만약 이를 5로 정해놓고 임계값을 50으로 지정한다하면 4번 모두 실패한 호출이더라도 CircuitBreaker 는 OPEN으로 상태변경하지 않습니다. 최소 호출 수를 만족해야 적용됩니다. 
  • waitDurationInOpenState (60000 ms) : CircuitBreaker가 열림에서 반열림으로 전환하기 전에 대기해야 하는 시간입니다.
  • recordExceptions (empty) : 실패로 기록되고 따라서 고장률을 증가시키는 예외 목록입니다.
  • ignoreExceptions (empty) : 실패나 성공으로 계산되지 않고 무시되는 예외 목록입니다. 예외가 recordExceptions의 일부이더라도 목록 중 하나에서 일치하거나 상속하는 예외는 실패 또는 성공으로 계산되지 않습니다.
  • permittedNumberOfCallsInHalfOpenState (10) : CircuitBreaker가 반쯤 열려 있을 때 허용되는 호출 수를 구성합니다.

위와 같은 설정 구성 요소들이 있습니다. 더 많은 구성 요소가 있지만 간추려서 작성했습니다. 궁금하신 분은 여기를 참고하시면 됩니다. 

 

이를 적용하기 위해서 공식문서를 통해 여러가지 등록 방식들을 봤습니다.

  • CircuitBreaker를 직접 만드는 방식
  • applicatoin.yml파일에 config를 정의해 만들고 @CircuitBreaker 어노테이션을 사용하는 방식
  • @Configuration 클래스 파일을 만들어 CurciutBreaker를 @Bean으로 등록하는 방식

등 다양한 방식들이 존재하더군요. 이 글에선 CircuitBreaker 를 직접 만드는 방식과 application.yml 파일을 사용해 config를 정의하고 어노테이션을 사용하는 방식 두 가지를 다뤄보려 합니다. 

 

2.1 CircuitBreaker를 만드는 방식 설정과 실행

 

우선 CircuitBreaker를 만드는 방식입니다. 이 방식은 유튜브 https://www.youtube.com/watch?v=WL0eIKD8krU 를 참고하여 작성하였습니다. 

@Slf4j
public class Resilience4jMain implements QuoteService {

  private String productName;

  public static void main(String[] args) {

    // 1. Circuit breaker Configuration
    CircuitBreakerConfig config = CircuitBreakerConfig.custom()
        .slidingWindow(10, 5, SlidingWindowType.COUNT_BASED)
        .automaticTransitionFromOpenToHalfOpenEnabled(true)
        .failureRateThreshold(50)
        .permittedNumberOfCallsInHalfOpenState(3)
        .waitDurationInOpenState(Duration.ofSeconds(10))
        .build();

    // 2. Circuit breaker Registry
    CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);

    // 3. Circuit breaker
    CircuitBreaker circuitBreaker = registry.circuitBreaker("myCustomCircuitBreaker");

    // 4. Supplier
    Supplier<Integer> supplier = CircuitBreaker.decorateSupplier(circuitBreaker,
        resQuoteService::getQuote);

    // 5. Call the method
    for (String product : products) {
      resQuoteService.setProductName(product);
      Try.ofSupplier(supplier).recover(resQuoteService::getQuoteFallback);
    }

  }

}

1. CircuitBreakerRegistryConfig 생성

  - CircuitBreakerConfig를 커스텀해서 생성할 수 있습니다. slidingWindow에 인자가 세 개 들어가있는 것은 각각 (slidingWindowSize) 10, (minimumNumberOfCalls) 5, slidingWindowType 을 의미합니다. 

  - 나머지 설정들은 위에 작성되어 있으니 참고바랍니다. 

 

2. CircuitBreakerRegistry 생성

   - 1번에서 작성한 config를 이용해 CircuitBreakerRegistry 를 생성합니다. 

 

3. CircuitBreaker 생성

    - 2번에서 작성한 전역 기본 구성으로 CircuitBreakerRegistry에서 CircuitBreaker 가져옵니다. 이름을 입력해서 가져올 수 있습니다.

 

4. 기능 인터페이스 장식

    - QuoteService.getQuote()에 대한 호출을 CircuitBreaker로 장식하고 장식된 공급업체를 실행하여 예외로부터 복구합니다.

 

5. 예외에서 복구

     - CircuitBreaker가 예외를 실패로 기록한 후 예외에서 복구하려면 메서드를 연결할 수 있습니다 . 복구 메서드는 모나드 를 반환하는 경우에만 호출됩니다. (사실 여기서 말하는 Monad 를 이해못했습니다... 이해한다면 추가로 작성하겠습니다. ) 

 

그리고 이를 실행시킬 코드로 다음과 같이 작성합니다. 

// 인터페이스 
public interface QuoteService {
  int getQuote();

  int getQuoteFallback(Throwable t);
}
  List<String> products = List.of("Soap", "Tooth Paste", "Biscuit", "Pepsi", "Tea", "Coke");

  Resilience4jMain resQuoteService = new Resilience4jMain();

  @Override
  public int getQuote() {
    log.info("Inside Quote Method for product "+ this.getProductName());
    if (getProductName().equalsIgnoreCase("Soap")) {
      return new Random().nextInt(100);
    } else if (getProductName().equalsIgnoreCase("Tooth Paste")) {
      return new Random().nextInt(100);
    } else {
      throw new RuntimeException("Product Not Available");
    }
  }

  public int getQuoteFallback(Throwable t) {
    log.info("Inside Quote fallback Method for product "+ this.getProductName());
    if (getProductName().equalsIgnoreCase("Soap")) {
      return 10;
    } else if (getProductName().equalsIgnoreCase("Tooth Paste")) {
      return 20;
    }
    return 0;
  }


  public String getProductName() {
    return productName;
  }

  public void setProductName(String productName) {
    this.productName = productName;
  }

이를 실행시키면 다음과 같은 결과가 나옵니다. 

알아보기 힘들어 필요없는 부분은 지워봤습니다. 

Inside Quote Method for product Soap
 -- CircuitBreaker 'myCustomCircuitBreaker' succeeded:
 -- No Consumers: Event SUCCESS not published
Inside Quote Method for product Tooth Paste
 -- CircuitBreaker 'myCustomCircuitBreaker' succeeded:
 -- No Consumers: Event SUCCESS not published
Inside Quote Method for product Biscuit
 -- CircuitBreaker 'myCustomCircuitBreaker' recorded an exception as failure:
 -- java.lang.RuntimeException: Product Not Available
 -- No Consumers: Event ERROR not published
Inside Quote fallback Method for product Biscuit
Inside Quote Method for product Pepsi
 -- CircuitBreaker 'myCustomCircuitBreaker' recorded an exception as failure:
 -- java.lang.RuntimeException: Product Not Available
 -- No Consumers: Event ERROR not published
Inside Quote fallback Method for productPepsi
Inside Quote Method for product Tea
 -- CircuitBreaker 'myCustomCircuitBreaker' recorded an exception as failure:
 -- java.lang.RuntimeException: Product Not Available
 -- No Consumers: Event ERROR not published
 -- No Consumers: Event FAILURE_RATE_EXCEEDED not published
 -- No Consumers: Event STATE_TRANSITION not published
Inside Quote fallback Method for product Tea
 -- No Consumers: Event NOT_PERMITTED not published
Inside Quote fallback Method for product Coke

log.info를 이용한 부분을 제외하고 --로 구분했습니다.

 

실행코드를 살펴보면 Soap와 Tooth Paste를 제외한 모든 문자열은 RuntimeException을 던지게 되어있습니다. --로 표시된 코드를 보면 Soap와 Tooth Paste는 succeeded를 나타내고, 나머지는 failure를 나타내며 CircuitBreaker에 기록을 남기고, 제가 정의했던 에러를 던진 후 콜백 메소드를 실행합니다. 

 

그리고 config에 정의했던 임계치와 최소 콜백을 충족시키니 에러를 던지지 않고 바로 콜백 메소드를 실행하는 것을 볼 수 있습니다. 

 

2.2 applicatoin.yml파일에 config를 정의해 만들고 @CircuitBreaker 어노테이션을 사용하는 방식설정과 실행

이번엔 application.yml 파일을 사용해 설정하고 어노테이션을 이용해 사용하는 방법을 알아보겠습니다. 

 

application.yml 파일입니다.

resilience4j.circuitbreaker:
  configs:
    default:
      failureRateThreshold: 50
      slowCallRateThreshold: 100
      slowCallDurationThreshold: 60000
      permittedNumberOfCallsInHalfOpenState: 4
      maxWaitDurationInHalfOpenState: 1000
      slidingWindowType: COUNT_BASED
      slidingWindowSize: 10
      minimumNumberOfCalls: 5
      waitDurationInOpenState: 10000
  instances:
    customCircuitBreaker:
      baseConfig: default

설정값 구성요소는 위에 설명되어 있으니 설명하지 않겠습니다. application.yml에서는 default 값으로 설정값을 설정할 수 있고, instances를 사용하여 원하는 CircuitBreaker에 추가적으로 혹은 따로 설정할 수 있습니다. instances를 지정하지 않으면 기본값으로 지정됩니다. 저는 customCircuitBreaker를 instances로 따로 지정했습니다. (지정만 했지 default와 다른 설정은 하지 않았습니다.)

 

그리고 간단히 실행시킬 Controller와 Service를 작성해보겠습니다. 

@RestController
@RequiredArgsConstructor
public class CalculatorController {

  private final CalculatorService calculatorService;

  @GetMapping("/calculator")
  public ResponseEntity<String> calculator() {
    String str = calculatorService.cal();
    return ResponseEntity.ok().body("Good! " + str);
  }
}
@Service
public class CalculatorService {

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

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

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

http://localhost:포트번호/calculator 를 호출할 때 랜덤으로 숫자를 생성해 2의 배수이면 RuntimeException을 발생시키고 아니면 Success를 리턴하도록 작성했습니다. 그리고 어노테이션 @CircuitBreaker를 사용해 이름을 지정하고, 예외발생시 실행할 fallbackMethod를 지정했습니다. 

 

여기서 주의해야 할 점은 fallback 메소드는 동일한 클래스에 배치되어야 한다는 점입니다. 그리고 @CircuitBreaker 에서 지정한 fallbackMethod 이름과 동일해야합니다. fallback으로 지정한 메소드에선 예외도 매개변수로 받아주어야 합니다. 받지 않으면 런타임 에러가 발생합니다. ㅎㅎ  저는 가장 상위 클래스인 Throwable을 받았습니다. 그리고 만약 fallbackMethod 명이 겹칠 경우 발생한 예외와 가장 근접한 예외를 매개변수로 가진 fallback메소드를 실행한다고 합니다. 

 

자 , 그럼 이제 http://localhost:포트번호/calculator 를 실행해보겠습니다. 설정값에 의하면 최소 5번 이상 호출이 되어야하며, 실패 임계치가 50% 가 넘어야합니다. 

 

첫번째 호출

두번째 호출

다섯번째 호출

첫번째와 두번째 ~ 네번째 호출까진 RuntimeException에 정의했던 Fail !! 메세지가 출력됩니다. 하지만 다섯번째 호출땐 CircuitBreaker 'customCircuitBreaker' is OPEN and does not permit further calls 메세지가 호출되는 것을 볼 수 있습니다. CircuitBreaker의 customCircuitBreaker가 OPEN상태가 되어서 호출이 허용되지 않습니다. 라고 메세지가 뜨는 것을 볼 수 있습니다. 

 


모듈 간 호출해보기

제가 진행할 프로젝트는 결국 외부 API 를 호출하는 것이니 아래와 같은 상황도 구현해보겠습니다. 

시나리오

  • Client가 http://localhost:8081/call 를 호출한다. 
  • 모듈 A 가 CallController에서 /call에 대한 처리를 한다.
  • /call 에 대한 처리가 모듈 B의 /calculator/test를 호출하는 작업이다. 
  • 하지만 /calculator/test는 무조건 에러를 발생한다. 

구현해보겠습니다. 우선 모듈 A에 해당하는 CallController 입니다. 

@RestController
@RequiredArgsConstructor
public class CallController {
  private final RestTemplate restTemplate = new RestTemplate();

  @GetMapping("/call")
  @CircuitBreaker(name = "customCircuitBreaker", fallbackMethod = "fallback")
  public String checkCircuitOnCall() {
    String response = restTemplate.exchange("http://localhost:8082/calculator/test", HttpMethod.GET,null,String.class).getBody();
    return response;
  }

  private String fallback(Throwable e) {
    return "fallback Method from CallController , error is "+ e.getMessage();
  }
}

모듈 A의 application.yml 파일입니다. 

resilience4j.circuitbreaker:
  configs:
    default:
      failureRateThreshold: 50
      slowCallRateThreshold: 100
      slowCallDurationThreshold: 60000
      permittedNumberOfCallsInHalfOpenState: 4
      maxWaitDurationInHalfOpenState: 1000
      slidingWindowType: COUNT_BASED
      slidingWindowSize: 10
      minimumNumberOfCalls: 5
      waitDurationInOpenState: 10000
  instances:
    customCircuitBreaker:
      baseConfig: default

 

그 다음 모듈 B 의 CalculatorController입니다. 

@RestController
@RequiredArgsConstructor
public class CalculatorController {

  @GetMapping("/calculator/test")
  public ResponseEntity<String> calculatorTest() {
    throw new RuntimeException("Exception");
  }
}

 

호출하면 다음과 같이 나옵니다. 1~4번째 호출

5번째 호출

위의 캡쳐본과 같이 모듈간 호출 시에도 문제없이 콜백 메소드가 호출되는 것을 볼 수 있습니다. 혹시나 싶어서 모듈 B의 서버를 끈 상태로 호출을 해봤는데 이 때에도 정상적으로 콜백 메소드 호출됩니다.


마치며

이렇게 Resilience4j 를 이용해서 CircuitBreaker를 구현하는 방법에 대해서 알아보았습니다. 이해가 안가서 정말 많은 구글링과 공식문서를 찾아보았습니다. 이번 글에선 CircuitBreaker만 다뤘지만, Resilience4j 에는 다양한 회복 패턴을 지원합니다.(관심가는건 timeLimited) 다음에 기회가 된다면 알아보고 글로 남겨보고 싶습니다. CircuitBreaker 하나를 알아보며 다른 패턴들의 개념까지 (아주 얕게) 알아가는 시간이었습니다. 공부는 양파같네요.. 삽질도 많이하고 유익하고 즐거운 시간이었습니다. 


참고

https://www.youtube.com/watch?v=WL0eIKD8krU 

https://javachoi.tistory.com/402

댓글