[BooTakHae] Resilience4j - CircuitBreaker & Retry

Kim Hyen Su·2024년 4월 29일

BooTakHae

목록 보기
7/22
post-thumbnail

공식문서

참고 포스팅

1. 환경 구축

Dependencies

	implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
	implementation 'org.springframework.boot:spring-boot-starter-aop'
	implementation 'io.github.resilience4j:resilience4j-all'
	implementation 'org.springframework.boot:spring-boot-starter-actuator'
  • aop와 resilience4j-all 의존은 Resilience4j를 어노테이션 형식으로 사용하기 위해 추가한 의존입니다.

2. CircuitBreaker

CircuitBreaker에서 실패와 지연 횟수/시간 를(을) 집계하기 위해 내부적으로 구현된 알고리즘이 'Sliding Window' 알고리즘 입니다.

고정된 사이즈의 큐 자료구조를 '윈도우'라고 칭합니다.

큐에서 하나의 데이터 주기가 지날때마다, 윈도우 내부 데이터 갯수는 같지만 이동할 앞의 데이터를 추가한 뒤 제일 끝에 있는 데이터를 빼줍니다.

CircuitBreaker를 구현하기 위해서는 방법이 크게 2가지로 구분됩니다.

  1. Java Config Class로 정의하여 사용.

  2. Annotation의 형태로 구현 및 사용.

Annotation 형태로 CircuitBreaker 패턴을 구현하겠습니다.

CircuitBreaker 생성 및 구성

CircuitBreakerConfig를 통해 관련 설정을 제공합니다. 사용자 정의 전역 CircuitBreakerConfig를 생성하려면 CircuitBreakerConfig Builder를 사용할 수 있습니다.

  • 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_OEPN 상태로 무한정 기다립니다. (기본값: 0)

  • slidingWindowType : sliding window 타입을 결정합니다. COUNT_BASED인 경우 slidingWindowSize 만큼의 마지막 call들이 기록되고 집계됩니다.
    TIME_BASED인 경우 마지막 slidingWindowSize초 동안의 call들이 기록되고 집계됩니다. (기본값: COUNT_BASED)

  • slidingWindowSize : CLOSED 상태에서 집계되는 슬라이딩 윈도우 크기를 설정한다. (기본값: 100)

  • minimumNumberOfCalls : minimumNumberOfCalls 이상의 요청이 있을 때부터 faiure/slowCall rate를 계산합니다. 예를 들어, 해당 값이 5라면 최소한 호출을 5번을 기록해야 실패 비율을 계산할 수 있습니다. 기록한 호출 횟수가 4번뿐이라면 4번 모두 실패했더라도 circuitbreaker는 열리지 않습니다. (기본값: 100)

  • waitDurationInOpenState : OPEN에서 HALF_OPEN 상태로 전환하기 전 대기 시간을 말합니다.(60초, 1000 ms = 1 sec) (기본값: 60000[ms])

  • recordExceptions : 실패로 기록할 Exception 리스트입니다. (기본값: empty)

  • recordFailure : 어떠한 경우에 Failure Count를 증가시킬지 Predicate를 정의해 CircuitBreaker에 대한 Exception Handler를 재정의.
    true를 return할 경우, failure count를 증가시키게 된다 (기본값: false)

  • ignoreExceptions : 실패나 성공으로 기록하지 않을 Exception 리스트입니다. (기본값: empty)

  • ignoreException : 기록하지 않을 Exception을 판단하는 Predicate을 설정 (커스터마이징, 기본값: throwable -> true)

실습

Cicuit OPEN, CLOSED, HALF_OPEN 테스트를 위해서 다음과 같이 테스트 코드를 구현하겠습니다.

User-Service

ErrorfulController

@RestController
@RequestMapping("test/internal")
public class ErrorfulController {

    // 임계값 테스트 - 실패율
    @GetMapping("/errorful/case1")
    public ResponseEntity<String> case1() {
        // Simulate 5% chance of 500 error 랜덤 중 100 중 5가 나올 확률을 시뮬레이팅
        int num = new Random().nextInt(100);
        if (num < 5) {
            System.out.println("num : " + num);
            return ResponseEntity.status(500).body("Internal Server Error");
        }

        return ResponseEntity.ok("Normal response");
    }

    // 임계값 테스트 - 느린 호출율
    @GetMapping("/errorful/case2")
    public ResponseEntity<String> case2() {
        // Simulate blocking requests every first 10 seconds
        LocalTime currentTime = LocalTime.now();
        int currentSecond = currentTime.getSecond();

        if (currentSecond < 10) {
            System.out.println("currentSecond : " + currentSecond);
            // Simulate a delay (block) for 10 seconds
            try {
                System.out.println("===========================================" + System.currentTimeMillis());
                Thread.sleep(10000);
                System.out.println("===========================================" + System.currentTimeMillis());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return ResponseEntity.status(503).body("Service Unavailable");
        }

        return ResponseEntity.ok("Normal response");
    }

    // 슬라이딩 윈도우(시간 기반) - 10초 이내에 오류가 발생하는 경우
    @GetMapping("/errorful/case3")
    public ResponseEntity<String> case3() {
        // Simulate 500 error every first 10 seconds
        LocalTime currentTime = LocalTime.now();
        int currentSecond = currentTime.getSecond();

        if (currentSecond < 10) {
            return ResponseEntity.status(500).body("Internal Server Error");
        }

        return ResponseEntity.ok("Normal response");
    }
}

Order-Service

Dependencies

	implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
	implementation 'org.springframework.boot:spring-boot-starter-actuator'

application.yml

# CircuitBreaker 설정
resilience4j.circuitbreaker:
    instances:
      cb-case:
        base-config: default
    configs:
      default:
        # sliding window
        sliding-window-type: count_based # Sliding Window 타입 : 횟수 or 시간
        sliding-window-size: 10 # Sliding Window Size : 총 10개를 기준으로 집계 수행

        # fail
        failure-rate-threshold: 20 # 임계치 : sliding window size 가 10인 경우, 10개 중 1갬만 fail 이 발생해도, 임계치 이상 fail 발생으로 인해 CircuitBreaker 가 open 됩니다.
        minimum-number-of-calls: 5 # 10개를 굳이 채우지 않더라도, 최소 5회 요청 집계 후 임계치 이상일 경우, CircuitBreaker 를 Open 해주는 설정.

        # delay
        slow-call-rate-threshold: 10 # Circuit Open 지연 비율 설정, 지연수 / 슬라이딩 윈도우 크기, 10% 임계치 설정
        slow-call-duration-threshold: 3000ms #3초 이상 지연 시, 지연 요청

        # half open
        permitted-number-of-calls-in-half-open-state: 10 # open 된 상태에서 최소 10회 요청을 수행, 성공 시 close 실패 시 open

        # half open 유지 시간
        max-wait-duration-in-half-open-state: 0 # 0인 경우, 설정한 값만큼 수행 후 바로 다음 상태로 전환

        wait-duration-in-open-state: 6000ms # open 상태 유지 시간, 해당 시간 지연 후 half-open 으로 전환
        automatic-transition-from-open-to-half-open-enabled: true # half open 전환 시 자동으로 전환할 지 여부를 체크

        # actuator 설정
        register-health-indicator: true # open / close 여부를 체크

        # 예외 처리 설정 - 예상되는 예외만 실패처리에서 제외
#        record-exceptions:
#          - java.io.IOException
#          - java.util.concurrent.TimeoutException
#          - org.springframework.web.client.HttpServerErrorException

UserClient

@FeignClient(name = "user-service")
public interface UserClient {

    @GetMapping("test/internal/errorful/case1")
    String case1();

    @GetMapping("test/internal/errorful/case2")
    String case2();

    @GetMapping("test/internal/errorful/case3")
    String case3();
}

ErrorController

@RestController
@RequestMapping("test")
@RequiredArgsConstructor
public class ErrorController {

    private final ErrorService errorService;

    @GetMapping("case1")
    public ResponseEntity<?> test1(){
        String result = errorService.errorTest1();

        return ResponseEntity.ok(result);
    }

    @GetMapping("case2")
    public ResponseEntity<?> test2(){
        String result = errorService.errorTest2();

        return ResponseEntity.ok(result);
    }

    @GetMapping("case3")
    public ResponseEntity<?> test3(){
        String result = errorService.errorTest3();

        return ResponseEntity.ok(result);
    }
}

ErrorService

@Slf4j
@Service
@RequiredArgsConstructor
public class ErrorService {
    private final UserClient userClient;

    @CircuitBreaker(name = "cb-case", fallbackMethod = "fallbackMethod1")
    public String errorTest1(){
        return userClient.case1();
    }

    public String errorTest2(){
        return userClient.case2();
    }

    public String errorTest3(){
        return userClient.case3();
    }

    private String fallbackMethod1(Throwable throwable){
        return "[fallback] : " + throwable.getMessage();
    }
}

Test

case1 : Count_Base & 실패율 임계치

초기 테스트 전

실패율 0% (0/ 10)

실패율 10% (1/ 10)

1회 실패가 발생했지만, 임계치가 20%이므로, Circuit이 CLOSED 상태입니다.

💡 참고

슬라이딩 윈도우로 동작하기 때문에, 1회 실패한 뒤 10회의 성공이 발생할 경우, 실패횟수는 다시 0이 됩니다.

실패율 20% - OPEN

임계치(20%) 도달 시, 정상적으로 Circuit이 OPEN 되는것을 확인할 수 있습니다.

HALF_OPEN

아래 설정으로 인해 6초 뒤 OPEN → HALF_OPEN으로 전환됩니다.

wait-duration-in-open-state: 6000ms # open 상태 유지 시간, 해당 시간 지연 후 half-open 으로 전환

최소 수행횟수 10회 이지만, minimum-number-of-calls 설정으로 인해 5회 기준으로 집계를 하여 임계치 미만 시 CLOSED, 이상 시 OPEN으로 전환됩니다.

minimum-number-of-calls: 5 # 10개를 굳이 채우지 않더라도, 최소 5회 요청 집계 후 임계치 이상일 경우, CircuitBreaker 를 Open 해주는 설정.

Fallback 함수

설정

@Slf4j
@Service
@RequiredArgsConstructor
public class ErrorService {
    private final UserClient userClient;

