용로그
article thumbnail

들어가며


이번 글에서는 자바 진영의 서킷 브레이커(CircuitBreaker) 라이브러리인 Resilience4j를 사용하여 외부 서버 통신에 대한 장애를 처리하는 방법을 살펴보겠습니다. 서킷 브레이커 패턴에 대한 내용은 이 글을 참고해 주세요.

 

Resilience4j


Resilience4j에는 여러 가지 코어 모듈이 존재합니다. 이 중에서 CircuitBreaker를 활용할 것이기 때문에 이 부분만 자세히 살펴보도록 하겠습니다.

CircuitBreaker

Resilience4j의 CircuitBreaker는 CLOSED, OPEN, HALF OPEN으로 이루어진 일반적으로 3가지의 일반 상태와 DISABLED 및 FORCED OPEN으로 이루어진 2가지의 특수 상태를 제공합니다.

 

 

CircuitBreaker 상태

CircuitBreaker는 슬라이딩 윈도우를 사용하여 호출 결과를 저장하고 집계합니다. 슬라이딩 윈도우는 2가지로 분류할 수 있습니다.

  • 개수 기반 : 마지막 n개의 호출 결과 계산
  • 시간 기반 : 마지막 n초의 호출 결과 계산

개수 기반 슬라이딩 윈도우

개수 기반 슬라이딩 윈도우는 전체 집계를 점진적으로 업데이트합니다. 새 결과가 기록되면 전체 집계에서 가장 오래된 값이 제거되며 전체 집계가 업데이트됩니다.

 

시간 기반 슬라이딩 윈도우

시간 기반 슬라이딩 윈도우 또한 전체 집계를 점진적으로 업데이트합니다. 가장 오래된 데이터가 제거되면 해당 버킷의 부분 합계 집계가 전체 집계에서 제거되고 버킷이 재설정됩니다.

 

실패율 및 느린 호출 속도 임계값

실패율이 구성 가능한 임계값보다 크거나 같으면 서킷브레이커의 상태가 CLOSED에서 OPEN으로 변경됩니다. 기본적으로 모든 예외가 실패로 간주되기 때문에 클라이언트 예외와 같은 4xx 번대 예외는 별도로 처리해주어야 합니다.

 

느린 호출의 비율이 설정한 값보다 크거나 같을 때도 서킷브레이커의 상태가 CLOSED에서 OPEN으로 변경됩니다. 이는 외부 시스템이 실제로 응답하기 전에 외부 시스템의 로드를 줄이는데 도움이 됩니다.

 

HALF OPEN 상태는 OPEN과 CLOSED 상태와 조금 다릅니다. 서킷브레이커는 OPEN 상태에서 설정한 대기 시간이 지나면 HALF OPEN 상태로 변경됩니다. 이때 외부 서버가 사용할 수 있게 되었는지, 여전히 사용할 수 없는지 확인하기 위한 호출 횟수를 지정해주게 됩니다.

 

이 때 설정한 호출 횟수가 모두 소진될 때까지 다른 호출들은 거부되며, 실패율 또는 느린 호출의 값이 설정한 임계값보다 크거나 같으면 상태가 OPEN으로, 작으면 CLOSED로 변경됩니다.

FORCED_OPEN과 DISABLED

Resilience4j의 CircuitBreaker 모듈은 특수한 2가지 상태를 더 지원합니다. 바로 FORCED_OPEN과 DISABLED 상태입니다. FORCED_OPEN 상태는 서킷브레이커의 상태를 항상 OPEN으로 유지합니다. 반대로 DISABLED 상태는 서킷브레이커의 상태를 CLOSED 하여 요청을 항상 허용하는 상태입니다. 이 2가지 상태에서는 서킷브레이커 상태가 변경되지 않으며, 메트릭도 기록되지 않습니다. 이러한 상태를 종료하기 위해서는 상태 전환을 트리거하거나 서킷브레이커를 재설정해야 합니다.

 

CircuitBreaker 생성 및 구성


Builder를 통해 CircuitBreakerConfig를 만들 수 있는데, 이는 yml에서 직접 설정할 수도 있으므로 한 번씩 훑어보는 걸 추천합니다. 

 

