resilience4j 로 알아보는 서킷브레이커패턴(CircuitBreaker)

엄태권·2023년 7월 21일
1
post-thumbnail

소프트웨어는 모두 실패한다 - 버너 보겔스(아마존 부사장)

실패하지 않는 소프트웨어를 만들기는 불가능하다. 이전의 소프트웨어는 무결함이나 실패 무결성 즉, 완벽한 시스템을 추구했다.

그러나 여러 개의 작은 독립적인 마이크로서비스로 분할하는 아키텍처 패턴이 많아지는 요즘, 단일 서비스의 장애가 전체 시스템에 영향을 줄 수 있어, 이전에 목표하던 실패 무결성을 지키기 어려운것이 현실이다.

그렇다면 MSA는 장애가 나면 큰일나는 아키텍처인가?

버너 보겔스의 말대로 소프트웨어는 모두 실패하는 것인가? 사실 버너 보겔스의 말에는 이런 내용이 숨어져있다.

'완벽한 시스템을 만들기 보다, 실패에 유연하고 빠르게 대응가능한 시스템을 설계해야 한다.'

따라서 MSA 환경에서는 단일 마이크로서비스의 장애가 전체시스템에 영향을 미치지 않도록 장애가 발생해도 견딜 수 있는 내결함성(Fault Tolerance)이 중요하다는 내용이다.

Fault Tolerance는 시스템의 신뢰성과 가용성을 보장하기 위한 개념으로, 이를 위한 다양한 기술과 방법이 존재하며, 그중 우리는 circuit-breaker-pattern을 알아보려 한다.

캐스케이드 장애 문제(Cascading_failure)

MSA 환경에서 서비스의 사용량이 많은 경우에 시스템의 한 부분에서 오류가 발생하면 연쇄적으로 오류가 발생할 수 있다.

  • 총 네 가지의 서비스가 있다.(A, B, C, D)
  • 클라이언트의 요청에 따라 A->B->C->D의 순서로 연결되어 호출한다.
  • 알수없는 이유로인해 D 서비스가 실패한다.
  • 서비스 D에 연결된 연결은 대기하며, 서비스 D는 동일한 상태로 지속된다.
  • 이는 연쇄적으로 A, B, C 서비스들의 통신에 영향을 준다.
  • 이러한 캐스케이드 오류는 전체 또는 일부 서비스를 사용할 수 없게 된다.

캐스케이드 장애를 해결하자

분산환경(MSA)에서의 일시적인 결함은 일반적으로 짧은 시간 후에 자체적 수정이나, 재시도 패턴과 같은 전략으로 해결할 수 있다.

그러나 예기치 않은 이벤트로 인해 오류가 발생하며, 수정하는데 훨씬 더 오래 걸리는 상황이 생길 수 있다. 이러한 문제는 부분적인 연결손실에서 전체 서비스 실패에 이르기까지 다양하다.

이러한 상황에서 응용 프로그램이 성공할 가능성이 없는 작업을 계속해서 재시도 하는 것은 무의미 할 수있으며, 실패를 받아들이고, 이 실패를 처리해야한다.

예를 들어, 서비스를 호출하는 작업은 제한 시간을 걸어두고, 해당 기간내에 응답하지 않을 경우 실패 메시지로 응답하도록 구현이 가능하다.

그러나 해당 전략은 제한 시간이 만료될 때까지 동일한 작업에 대한 많은 동시요청을 차단시킬 수 있다. 해당 요청들은 쓰레드와 메모리 및 CPU등의 자원을 점유하게 된다.

결과적으로 이러한 리소스가 고갈되어 동일한 리소스를 사용해야 하는 시스템의 관련되지 않은 다른 부분까지 오류가 발생할 수 있다.

이러한 상황에선 작업이 즉시 실패하고 성공할 가능성이 있는 경우에만 서비스 호출을 하는 것이좋다.

서킷브레이커 패턴(circuit-breaker-pattern)은 장애가 발생한 서비스를 감지하고 더이상 요청을 보내지 않도록 차단하여, 장애가 퍼지지 않도록 격리시킨다.

서킷브레이커 Circuit-breaker

