spring

Spring Circuit Breaker 적용

HJHStudy 2025. 3. 23. 01:06
728x90

간단히 서킷 브레이커와 fallback에 대해 간략히 정리하고 바로 넘어가자.

Resiliencd4j를 통한 서킷 브레이커를 진행할 것이다.

 

서킷 브레이커는 장애를 방지하기 위한 패턴으로 장애 발생 지점을 감지하고 실패하는 요청을 계속적으로 보내지 않도록 방지하는 패턴이다.

 

서킷 브레이커는 3개의 상태를 통해 관리가 된다.

 

Closed - 요청의 실패율이 설정한 실패 임계치보다 낮은 상태

Open - 요청의 실패율이 설정한 실패 임계치보다 높은 상태

Half-Open - Open 상태 이후 일정 시간이 지난 상태이며, 이후 요청의 성공/실패 상태에 따라 Closed/Open 상태로 변경된다.

 

 

실패 임계치를 정할 때는 2가지 기준으로 요청이 실패했다는 것을 정의할 수 있다.

slow call  - 설정한 시간보다 오래 걸린 요청으로 지연된 요청

failure call - 요청이 실패하거나 에러 응답을 받은 요청이다.

 

 

 

 

프로젝트 진행 중 서킷 브레이커를 적용하게 되었는데 적용을 하자.

서킷 브레이커를 달아볼 곳을 추려서 먼저 달아보자.

private static final String GEMINI_CIRCUIT_BREAKER = "AiService";
private static final String FALL_BACK_RESPONSE = "fallbackResponse";

private final WebClient webClient;
@Value("${ai.api.key}")
private String apiKey;
@Value("${ai.api.url}")
private String aiUrl;

@CircuitBreaker(name = GEMINI_CIRCUIT_BREAKER, fallbackMethod = FALL_BACK_RESPONSE)
public Mono<String> sendRequestToGemini(PostSlackMessageRequestDto requestDto) {
    // 필요한 파라미터들 추출
    String prompt = generatePrompt(requestDto);
    log.info("Request URL: {}", aiUrl + apiKey);
    String url = aiUrl + apiKey;

    return webClient.post()
        .uri(url)
        .contentType(MediaType.APPLICATION_JSON)
        .accept(MediaType.APPLICATION_JSON)
        .body(BodyInserters.fromValue(generateRequestBody(prompt))) // 요청 본문 설정
        .retrieve()
        .bodyToMono(String.class) // 응답 처리
        .doOnError(error -> log.error("Gemini 응답에 에러가 있습니다: {}", error.getMessage(), error))
        .map(response -> {
            String deadline = extractDeadline(response);
            log.info("발송 시한: {}", deadline);

            return generateResultMessage(requestDto, deadline);
        });
}
//ai에 대한 요청은 내부적으로 슬랙에 보내기 위함으로 별도의 retry 설정은 하지 않고
//30분 이내로 출발해 달라는 요청을 남깁니다.
private Mono<String> fallbackResponse(Throwable t) {
    log.error("Gemini AI 서비스 장애 발생 {}", t.getMessage());
    return Mono.just("AI 서비스가 일시적으로 응답할 수 없습니다. 30분 이내로 출발 해주세요.");
}

 

private static final String SLACK_CIRCUIT_BREAKER = "slackService";
private static final String FALL_BACK_RESPONSE = "slackRequestFail";
private static final String SLACK_RETRY = "slackRetry";
private final MethodsClient methodsClient;

//Slack 객체는 재사용 가능하므로 빈으로 관리
public SlackAlarmService(@Value("${slack.key}") String slackToken) {
    this.methodsClient = Slack.getInstance().methods(slackToken);
}

//retry는 yml에 설정된 값에 따라 재시도 합니다. 재시도 한 것이 모두 성공적이면 close로 진행합니다.
@Retry(name = SLACK_RETRY, fallbackMethod = FALL_BACK_RESPONSE)
@CircuitBreaker(name = SLACK_CIRCUIT_BREAKER, fallbackMethod = FALL_BACK_RESPONSE)
public void sendSlackMessageToChannel(String message, String channel){
    try{

        ChatPostMessageRequest request = ChatPostMessageRequest.builder()
            .channel(channel)
            .text(message)
            .build();
        if (message.equals("서킷 브레이커")) {
            throw new IllegalArgumentException("서킷 브레이커 테스트");
        }

        methodsClient.chatPostMessage(request);

        log.info("Slack " + channel + " 에 메시지 보냄");
    } catch (SlackApiException | IOException e) {
        log.error(e.getMessage());
        throw BusinessException.from(SlackErrorCode.SLACK_ERROR);
    }
}

 