    @CircuitBreaker(name = "cb-case", fallbackMethod = "fallbackMethod1")
    public String errorTest1(){
        return userClient.case1();
    }

    public String errorTest2(){
        return userClient.case2();
    }

    public String errorTest3(){
        return userClient.case3();
    }

    private String fallbackMethod1(Throwable throwable){
        return "[fallback] : " + throwable.getMessage();

    }
}

위처럼 fallback 함수명을 fallbbackMethod에 설정 추가해주면, 실패 시마다 해당 fallback 함수가 수행됩니다.

  • 적용 전 응답

  • 적용 후 응답

Case2 : Count_Base & 지연율 임계치

현재 시간의 초가 10초 이내인 경우, 10초 동안의 시간 지연 발생하도록 sleep 메서드를 수행합니다.

현재 지연 초과 시간 설정은 3초입니다.

 slow-call-duration-threshold: 3000ms

3초 이상 지연 발생 시, 다음과 같이 실패 및 지연 횟수가 올라가게 됩니다.

지연율 임계치가 10% 이므로, Circuit이 정상적으로 OPEN 됩니다.

Case3: Time_Base & 실패율 임계치

기존의 Count_Base와는 다르게 Time_Base의 경우, 10의 의미는 10초 동안에 실패율 20% 이상을 달성했는지 여부를 확인하며, 위의 경우 10초 내 발생했던 요청들이 모두 실패하여 OPEN 된 상태입니다.


3. Retry

재시도와 관련된 Resilience4j 라이브러리입니다.

실패한 실행을 짧은 지연을 가진 후 재시도하게 됩니다.

retry의 기본적인 우선 순위는 CircuitBreaker 보다 낮기 때문에, CircuitBreaker 실행 후 실행하게 됩니다. 이로 인해, fallbackmethod를 등록한 circuit의 경우 처리를 하면, 안됩니다.

만약, fallbackmethod 동작 전 retry 를 수행하고 싶을 경우, Config 설정에서 우선순위를 변경해주면 됩니다.

Retry 생성 및 구성

RetryConfig를 통해 관련 설정을 제공합니다. 사용자 정의 전역 RetryConfig를 생성하려면 RetryConfig Builder를 사용할 수 있습니다.

  • maxAttempts : 최대 시도 횟수(처음 요청 시도를 포함한 횟수입니다.)

  • waitDuration : 재시도 간에 지연 시간(ms)

  • intervalFunction : 동적으로 waitDuration을 만들고자 할 때 사용

  • intervalBiFunction : 동적으로 waitDuration을 만들고자 할 때 사용(매개변수 2개)

  • retryOnResultPredicate : 반환 결과에 따라서 retry 여부를 결정하는 필터(true - retry, false - ignore)

  • retryExceptionPredicate : 해당 exception 종류에 따라서 retry 여부를 결정하는 필터

  • retryExceptions : 실패로 기록하고 재시도하는 Exception 리스트.

  • ignoreExceptions : Exception이 발생해도 ignore하는 Exception 리스트.

  • failAfterMaxRetries : 모든 시도를 실패했을 때 MaxRetriesExceededException를 리턴할지 아니면 해당 Exception을 리턴할지 정하는 값

실습

Order-Service

application.yml

# Retry 설정
resilience4j.retry:
  instances:
    rt-case:
      base-config: default
  configs:
    default:
      max-attempts: 4
      wait-duration: 2000ms

ErrorService

@Slf4j
@Service
@RequiredArgsConstructor
public class ErrorService {
    private final UserClient userClient;

//    @CircuitBreaker(name = "cb-case", fallbackMethod = "fallbackMethod1")
    @Retry(name = "rt-case", fallbackMethod = "fallbackRetry")
    public String errorTest1(){
        return userClient.case1();
    }
    
    ...

    private String fallbackRetry(Exception e){
        return "[retry-fallback] : " + e.getMessage();
    }
}

Test

요청 중 실패 발생할 경우, 해당 요청에 대해 Retry가 실행됩니다.(2초마다 4번)

Retry 중 한번이라도 요청에 성공할 경우, 정상 응답을 반환합니다.

만약, Retry를 했음에도 요청을 실패한 경우, fallback 함수에 정의된 함수가 호출됩니다.

profile
백엔드 서버 엔지니어

0개의 댓글