BackEnd/SpringBoot

CircuitBreaker 로 예방하는 장애 전파(2)

hyunki.Dev 2023. 12. 12. 09:43

 

이번 포스팅에서는 지난 포스팅에 이어

CircuitBreaker를 실제로 구현하는 것에 대해 알아보도록 하겠습니다.

 

📌CircuitBreaker를 지원하는 라이브러리의 종류

1. Netflix Hystrix

Netflix 에서 개발한 라이브러리로 MSA 환경에서 서비스 간 통신이 원활하지 않을 경우

각 서비스가 장애 내성과 지연 내성을 갖게 하는 라이브러리이지만 현재는 deprecated 된 상태로

더 이상의 업데이트가 없으며  공식 문서에서도 Resilience4j 의 사용을 권장하고 있습니다.

 

2. Resilience4j

Netflix Hystrix로부터 영감을 받아 개발된 Fault Tolerance 라이브러리

Java 전용으로 개발된 라이브러리 입니다.

 

📌CircuitBreaker의 코어 모듈

Resilience4j 의 코어 모듈은 아래와 같으며, 필요한 모듈을 선택하여 사용할 수 있습니다.

dependencies {
  // 1. CircuitBreaker : 장애 전파 방지 기능 제공
  implementation("io.github.resilience4j:resilience4j-circuitbreaker:${resilience4jVersion}")
  // 2. Retry : 요청 실패 시 재시도 처리 기능 제공
  implementation("io.github.resilience4j:resilience4j-retry:${resilience4jVersion}")
  // 3. RateLimiter : 제한치를 넘어서 요청을 거부하거나 Queue 생성하여 처리하는 기능 제공
  implementation("io.github.resilience4j:resilience4j-ratelimiter:${resilience4jVersion}")
  // 4. TimeLimiter : 실행 시간제한 설정 기능 제공
  implementation("io.github.resilience4j:resilience4j-timelimiter:${resilience4jVersion}")
  // 5. Bulkhead : 동시 실행 횟수 제한 기능 제공
  implementation("io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}")
  // 6. Cache : 결과 캐싱 기능 제공
  implementation("io.github.resilience4j:resilience4j-cache:${resilience4jVersion}")
}

 

 

Resilience4j 모듈의 우선순위

각 모듈은 다음과 같은 우선순위로 적용됩니다. (Retry 모듈이 가장 마지막에 적용됩니다.)

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

 

이를 알아보기 위해 resilience4j의 CircuitBreakerConfigurationProperties, RetryConfigurationProperties 클래스 내부를 살펴보면,CircuitBreaker 와 Retry 의 Order 값이 각각 -3, -4 로
별도 처리가 없으면, CircuitBreaker 가 Retry 보다 우선으로 적용된다는 것을 알 수 있습니다.

 

 

📌OpenFeign 에 Resilience4J 적용하기

적용 순서

  • 의존성 추가
  • 설정 파일 추가
  • recordFailurePredicate 작성
  • CircuitBreakerNameResolver 작성
  • CallNotPermittedException 예외 처리 
  • Fallback 처리

 

의존성 추가 

가장 먼저 build.gradle에 Resilience4J 적용에 필요한 의존성을 추가해주어야 합니다.

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

 

 

설정 파일 추가

그 다음으로 Feign에 서킷브레이커 적용을 활성화 해주어야 합니다. application.yaml 파일에서 활성화 할 수 있습니다.

해당 설정은 FeignAutoConfiguration에 의해 적용되며 Spring-Cloud-OpenFeign 4.0.0-SNAPSHOT 버전부터는ㄴ

 spring.cloud.openfeign.circuitbreaker.enabled 값으로 변경되었습니다.

 

feign:
  client:
    config:
      default:
        connectTimeout: 5000
        readTimeout: 5000

  # Updated to spring.cloud.openfeign.circuitbreaker.enabled in 4.0.0-SNAPSHOT
  circuitbreaker:
    enabled: true

 

 

이렇게 설정 후 서킷브레이커와 관련된 설정을 넣어줘야 합니다. 

아래는 CircuitBreaker 모듈이 제공하는 설정들을 보여줍니다.

 

https://mangkyu.tistory.com/289

 

 