구성 속성 기본 값 설명
failureRateThreshold 50 실패율이 임계값보다 크거나 같으면 서킷브레이커의 상태가 OPEN됩니다.
slowCallRateThreshold 100 서킷브레이커는 외부 서버의 호출 시간이 해당 설정값 보다 길면 느린 호출(slow call)로 간주합니다.
slowCallDurationThreshold 60000[ms] 호출이 느린것으로 간주되는 시간 임계값입니다.
permittedNumberOfCallsInHalfOpenState 10 서킷브레이커가 HALF_OPEN 상태일 때 서버가 응답이 가능한 상태인지 확인하기 위한 호출 횟수입니다.
maxWaitDurationInHalfOpenState 0[ms] HALF_OPEN 상태로 유지할 수 있는 최대 대기 시간을 지정합니다. 0으로 설정하면 모든 호출이 완료될 때까지 서킷브레이커가 HALF_OPEN 상태로 무한 대기합니다.
slidingWindowType COUNT_BASED 슬라이딩 윈도우가 COUNT_BASED인 개수 기반으로 집계하며, TIME_BASED인 경시간 기반으로 집계합니다.
slidingWindowSize 100 서킷브레이커가 CLOSED될 때 호출 결과를 기록하는데 사용되는 슬라이딩 윈도우의 크기를 구성합니다.
minimumNumberOfCalls 100 서킷브레이커가 오류 비율 또는 느린 호출 비율을 계산하기 전에 필요한 최소 호출 수를 구성합니다.
waitDurationInOpenState 60000[ms] 서킷브레이커가 OPEN 상태에서 HALF_OPEN 상태로 전환되기 전에 기다려야 하는 시간입니다.
automaticTransitionFromOpenToHalfOpenEnabled false true로 설정하면 서킷브레이커 상태가 자동으로 HALF_OPEN으로 전환됩니다.
false로 설정하면 호출이 이루어진 경우에만 HALF_OPEN으로의 전환이 발생합니다.
recordExceptions empty 이 설정에 지정한 예외들은 모두 실패로 간주됩니다.
ignoreExceptions empty 이 설정에 지정한 예외들은 모두 성공으로 간주합니다.
recordFailurePredicate throwable -> true 예외가 실패로 기록되어야 하는지 평가하는 사용자 정의 조건자입니다.
ignoreExceptionPredicate throwable -> false 예외를 무시하고 실패나 성공으로 간주하지 않아야 하는지 평가하는 사용자 정의 조건자입니다.

 

CircuitBreakerConfig 예시

// Create a custom configuration for a CircuitBreaker
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
  .failureRateThreshold(50)
  .slowCallRateThreshold(50)
  .waitDurationInOpenState(Duration.ofMillis(1000))
  .slowCallDurationThreshold(Duration.ofSeconds(2))
  .permittedNumberOfCallsInHalfOpenState(3)
  .minimumNumberOfCalls(10)
  .slidingWindowType(SlidingWindowType.TIME_BASED)
  .slidingWindowSize(5)
  .recordException(e -> INTERNAL_SERVER_ERROR
                 .equals(getResponse().getStatus()))
  .recordExceptions(IOException.class, TimeoutException.class)
  .ignoreExceptions(BusinessException.class, OtherBusinessException.class)
  .build();

// Create a CircuitBreakerRegistry with a custom global configuration
CircuitBreakerRegistry circuitBreakerRegistry = 
  CircuitBreakerRegistry.of(circuitBreakerConfig);

// Get or create a CircuitBreaker from the CircuitBreakerRegistry 
// with the global default configuration
CircuitBreaker circuitBreakerWithDefaultConfig = 
  circuitBreakerRegistry.circuitBreaker("name1");

// Get or create a CircuitBreaker from the CircuitBreakerRegistry 
// with a custom configuration
CircuitBreaker circuitBreakerWithCustomConfig = circuitBreakerRegistry
  .circuitBreaker("name2", circuitBreakerConfig);

 

Resilience4j-CircuitBreaker 적용하기


이제 프로젝트에 Resilience4j를 적용해 볼 차례입니다.

 

의존성 추가

가장 먼저 Resilience4j의 의존성을 추가해 줍니다.

implementation "org.springframework.boot:spring-boot-starter-aop"
implementation group: 'io.github.resilience4j', name: 'resilience4j-spring-boot3', version: '2.1.0'

 