private void slackRequestFail(String message, String channel, Throwable t) {
    log.error("slack 서비스 장애 발생 {}", t.getMessage());
    log.error("전송하려던 메시지: {}", message);
    log.error("전송해야할 채널 {}", channel);
}

 

이렇게 AI와 Slack 메시지를 보내는 부분이 외부를 사용하기 때문에 서킷 브레이커를 걸어두었다.

테스트는 만만한 Slack으로 진행하도록 하겠다.

로그를 남기는 서킷 브레이커 메서드만 남겨두게 됐다.

 

 

 

로그를 통해 서킷 브레이커 작동이 잘 되는지를 확인해보자.

우선 서킷 브레이커가 동작하기 위해 yml을 확인해 보자.

#서킷브레이커 설정
resilience4j:
  circuitbreaker:
    configs:
      default:
        #실패 요청(failure call) 임계치 설정 요청 실패가 50 이상시 OPEN 상태로 전환
        failure-rate-threshold: 50
        #지연 요청(slow call)의 임계치 설정 기본값이 100이지만 지연 요청이 100일 때 서버 스레드를
        #전부 점유할 수 있으므로 100 보다 적게 설정
        slow-call-rate-threshold: 80
        #지연 요청이라고 판단할 시간 설정 5초로 지정 했습니다.
        slow-call-duration-threshold: 5s
        #Half-Open에서 Open,Close 상태 전환을 판단할 요청 개수
        #ex)3개 요청이 전부 성공시 Closed, 하나라도 실패시 Open
        permitted-number-of-calls-in-half-open-state: 3
        #기본 값은 0으로 위 설정한 permitted 요청 개수가 모두 올 때까지 Half_Open으로 대기
        max-wait-duration-in-half-open-state: 0
        #요청 시간 기반과 요청 요청 개수 기반으로 슬라이딩 윈도우 타입을 설정
        sliding-window-type: COUNT_BASED
        #사이즈를 10으로 요청 개수가 10개가 될 때마다 집계 되도록 설정
        sliding-window-size: 10
        #지연 요청과 실패 요청이 계산되는 최소 요청 수
        #ex) 슬라이딩 윈도우 사이즈 10이라면 아래 값이 슬라이딩 윈도우 사이즈보다 낮을 때
        # -> 낮은 수의 요청이 들어왔을 때 해당 요청의 지연, 실패 요청을 계산해 상태 전환을 판단.
        #ex) 아래 설정 값이 슬라이딩 윈도우 사이즈보다 클 때는 윈도우 사이즈만큼 계산된다.
        minimum-number-of-calls: 10
        #Open에서 최소 5초가 지나고 요청이 왔을 때 Half-Open이 되도록 설정
        wait-duration-in-open-state: 5s
    instances:
      shboard-circuit-breaker:
        base-config: default

 

주석을 달아 놓았으니 어떤 동작을 위한 것이지 알 수 있을 것이다.

 

요약하면 다음과 같다

요청이 10개 이상 있어야 서킷 브레이커가 활성화.

실패율 50%, 지연율 80% 이상이면 OPEN 상태로 전환.

OPEN 상태에서 5초 후 Half-Open 상태로 변경.

Half-Open 상태에서 3개 요청을 테스트한 후 성공 여부에 따라 다시 CLOSED or OPEN으로 전환.

이런 흐름이다.

 

테스트할 흐름을 정리해 보자.

1. 10개 요청 실패 시 Closed -> Open 변환

10개의 요청 실패시 Open으로 변환 확인

 

2. Open 상태에서 5초 후 요청 보내서 Half_Open으로 변환되는지 확인

 

3.Half_Open 상태에서 failureRate를 통해 설정한 50% 이상시 Open으로 아래시 Close로 변환 확인

 

 

확인하기 이전에 슬랙 서킷 브레이커 건 부분이 실제 슬랙 메시지가 도착하는 부분에 걸어서 잘못된 요청일 땐 슬랙 메시지가 도착하지 않을 것이고 성공 시에는 슬랙에 요청이 갈 것이다.

비즈니스에 따라 데이터는 저장하되 슬랙에 문제가 생겨 메시지가 도착되지 않았을 때를 고려해 구성해 보았다.

 

정리한 흐름을 1번부터 실행해 보자.

{
"slackService": {
      "failureRate": "-1.0%",
      "slowCallRate": "-1.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 0,
      "failedCalls": 0,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "CLOSED"
    }
  }
}

 

-1.0 인 이유는 Rate 할 데이터가 없기 때문에 -1.0인 것이다.

이제 요청을 하게 되면 bufferedCalls에 쌓이고 failedCalls는 실패한 요청이 쌓이게 된다.

우선 실패 요청을 10번 진행해서 Open으로 전환을 확인해 보자.

 