resilience4j:
  circuitbreaker:
    configs:
      default:
        sliding-window-type: COUNT_BASED
        slidingWindowSize: 100 # sliding window 크기 (default)
        minimumNumberOfCalls: 100 # failureRate, slowCallRate 를 계산하기 위한 최소 call 갯수 (default)
        waitDurationInOpenState: 30s # OPEN 상태에서 HALF-OPEN 상태로 변경 대시 시간 (빨리 전환되어 복구 될 수 있도록 기본값(60s)보다 작게 설정)
        failureRateThreshold: 30 # 실패율 임계값 (임계값 초과시 CLOSED -> OPEN 으로 변경, default : 50)
    instances:
      default:
        baseConfig: default
  timelimiter:
    configs:
      default:
        timeoutDuration: 30s # slowCallDurationThreshold 보다는 크게 설정 , 30s 가 경과하면 Timeout 발생
        cancelRunningFuture: true #현재 진행중인 작업을 취소함

 

 yaml 파일을 이용하면 설정값을 바탕으로 자동 설정(AutoConfig)이 되는데, 공통으로 사용할 값들은 configs에 정의하고 개별 인스턴스 설정은 instances에 작성해주면 됩니다. Resilience4J는 Thread-safe와 원자성 보장을 제공하는 ConcurrentHashMap 기반의 인메모리 CircuitBreakerRegistry를 제공해 줍니다. 해당 객체에서 설정 내용이 관리되며, CircuitBreaker 객체를 얻어올 수 있습니다.

 

 

recordFailurePredicate 작성

recordFailurePredicate는 어떤 예외를 Fail로 기록할 것인지를 결정하기 위한 Predicate 설정입니다. 해당 클래스에서 true를 반환하면 요청 실패로 기록되며, 실패가 쌓이면 서킷이 OPEN 상태로 변경됩니다. OpenFeign과 연동하는 상황에서는 기본적으로 아래와 같이 작성할 수 있으며, 상황에 따라 커스터마이징할 수 있습니다.

 

public class DefaultExceptionRecordFailurePredicate implements Predicate<Throwable> {

    // 반환값이 True면 Fail로 기록됨
    @Override
    public boolean test(Throwable t) {
        // occurs in @CircuitBreaker TimeLimiter
        if (t instanceof TimeoutException) {
            return true;
        }

        // occurs in @OpenFeign
        if (t instanceof RetryableException) {
            return true;
        }

        return t instanceof FeignException.FeignServerException;
    }

}

 

 

만약 timeLimiter에 설정한 연결 시간을 초과하거나 커넥션에 실패했다면 TimeoutException이 발생하는데, 해당 경우에는 서킷을 열어서 요청을 차단해야 하므로 true를 반환하도록 하였습니다. 또한 RetryableException은 Feign에서 던지는 Retry 가능한 예외인데, 해당 예외도 true로 반환하도록 하였다. 이는 상황에 따라 달라질 수 있으므로 false로 반환이 필요하다면 수정해주면 됩니다. 그리고 그 외에 FeignException 중에서 FeignServerException이라면 true를 반환하도록 설정 되었습니다.

해당 Predicate 클래스를 적용하려면 yaml 설정 파일에 recordFailurePredicate 내용을 추가해주면 됩니다.

 

resilience4j:
  circuitbreaker:
    configs:
      default:
        waitDurationInOpenState: 30s # HALF_OPEN 상태로 빨리 전환되어 장애가 복구 될 수 있도록 기본값(60s)보다 작게 설정
        slowCallRateThreshold: 80 # slowCall 발생 시 서버 스레드 점유로 인해 장애가 생길 수 있으므로 기본값(100)보다 조금 작게 설정
        slowCallDurationThreshold: 5s # 위와 같은 이유로 5초를 slowCall로 판단함. 해당 값은 TimeLimiter의 timeoutDuration보다 작아야 함
        registerHealthIndicator: true
        recordFailurePredicate: com.starbucks.openfeign.app.openfeign.circuit.DefaultExceptionRecordFailurePredicate
    instances:
      default:
        baseConfig: default
  timelimiter:
    configs:
      default:
        timeoutDuration: 6s # slowCallDurationThreshold보다는 크게 설정되어야 함
        cancelRunningFuture: true

출처: https://mangkyu.tistory.com/289 [MangKyu's Diary:티스토리]

 

 

CircuitBreakerNameResolver 작성

CircuitBreaker 인스턴스는 여러 개로 관리될 수 있습니다.

예를 들어 커머스앱이라면 “관리자 서버”, “주문 서버” 등이 있고, 각각을 FeignClient로 호출하게 됩니다. 서로 다른 서버들이 별도의 CircuitBreaker 인스턴스로 관리되지않으면 “관리자 서버”만 문제있는 상황에서 “주문 서버”로의 요청도 막히게 됩니다.

 

 그래서 이를 처리하기 위한 CircuitBreaker 인스턴스를 지정해주어야 하는데, OpenFeign은 해당 FeignClient가 어떤 인스턴스를 적용할지 식별할 수 있는 CircuitBreakerNameResolver 인터페이스를 제공해줍니다.

해당 인터페이스를 구현하지 않으면 기본적으로 FeignClient의 이름과 메소드를 조합하여 사용하는 DefaultCircuitBreakerNameResolver가 사용됩니다. 만약 숫자와 알파벳만으로 설정을 하고 싶다면 alphanumeric-ids 옵션을 주면 됩니다. 

 

 참고로 이때 CircuitBreaker 인스턴스를 찾지 못한다면 인메모리에 새로운 인스턴스를 생성하게 됩니다.