aop 의존성이 필요한 이유는 서킷브레이커 모듈에서 지정한 예외가 발생했을 경우 fallbackMethod를 실행해야 하기 때문입니다.

 

또 한가지 주의할 점은 version을 명시하는 것인데요, 왠지 모르겠는데 version을 지정하지 않으면 CircuitBreaker 모듈을 정상적으로 불러올 수 없었습니다. 각자 상황에 맞는 버전을 찾아 사용하는 것을 추천합니다.(2023.11.10 기준 최신 버전 2.1.0)

설정 파일 추가

resilience4j:
  circuitbreaker:
    configs:
      default:
        sliding-window-type: COUNT_BASED
        registerHealthIndicator: true
        slidingWindowSize: 3
        minimumNumberOfCalls: 1
        permittedNumberOfCallsInHalfOpenState: 3
        automaticTransitionFromOpenToHalfOpenEnabled: true
        waitDurationInOpenState: 5s
        failureRateThreshold: 50
        eventConsumerBufferSize: 10
  instances:
    webClientGithubClient:
      baseConfig: default
  timelimiter:
    configs:
      default:
        timeoutDuration: 6s
        cancelRunningFuture: true

 

resilirence4j-spring-boot3 의존성을 사용하기 때문에 application.yml을 통해 선언적으로 서킷브레이커 설정을 할 수 있습니다. 여기서 서킷브레이커 인스턴스와 timeLimiter 인스턴스만 별도의 설정을 하였습니다. 이 설정 값은 각자의 환경에 맞게 설정하는 게 중요합니다. 

 

서킷브레이커 적용

그다음으로는 서킷브레이커를 적용해야 합니다. 서킷 브레이커를 적용하는 방법으로는 크게 2가지로 나뉘는데, 코드로 구현하는 방법과 어노테이션을 사용하는 방법이 있습니다.

 

먼저 코드로 구현하는 방식을 살펴보겠습니다. 코드 방식은 다음과 같이 서킷브레이커를 직접 주입받고 적용해 주는 것입니다. executeSupplier라는 함수형 인터페이스를 사용하면 되고, 별도의 fallback 처리를 하고 싶다면 decorateSupplier를 사용해 주면 됩니다.

 

@Component
@RequiredArgsConstructor
public class WebClientGithubClient implements GithubClient {

    private final WebClient webClient;
    private final CircuitBreakerRegistry registry;

    @Override
    public OAuthGithubUsernameResponse getGithubUsername(final String circuitBreakerName, final String accessToken) {
        final HttpHeaders headers = new HttpHeaders();
        headers.setBearerAuth(accessToken);

        final CircuitBreaker circuitBreaker = registry.find(circuitBreakerName)
                .orElseThrow(() -> new CircuitBreakerInvalidException());

        return circuitBreaker.executeSupplier(
                () -> webClient.get()
                .uri(getMemberInfoUrl())
                .headers(httpHeaders -> httpHeaders.addAll(headers))
                .retrieve()
                .bodyToMono(OAuthGithubUsernameResponse.class).block()
        );
    }

}

 

다음은 어노테이션 방식으로 서킷브레이커를 구현해 보겠습니다. 코드에 비해 매우 간단합니다. @CircuitBreaker 어노테이션에는 2가지 속성이 존재합니다.

 

name과 fallbackMethod 속성

여기서 name 속성은 yml에 설정해 주었던 resilience4j.instance에 지정해 준 인스턴스 명과 동일합니다.(무조건 동일해야 하는건 아닙니다.) fallbackMethod 속성은 서킷이 열렸을 때 fallback을 하기 위해 실행할 메서드의 이름입니다.

 

@Override
@CircuitBreaker(name = "webClientGithubClient", fallbackMethod = "fallback")
public OAuthGithubUsernameResponse getGithubUsername(final String accessToken) {
    final HttpHeaders headers = new HttpHeaders();
    headers.setBearerAuth(accessToken);

    return webClient.get()
            .uri(getMemberInfoUrl())
            .headers(httpHeaders -> httpHeaders.addAll(headers))
            .retrieve()
            .bodyToMono(OAuthGithubUsernameResponse.class).block();
}

private void fallback(Exception e) {
    throw new CircuitBreakerInvalidException(e);
}

 