t.l.m.s.a.service.SlackAlarmService      : slack 서비스 장애 발생 서킷 브레이커 테스트
t.l.m.s.a.service.SlackAlarmService      : 전송하려던 메시지: 서킷 브레이커
t.l.m.s.a.service.SlackAlarmService      : 전송해야할 채널 #사용자메세지
"slackService": {
      "failureRate": "-1.0%",
      "slowCallRate": "-1.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 1,
      "failedCalls": 1,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "CLOSED"
    }

 

로그도 뜨고 각 calls에 쌓이는 것을 확인할 수 있다 이제 10까지 해보자.

"slackService": {
      "failureRate": "-1.0%",
      "slowCallRate": "-1.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 9,
      "failedCalls": 9,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "CLOSED"
    }

 

9인 상태에서 다음 요청을 했을 때 10이 되고 state가 Open인 것을 확인하면 된다.

"slackService": {
      "failureRate": "100.0%",
      "slowCallRate": "0.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 10,
      "failedCalls": 10,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "OPEN"
    }

 

오류 Rate가 100퍼센트가 되고 state가 Open이 되었다.

 

메시지가 오지 않은 것을 확인할 수 있다.

 

다음은 다시 Close로 바꾸고 오류 Rate를 50퍼센트만 넘겨서 Open으로 만들어보자.

"slackService": {
      "failureRate": "50.0%",
      "slowCallRate": "0.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 10,
      "failedCalls": 5,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "OPEN"
    }

 

50으로 맞추니 Open이 되었고 

 

메시지도 5번만 잘 도착했다.

 

2.Open 상태에서 5초가 지나고 요청 시 Half_Open으로 변환되는 것을 확인하자.

    "slackService": {
      "failureRate": "-1.0%",
      "slowCallRate": "-1.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 1,
      "failedCalls": 1,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "HALF_OPEN"
    }

 

Open 상태에서 다음 요청으로 Half_Open인 것을 확인할 수 있다. 

 

 

 

3.Half_Open에서 Open과 Close 변환

half-open 상태에서 요청에 따라 설정한 값인 3에 따라서 오류 Rate를 통해 2번 실패 시 66%가 되기 때문에 Open이 될 것이다.

성공이 2번, 실패가 1번이면 오류 Rate이 33이 된다.

각  오류 Rate가 66일 때 Open, 33일 때 Close가 되는 것을 확인해 보자.

"slackService": {
      "failureRate": "-1.0%",
      "slowCallRate": "-1.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 2,
      "failedCalls": 2,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "HALF_OPEN"
    }

 

실패가 2번이고 다음 이 성공이어도 Open이 되어야 한다.

"slackService": {
      "failureRate": "66.666664%",
      "slowCallRate": "0.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 3,
      "failedCalls": 2,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "OPEN"
    }

 

확인 결과 66%로 상태가 Open이 되었다.

 

"slackService": {
      "failureRate": "-1.0%",
      "slowCallRate": "-1.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 2,
      "failedCalls": 0,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "HALF_OPEN"
    }

 

성공요청 2번과 실패 요청 1번 시 Close가 되어야 한다.

"slackService": {
      "failureRate": "-1.0%",
      "slowCallRate": "-1.0%",
      "failureRateThreshold": "50.0%",
      "slowCallRateThreshold": "80.0%",
      "bufferedCalls": 0,
      "failedCalls": 0,
      "slowCalls": 0,
      "slowFailedCalls": 0,
      "notPermittedCalls": 0,
      "state": "CLOSED"
    }

 

정상적으로 Close로 변환되었다.

 

지속적으로 장애 발생 시 Open 되었다는 로그와 함께 확인할 수 있다.

 

 

resilience4j에 BulkHead라는 기능이 있다.

BulkHead는 2개의 구현체를 사용할 수 있다.

세마 포어 BulkHead를 사용해 동시 호출 요청수를 제한해서 자원을 격리시킬 수 있는 것인데 세세한 설정을 위해서는 고정 스레드 풀 BulkHead를 사용하면 된다. 직접 사용해 볼 것은 아니기 때문에 어떤 구현체가 있는지만 알아보고 끝내보도록 하자.

 

SemapohoreBulkhead - 동시 호출 요청 수 제한

FixedThraedPoolBulkhead - 스레드풀 설정 제공

위 구현체에 대해서는 스레드에 대해 요청을 조절해야 할 때 공부해서 따로 포스팅되게 해 보도록 하겠다!

 

여기까지 서킷 브레이커에 대해 알아보았다.

728x90

'spring' 카테고리의 다른 글

TIL 의존관계 역방향 제거  (0) 2025.03.25
TIL 트러블 슈팅 Redis Cache  (0) 2025.03.24
TIL 성능 테스트  (0) 2025.03.22
Spring Cloud Contract, WireMock  (0) 2025.03.20
TIL MSA Cloud Config  (1) 2025.03.07