먼저 사전적인 서킷브레이커를 찾아봤다.

  • 사전적 의미로 회로(circuit) 차단기(breaker)
  • 흔히 가정집에 있는 두꺼비집이 그 예이다
  • 과전류가 흐를경우 회로가 과열되어 화재등의 더큰 문제가 발생하지 않도록 회로를 차단하는 역할을 한다.
  • 비슷한 예로 주식장에서도 주식이 폭락을 할 경우 서킷브레이커가 발동되며, 20분동안 모든 거래가 멈추게 된다.
    (투자자들은 이 20분동안 천천히 생각해 볼 수 있겠다)
  • 사전적 의미로는 회로 차단이라는 용어로 널리 쓰이지만 실상 각 장르에 맞게 차용적으로 사용되고 있다.

사전적 의미를 봤을때 잠시 과열된 문제점을 식혀주는 말그대로의 breaker 역할을 한다. 그렇다면 우리가 알아볼 실제 서킷 브레이커 패턴은 어떨까?

캐스케이드 장애 해결부분의 마지막 줄에서 언급한 내용과 같이 서킷브레이커 패턴은 애플리케이션이 실패할 가능성이 있는 작업을 반복적으로 실행하려고 시도하는 것을 방지한다.

오류가 오래 지속된다고 판단하는 동안 오류가 수정될 때까지 기다리고, 오류가 해결되었는지 여부를 감지하여 문제가 수정된 것으로 보이면 애플리케이션이 작업 호출을 시도할 수 있다.

[TIP] 재시도 패턴과는 다르다.

  • 재시도 패턴은 애플리케이션이 성공할 것이라는 기대로 작업을 재시도 할 수있다.
  • 그러나, 서킷브레이크 패턴은 애플리케이션이 실패할 가능성이 있는 작업을 수행하는 것을 방지한다.
  • 서킷브레이크 패턴 호출을 위해 재시도 패턴을 사용하여 결합할 순있으며, 주의점으로 재시도의경우 서킷브레이크 패턴에서 반환되는 모든 예외에 민감해야 하며, 일시적이지 않은 오류라고 표시하는 경우 재시도를 포기해야한다.

서킷브레이크 패턴은 실패할 수 있는 작업에 대한 프록시 역할을 하며, 최근 실패 수를 모니터링 하고 이정보를 사용해 작업의 지속여부 또는 단순히 예외를 즉시 반환할지 결정한다. 서킷브레이크 패턴은 아래 3개의 상태로 구현이 가능하다

  • 닫힘(CLOSE)
    닫힘 상태일 때 정상적으로 작동(서비스를 통한 요청 통과)한다.
    그러나 오류가 임계값 제한을 초과하면 회로 차단기가 작동하며, 위의 다이어그램에서 볼 수 있듯이, 회로의 상태가'개방'으로 전환된다.
  • 열림(OPEN)
    개방 상태일 때 들어오는 요청은 실제 작업을 실행하려는 시도 없이 오류(fallback)와 함께 반환된다.
  • 반개방(HALF-OPEN)
    열림상태에서 일정 시간이 지나면 차단기가 half-open 상태가 되며, 이 상태에서 회로 차단기는 제한 된 수의 테스트 요청을 통과하도록 허용하고, 요청이 성공할경우 닫힌 상태로 돌아가며, 트래픽은 평소와 같이 통과된다. 반면 요청이 실패할 경우 다른 제한 시간이 초과될 때까지 열린 상태로 유지된다.

이를 유한개의 상태를 가지고 주어지는 입력에 따라 어떤 상태에서 다른 상태로 전환 하거나 액션이 일어나게하는 모델을 말하는 유한 상태머신-FSM(FINITE State Machine)이라고 한다.

이렇게 기본적인 서킷브레이크 패턴에 대해 알아봤다. 이렇게만 하고 끝내면 이론만 주구장창 설명하는 느낌이니 아래에선 이러한 circuit-breaker를 구현하기 위한 다양한 방법중, Resilience4j와 같은 라이브러리를 통해 코드와 테스트를 통해 확인해 보려한다.

Resilience4j