각 방법의 장단점이 존재하는데, 단순하게 생각하면 어노테이션을 사용하는 방식이 훨씬 간편합니다. 코드를 작성하는 방법은 중복 코드가 굉장히 많아집니다. 반면, 어노테이션 방식은 상당히 간단하게 서킷브레이커를 구현할 수 있습니다.

 

하지만 항상 서킷브레이커의 인스턴스의 이름을 지정해줘야 합니다. 만약 새로운 인스턴스에 서킷브레이커를 붙일 일이 생긴다면 다양한 값으로 인한 휴먼 에러가 발생할 수도 있습니다. 이 문제를 해결하기 위해서는 AOP 등을 사용하는 방법이 존재하기도 합니다.

 

fallbackMethod 작성하는 법

이제 서킷이 열려 있는 상황에서 메서드를 fallback 할 차례입니다. fallback 메서드를 작성하는 방법은 간단합니다.

@Component
@RequiredArgsConstructor
public class WebClientGithubClient implements GithubClient {

    private final WebClient webClient;
    private final GithubClientProperties githubClientProperties;

    @Override
    @CircuitBreaker(name = "webClientGithubClient", fallbackMethod = "fallback")
    public List<GithubPrInfoResponse> getPrsByRepoName(final String accessToken, final String repo) {
        throw new RuntimeException("Failed");
    }

    private List<GithubPrInfoResponse> fallback(Throwable t) {
        throw new CircuitBreakerInvalidException();
    }

}

 

주의할 점은 파라미터와 반환 값의 타입이 일치해야 합니다. 즉 메서드 시그니처들이 모두 일치해야 한다는 것입니다. 꼭  Resilience4j에서 제공하는 Throwable이나 Exception을 사용하지 않아도 같은 파라미터 타입을 받기는 해야합니다.

 

서킷이 열려 있을 때

정상적으로 fallback이 등록되었다면, 이런식으로 예외 메시지를 던지거나 또는 다른 외부 서버의 API를 호출해주던지 후처리 작업을 해주시면 됩니다.

실패 처리에서 제외되는 예외 작성

서킷브레이커는 어떤 예외가 발생하더라도 모두 실패로 처리한다고 했습니다. 그렇기 때문에 클라이언트에서 잘못된 값을 전달하여 발생하는 4xx 번대 예외는 적절한 처리가 필요합니다.

 

public class CircuitRecordFailurePredicate implements Predicate<Throwable> {

    @Override
    public boolean test(Throwable throwable) {
        if (throwable instanceof TechHubException) {
            return false;
        }
        return true;
    }
}

 

위의 코드는 커스텀 예외(4xx) 번대 외의 에러가 발생한 경우 모두 fail로 처리하도록 하는 메서드입니다. 이제 이 클래스를 설정 파일에 등록해 주면 정상적으로 동작하게 됩니다.

 

resilience4j:
  circuitbreaker:
    configs:
      default:
        sliding-window-type: COUNT_BASED
        registerHealthIndicator: true
        slidingWindowSize: 3
        minimumNumberOfCalls: 1
        permittedNumberOfCallsInHalfOpenState: 3
        automaticTransitionFromOpenToHalfOpenEnabled: true
        waitDurationInOpenState: 5s
        failureRateThreshold: 50
        eventConsumerBufferSize: 10
        # 추가
        recordFailurePredicate: com.integrated.techhub.resilience4j.circuitbreaker.config.CircuitRecordFailurePredicate
  instances:
    webClientGithubClient:
      baseConfig: default
  timelimiter:
    configs:
      default:
        timeoutDuration: 6s
        cancelRunningFuture: true

 

OPEN 상태의 예외처리

서킷이 열린 상태라면 요청을 차단하고 CallNotPermittedException 예외를 발생시킵니다. 그렇기 때문에 ControllerAdvice에 다음과 같이 별도의 예외를 등록해주어야 합니다.

 

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

 

마무리하며


이렇게 Resilience4j를 이용한 CircuitBreaker 라이브러리를 사용해 보았습니다. 아직 MSA 환경을 경험해보지는 못하였지만, 외부 서버와 통신하는 기능이 많이 존재해서 조금이나마 비슷한 경험을 할 수 있었습니다. 실제 MSA 환경은 어떨지 궁금하네요.. 읽어주셔서 감사합니다.

profile

용로그

@용로그

벨덩보단 용덩 github.com/wonyongChoi05