따라서 앞에서 했던 설정에서 default 인스턴스 외에는 별도로 미리 생성해두지 않은 것입니다.

 

@Configuration
public class OpenFeignConfig {

    @Bean
    public CircuitBreakerNameResolver circuitBreakerNameResolver() {
        return (String feignClientName, Target<?> target, Method method) -> feignClientName + "_" + method.getName();
    }
}

 

 

 

CallNotPermittedException 예외 처리 

서킷이 OPEN 상태로 바뀌면 더 이상 요청이 전달되지 않게 되는데

대신 요청을 차단하고 바로 CallNotPermittedException 예외를 발생시킵니다.

그러므로 각각의 예외 처리 방법에 맞게 CallNotPermittedException 예외를 처리해주어야 합니다.

일반적으로 ControllerAdvice를 사용하고 있을 것인데, 그렇다면 해당 클래스에 아래의 내용을 추가 및 구현하면 됩니다.

@ExceptionHandler(CallNotPermittedException.class)
    public ResponseEntity<?> handleCallNotPermittedException(CallNotPermittedException e) {
        return ResponseEntity.internalServerError()
                .body(Collections.singletonMap("code", "InternalServerError"));
}

 

 

Fallback  처리

요청이 실패하였을 때 기본값을 반환하여 해당 기능을 정상 동작하게 하고 싶을 수 있습니다.

이때 Fallback을 구현하여 기본값을 반환 시킬 수 있습니다. 

 


[ Fallback이 없는 경우의 에러 처리 ]

Open Feign은 Spring Cloud가 제공하는 라이브러리이며, Open Feign이 서킷 브레이커를 적용하는 방법 역시 Spring Cloud가 제공하는 Spring Cloud CircuitBreaker를 기반으로 합니다.

 

Spring Cloud CircuitBreaker는 일관된 서킷 브레이커 적용을 위한 인터페이스를 제공하는데,

해당 인터페이스에서 Fallback이 없는 경우라면 예외를 반드시 NoFallbackAvailableException으로 감싸서 반환하도록 하고 있습니다. 물론 일반적으로 사용되는 예외 처리기인 @ExceptionHandler는 Exception의 cause가 있을 경우에 cause로 에러 처리를 해주고 있어서, 큰 문제가 없을 수 있다. 하지만 그럼에도 불구하고 다음과 같이 실제 cause에 해당하는 에러를 출력해주는 것이 좋습니다.

 

@ExceptionHandler(NoFallbackAvailableException.class)
public ResponseEntity<Object> noFallbackAvailableException(HttpServletRequest request, NoFallbackAvailableException e) {
    log.warn("uri: {}, exception : ", request.getRequestURI(), e.getCause());
    return handleExceptionInternal(CommonErrorCode.INTERNAL_SERVER_ERROR);
}

 

 

[ TimeLimiter의 Timeout 설정 ]

위의 yaml 설정에서 작성했던 TimeLimiter의 timeout은 클라이언트로 작업을 위임하는 시간입니다.

Resilience4J는 함수형 기반의 라이브러리인만큼 내부적으로 java.concurrenct 패키지의 도구들을 이용하는데, 해당 값은 Java Future 객체의 get에 전달된다. 예를 들어 timeout 값이 3초로 되어 있고, API 응답이 4초가 걸린다고 하자. 그러면 아래와 같은 상황이 발생할 수 있습니다.

  1. 작업 처리 시간을 3초로 설정함(TimeLimiter)
  2. 해당 요청 처리가 일시적으로 4초로 지연됨
  3. 작업 처리 시간이 만료되어 Fallback 처리 또는 예외 발생
  4. 작업 처리 시간이 만료되었으므로 Retry 등도 처리하지 않음

 

TimeLimiter의 timeout은 전체 작업 처리 시간의 timeout에 해당한다. 그러므로 위와 같은 경우라면 작업 처리 시간이 만료되었으므로 Retry 등도 시도하지 않을 것입니다. 이러한 상황이 생기지 않도록 timeout 값은 신중히 설정되어야 합니다.

기본적으로 TimeLimiter의 timeout 값은 CircuitBreaker의 slowCallDurationThreshold와 OpenFeign의 connectionTimeout, readTimeout 보다는 크게 설정되어야 한다. 그래야 응답이 조금 오래걸리는 상황에서도 정상적으로 처리가 가능하다. 그 외에 slowCall에 대해서도 재시도를 고려한다면 조금 더 큰 값으로 설정해줄 수도 있을 것이다.

 

 

 

출처:

https://mangkyu.tistory.com/289

https://oliveyoung.tech/blog/2023-08-31/circuitbreaker-inventory-squad/