공식적인 문서에 나와있듯이 Resilience4j 는 Netflix Hystrix에서 영감을 받았지만 함수형 프로그래밍을 위해 설계된 가벼운 내결함성 라이브러리이며, Resilience(회복력) 과 For Java가 합쳐진 이름이다.

실제 안에는 여러 핵심 모듈들이 있지만 우린 그중 CircuitBreaker라는 모듈을 사용하여 테스트 할 예정이다.

Resilience4j의 회로차단기(CircuitBreaker)는 유한 상태 머신을 통해 구현되며, 개수 기반 슬라이딩 윈도우, 시간 기반 슬라이딩 윈도우를 통해 통화 결과를 저장하고, 집계한다.

  • 개수 기반 슬라이딩 윈도우(Count-based sliding window)
    Resilience4j의 회로 차단기(circuit breaker)에서 사용되는 메트릭 수집 방법 중 하나입니다. 이 방법은 일정 개수의 요청을 추적하고, 해당 요청들 중 실패한 요청의 비율을 계산하여 회로 차단을 결정한다.

    쉽게 말해 일정 윈도우 크기만큼을 기준으로 임계치를 계산하는 방식이다.
    만일 10개의 윈도우에서 2번째 요청의 실패가 있을 경우 임계치는 10% 이며 이후 11번째 요청시 첫 번째 요청은 윈도우에서 제거되고 총 20%의 임계치가 계산된다.

  • 시간 기반 슬라이딩 윈도우(Time-based sliding window)
    기본적으로 슬라이딩 윈도우의 개념은 비슷하지만 이를 일정 시간 동안의 요청에 대한 추적을 기준으로 한다는 점이 다르다.

    즉 슬라이딩 윈도우의 길이를 정한후(ex 1분) 해당 기간동안의 실패율을 계산하여 특정 임계치가 넘는지를 계산한다.

실패율 및 느린 통화율에 대한 임계값처리

유한상태머신과 같이 실패율이 구성 가능한 임계값보다 크거나 같으면 회로 차단기의 상태가 변경되며(CLOSED -> OPEN) OPEN 상태에선 해당 호출에 대해 CallNotPermittedException 과함께 거부하고, 일정 대기시간 이후 HALF_OPEN상태가 된다.

HALF_OPEN 상태에선 구성 가능한 수의 호출을 허용하여 다시 사용 가능한 상태인지 체크를 진행한다. 여전히 호출이 불가능한 상태이면 OPEN상태로, 임계값 미만의 실패율이라면 CLOSED로 변경된다.

호출 성공/실패 수, 지연 시간 등(Metric)을 통해 유한상태머신이 동작한다.

이 외, Resilience4j는 thread-safe하며, 동시성으로 부터 원자적으로 유한상태머신의 상태 변화를 관리한다고 한다.(자세한 내용은 document 참조.)

Code...

먼저 스프링 클라우드에서 제공하는 Spring Cloud Circuit Breaker가 있으며, 어노테이션 및 간단한 yml 설정들로 설정이 가능하지만, 이 글에선 resilience4j의 의존성을 활용하여 테스트를 진행했다.

Dependencies

먼저 여러 모듈에 대한 dependencies가 있지만 circuitbreaker만 테스트 할 예정이기 때문에 해당 의존성만 추가하였다.

	//circuitBreaker
	implementation "io.github.resilience4j:resilience4j-circuitbreaker:${resilience4jVersion}"

여기서 resilience4j는 2버전 부터 java 17이 필요하다.
${resilience4jVersion} 부분은 gradle.properties로 관리 했으며, 2.0.0버전을 사용했다.

Configuration

@Configuration
public class CircuitBreakerConfiguration {

    @Bean
     CircuitBreakerRegistry circuitBreakerRegistry() {
        return CircuitBreakerRegistry.of(configurationCircuitBreaker());
    }

    private CircuitBreakerConfig configurationCircuitBreaker() {
        return CircuitBreakerConfig.custom()
                .failureRateThreshold(40) //실패율 임계값(밴분율 단위)
                .waitDurationInOpenState(Duration.ofMillis(10000))   //Open -> half-open으로 전환되기 전에 대기시간
                .permittedNumberOfCallsInHalfOpenState(3)           //half-open시에 허용되는 호출 수
                .slidingWindowSize(10)                               //호출 결과를 기록하는 데 사용되는 슬라이딩 윈도우 크기
                .recordExceptions(RuntimeException.class)    //실패로 기록되어 실패율이 증가하는 예외 목록
                .build();
    }
}

공식 문서를 보면 각 설정에 대한 자세한 설명이 나와있다. 이보다 더 많은 설정이 있지만 간단한 테스트를 위해 위와 같은 설정으로 테스트를 진행할 예정이다.

추가로 각 호출시 또는 비슷한 유형의 다른 서비스 혹은 third party api를 호출할 시 각 circuit-breaker의 설정이 다를텐데, 이를위해 CircuitBreakerRegistry 까지만 Bean 등록을 진행했다.

Create CircuitBreaker

@Slf4j
@Component
@RequiredArgsConstructor
public class BetweenAandBCircuitBreaker {

    private final CircuitBreakerRegistry circuitBreakerRegistry;
    private static CircuitBreaker circuitBreaker;

    public synchronized CircuitBreaker addCircuitBreaker(String entryName) {
        if (circuitBreaker == null) {
            addRegistryEvent();
            circuitBreaker = circuitBreakerRegistry.circuitBreaker(entryName);

            circuitBreaker.getEventPublisher()
                    .onSuccess(event -> log.info("success call A Method"))
                    .onError(event -> log.error("fail call A Method"))
                    .onIgnoredError(event -> log.info("ignore Exception occurred"))
                    .onReset(event -> log.info("state is reset"))
                    .onStateTransition(event -> log.info("change state result : {}", event.getStateTransition()));

            return circuitBreaker;
        }
        return circuitBreaker;
    }

    private void addRegistryEvent() {
        circuitBreakerRegistry.getEventPublisher()
                .onEntryAdded(entryAddedEvent -> {
                    CircuitBreaker addedEntry = entryAddedEvent.getAddedEntry();
                    log.info("CircuitBreaker {} added", addedEntry.getName());
                });
    }
}

우선 Bean으로 등록한 CircuitBreakerRegistry를 주입받은 후에
addCircuitBreaker를 통해 싱글톤으로 CircuitBreaker를 등록한다.

addRegistryEvent의 경우 CircuitBreaker가 등록 될 시에 발생하는 Event로 등록된 CircuitBreaker의 이름을 출력해준다.

이후 circuitBreaker.getEventPublisher를 통해 각 상태마다 출력할 로그를 작성해 주었다. 이를 통해 CircuitBreaker의 상태 변화를 확인할 수 있다.

Use CircuitBreaker

@Slf4j
@Service
@RequiredArgsConstructor
public class CallAServerService {
    private final CallSomeApiClient apiClient;
    private final BetweenAandBCircuitBreaker betweenAandBCircuitBreaker;

    public String callAServer() {
        CircuitBreaker circuitBreaker = betweenAandBCircuitBreaker.addCircuitBreaker("callA");

        String apiResponse = null;
        try {
            Supplier<String> decorateSupplier = CircuitBreaker.decorateSupplier(circuitBreaker, apiClient::callAServerApi);
            apiResponse = Try.ofSupplier(decorateSupplier).recover(throwable -> callA_1Server()).get();
            log.info("api result : {}", apiResponse);

            return apiResponse;
        } catch (CallNotPermittedException e) {
            log.warn("service is block because circuitBreaker block this request");
        } catch (Exception e) {
            log.error("UnKnown Exception occur", e);
        }
        return apiResponse;
    }

    private String callA_1Server() {
        return "fallback method running";
    }
}

betweenAandBCircuitBreaker.addCircuitBreaker를 통해 먼저 CircuitBreaker를 등록한다. 이전의 addRegistryEvent를 통해 등록된 서킷브레이커의 이름을 로그로 출력한다.

CircuitBreaker.decorateSupplier를 통해 서킷브레이커로 장식된 Supplier를 return한다.(즉 여기선 apiClient::callAServerApi를 서킷브레이커로 장식한 Supplier를 return 한다.)

이후 Try.ofSupplier를 통해 위에서 반환된 Supplier를 실행하고 recover를 통해 Exception 발생시 동작할 기능을 추가한다.

Try.ofSupplier
해당 Interface는 vavr라이브러리에서 제공하는 Interface로 try..catch 구문을 함수형으로 구현할 수 있게 지원한다.

사실 위 코드의 try catch 부분은 사실상 필요가 없다. 다만 Try.ofSupplier를 제거후 동작하게 했을 경우에는 필요할 수 있다.

위 Resilience4j 라이브러리 설명에도 나와있듯 서킷브레이커의 상태가
OPEN일 경우 해당 호출에 대해 CallNotPermittedException 과 함께 거부 되기 때문에 Try..Catch 구문에서 다른 행동을 할 수 있다.

다만 여기선 Try.recover 에서 해당 역할을 대신 수행한다.

즉 만일 apiClient::callAServerApi 수행시 실패가 발생했다면, callA_1Server를 호출할 것이며 fallback method running 가 return 될 것이다.

CircuitBreaker Test Code

테스트 코드가 매우 중요했다. 내가 설정한 임계치에 도달했을 경우 그에 따른 CircuitBreaker의 상태값 변화가 주 테스트 목적이었고, CLOSED, OPEN, HALF-OPEN 모두 테스트가 필요했다.

다만 해당 글에 테스트 코드 전문을 작성하기엔 매우 길어 한 가지의 테스크 코드만 가져왔으며, 자세한 테스트 코드는 아래 참조의 GitHub를 통해 확인 할 수 있다.

    @DisplayName("반개방 케이스 - HALF_OPEN")
    class HalfOpenTest {
        @Test
        void 정해진_임계점만큼_실패후_1초가_지나면_HALF_OPEN_상태가된다() throws InterruptedException {
            // Given
            callAServerService = new CallAServerService(callSomeApiClientMock, betweenAandBCircuitBreakerMock);
            when(betweenAandBCircuitBreakerMock.addCircuitBreaker(anyString())).thenReturn(circuitBreakerMock);

            int expectedThrowCount = 5;
            AtomicInteger throwCount = new AtomicInteger();

            Answer<String> throwExceptionAnswer = invocation -> {
                throwCount.getAndIncrement();
                if (throwCount.get() <= expectedThrowCount) {
                    throw new RuntimeException();
                }
                return "success";
            };

            when(callSomeApiClientMock.callAServerApi()).thenAnswer(throwExceptionAnswer);

            // When
            for (int i = 0; i < 12; i++) {
                callAServerService.callAServer();
                if (i == 10) {
                    Thread.sleep(1000);
                }
            }
            // Then
            assertThat(circuitBreakerMock.getName()).isEqualTo("testCircuitBreaker");
            assertThat(circuitBreakerMock.getMetrics().getNumberOfFailedCalls()).isZero();
            assertThat(circuitBreakerMock.getState()).isEqualTo(CircuitBreaker.State.HALF_OPEN);

반개방(HALF_OPEN)테스트 이다. 위의 설명에서 말했듯이 OPEN 상태에서 일정 시간이후가 되면 CircuitBreaker는 HALF_OPEN 상태가 되며, 우리는 위의 Configuration 부분에서 이를 1초로 잡아두었다.

해당 테스트에서의 CircuitBreaker 설정은 아래와 같다.

CircuitBreaker 이름 : testCircuitBreaker
실폐율 임계값 : 40%
슬라이딩 윈도우 크기 : 10
OPEN -> HALF_OPEN 전환 대기시간 : 1초
슬라이딩 윈도우 옵션 : 개수 기반
실패로 판단할 Exception : RunTimeException.class

위 테스트 코드를 요약하자면 총 12번의 호출중 5번의 실패가있으며, 10번째 호출이후 1초의 대기시간을 가진다.

슬라이딩 윈도우의 크기가 10이기 때문에 10개의 윈도우 기준으로 실패율을 계산하며, 이중 5개가 실패이기 때문에 위의 테스트 코드에서의 실패율은 50% 이다.(5개 실패 이후의 호출은 모두 성공이다.)

지정한 임계값을 넘은 값으로 해당 10번의 호출이 종료되면, CircuitBreaker의 상태는 OPEN 상태가 된다. 이후, 1초의 대기시간이 있고, 11번째 호출부턴 HALF_OPEN 상태로 전환된다.

따라서 최종 테스트의 결과는 CircuitBreaker의 상태는 HALF_OPEN이며, 상태의 변경에 따라 실패 횟수는 초기화 되기 때문에 0이 된다.

Real Test

실제 API 호출을 통해 테스트를 진행하면서 로그를 통해 CircuitBreaker의 상태 값 변화를 알아볼 필요가 있다 . 테스트 케이스는 아래와 같다.

  • 10번 호출하여 10번 모두성공 -> CLOSED
  • 10번 호출하여 5번 실패 -> OPEN
  • 1초 정도의 대기시간 이후 상태값 -> HALF_OPEN
  • HALF_OPEN 에서 3번의 성공이 모두 성공 -> CLOSED
  • HALF_OPEN 에서 3번의 요청이 임계치만큼 실패 -> OPEN

그럼 하나씩 테스트를 해보고 결과를 확인해 보자.

[10번 호출하여 10번 성공]

우리는 정상적으로 10번에대한 호출을 성공하였고 응답을 받았다.
모든 호출이 끝났을때 정상적인 요청만 수신한 서비스의 Circuite Breaker는 CLOSED 상태가 유지되고 있다.

[10번 호출하여 5번 실패]

로그를 보면 실패를 5번까지 한 후 정상적인 응답으로 마무리를 지었어도,
실제 실패율은 50%(10번중 5번실패) 이기 때문에 우리가 지정한 임계치인
40%을 넘는다.

11번째 호출부턴 CircuitBreaker가 상태값을 OPEN으로 변경하며, 이전에 Configuration에서 설정한 상태값에 변경때마다 남기는 로그가 함께 남아있다.

또한 현재는 OPEN->HALF_OPEN 의 대기시간이 1지만, 해당 시간 사이에 api 요청이 들어올 경우 CircuitBreaker는 해당 요청을 실행하지 않고, 위에서 보았던 callA_1Server 즉 recover 메소드를 호출한다.

[1초 정도의 대기시간 이후 상태값]

로그를 보면 방금 위의 테스트 결과를 작성하느라 일정 시간이 지났다.
약 4분 정도의 시간이 소요되었고, 우리는 그사이 요청을 하지 않았다.

즉, 12번째만의 요청인데, 이때 우리는 Configuration에서 CircuitBreaker의 OPEN -> HALF_OPEN의 대기시간을 1초로 주었다.

따라서 4분이 지난 지금의 요청은 CircuitBreaker가 이미 HALF_OPEN 상태값과 로그를 보여준다.

[HALF_OPEN 에서 3번의 성공이 모두 성공]

위의 상황에 이어서 추가적으로 요청을 진행했고, HALF_OPEN 상태에서 3번의 정상적인 응답을 받았다.

우리는 Configuration에서 HALF_OPEN시에 3번의 요청을 받을수있으며, 이 3번의 요청에 대한 실패율을 계산해 정상일 경우 상태값을 CLOSED로 바꾼다는 것을 알았다.

마찬가지로 로그를 보면 반개방 상태에서의 3번요청이 모두 성공하고 그에따라 다시 CircuitBreaker의 상태가 CLOSED가 되었음을 알 수 있다.

[HALF_OPEN 에서 3번의 요청이 임계치만큼 실패]

위 로그를 보면, 반개방 상태에서 우리는 3번의 요청을 더진행했고, 3번의 요청 모두 실패했음을 볼 수 있다.

CircuitBreaker는 반개방 상태에서 3번의 요청에 대한 실패율을 계산하고 해당 실패율이 임계치가 넘었기 때문에 다시 상태를 OPEN 상태로 변경 하는 것을 볼 수 있다.

참조

https://digitalvarys.com/what-is-circuit-breaker-design-pattern/
https://en.wikipedia.org/wiki/Cascading_failure
https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker
https://resilience4j.readme.io/docs

Github

Repository
Test Code

profile
https://github.com/Eom-Ti

2개의 댓글

comment-user-thumbnail
2023년 7월 21일

항상 좋은 글 감사합니다.

1개의 답글