외부 API - Circuit Breaker

mseo39·2025년 9월 22일
0

TIL

목록 보기
9/10
post-thumbnail

우리 서비스는 AI 서버를 호출해야 하는 구조입니다. 그런데 만약 AI 서버에 장애가 생긴다면 그 영향이 그대로 우리 서버에도 전파될 수 있습니다.
처음에는 Retry 방식을 고려했습니다. 일시적인 네트워크 오류라면 2~3번 정도 재시도해서 정상 응답을 받을 수 있기 때문입니다. 하지만 문제가 장기적으로 지속된다면 계속해서 요청을 반복하는 Retry는 오히려 AI 서버에 불필요한 부하를 줄 수 있습니다. 장애 상황을 해결하기는커녕 더 악화시킬 수도 있는 거죠.
이 한계를 보완하기 위해 Circuit Breaker 패턴을 도입했습니다. Circuit Breaker는 일정 횟수 이상 실패가 발생하면 “회로를 끊고” 더 이상 요청을 보내지 않습니다. 이렇게 하면 장애가 장기화될 때 불필요한 재시도를 막고 우리 서버도 안정적으로 보호할 수 있습니다.

  • Retry
    • 네트워크 지연, 일시적 연결 실패 등 짧은 장애는 재시도를 통해 복구 가능
  • Circuit Breaker
    • 일정 횟수 실패 후 아예 외부 API 호출을 차단하고 바로 실패 반환
  • 단기적 장애는 Retry로 복구
  • 장기적 장애는 Circuit Breaker가 빠른 실패 유도
    이렇게 하면 전체 서비스 안전성을 높일 수 있습니다

Circuit Breaker 동작 방식


Circuit Breaker는 3개의 상태를 가집니다

  • 닫힘(CLOSED)
    모든 요청을 외부 API에 보낼 수 있는 상태
  • 열림(OPEN)
    실패율이 임계치를 초과하면 OPEN으로 전환하여 외부 API를 호출하지 않고 바로 실패를 반환
  • 반열림(HALF OPEN)
    일정 시간이 지나면 일부 요청만 외부 API 호출
    • 성공 👉🏻 CLOSED로 회복
    • 실패 👉 다시 OPEN

Circuit Breaker 적용

1) AiRestClient

@Component
public class AiRestClient {

    private final RestClient restClient;

    public AiRestClient(RestClient restClient) {
        this.restClient = restClient;
    }

    @CircuitBreaker(name = "aiService", fallbackMethod = "fallbackTemplate")
    public AiTemplateResponse createTemplate(Long userId, String requestContent) {
        AiTemplateResponse response = restClient.post()
                .uri("/ai/templates")
                .body(new AiTemplateRequest(userId, requestContent))
                .retrieve()
                .body(AiTemplateResponse.class);

        if (response == null) {
            throw new IllegalStateException("AI 서버 응답이 없습니다");
        }
        return response;
    }

    private AiTemplateResponse fallbackTemplate(Long userId, String content, Throwable t) {
        System.out.println("[Fallback] userId=" + userId + ", content=" + content + ", 원인=" + t);
        throw new AiException(AiErrorCode.AI_REQUEST_FAILED);
    }
}
  • @Component → Spring Bean 등록

  • RestClient → Spring 6에서 새롭게 제공하는 HTTP 클라이언트

  • @CircuitBreaker → Resilience4j가 제공하는 애너테이션.

    • name = "aiService" → 어떤 설정을 사용할지 지정
    • fallbackMethod = "fallbackTemplate" → CircuitBreaker가 OPEN일 때 실행할 대체 로직 지정
  • createTemplate() → 실제 AI 서버에 POST 요청 보내는 메서드

  • fallbackTemplate() → 실패 시 실행되는 대체 메서드

2) Resilience4jConfig

@Configuration
public class Resilience4jConfig {

    @Bean
    public CircuitBreakerConfigCustomizer aiServiceCircuitBreakerCustomizer() {
        return CircuitBreakerConfigCustomizer
                .of("aiService", builder -> builder
                        .recordException(throwable -> {
                            if (throwable instanceof HttpStatusCodeException ex) {
                                // 500 에러만 실패로 카운트
                                return ex.getStatusCode().is5xxServerError();
                            }
                            // 그 외는 실패로 카운트하지 않음
                            return false;
                        })
                );
    }
}
  • @Configuration → 설정 클래스

  • CircuitBreakerConfigCustomizer → Resilience4j CircuitBreaker 설정을 세밀하게 커스터마이징

  • recordException() → 어떤 예외를 실패로 볼지 정의

    • 여기서는 500대 서버 오류만 실패로 카운트
    • 400 Bad Request 같은 클라이언트 오류는 실패로 보지 않음

3) application.yml (기본 설정)

resilience4j:
  circuitbreaker:
    instances:
      aiService:
        registerHealthIndicator: true
        slidingWindowSize: 10
        minimumNumberOfCalls: 5
        failureRateThreshold: 50
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 3
        automaticTransitionFromOpenToHalfOpenEnabled: true
  • slidingWindowSize: 10 → 최근 10개의 호출을 기준으로 통계 계산
  • minimumNumberOfCalls: 5 → 최소 5번 이상 호출돼야 통계 반영
  • failureRateThreshold: 50 → 실패율이 50% 넘으면 OPEN 전환
  • waitDurationInOpenState: 10s → OPEN 상태 유지 시간
  • permittedNumberOfCallsInHalfOpenState: 3 → HALF_OPEN에서 테스트 호출 3번만 허용

4) 환경별 설정

운영/개발/테스트 환경마다 다르게 적용

  • application-dev.yml
resilience4j:
  circuitbreaker:
    instances:
      aiService:
        slidingWindowSize: 5
        minimumNumberOfCalls: 3
        waitDurationInOpenState: 5s
  • application-prod.yml
resilience4j:
  circuitbreaker:
    instances:
      aiService:
        slidingWindowSize: 50
        minimumNumberOfCalls: 10
        waitDurationInOpenState: 60s
  • application-test.yml
resilience4j:
  circuitbreaker:
    instances:
      aiService:
        slidingWindowSize: 3
        minimumNumberOfCalls: 1
        waitDurationInOpenState: 1s

☘️ Test Code

전체 코드

<dependency>
  <groupId>org.wiremock</groupId>
  <artifactId>wiremock-standalone</artifactId>
  <version>3.13.1</version>
  <scope>test</scope>
</dependency>
@ActiveProfiles("test")
@SpringBootTest
@Import(AiRestClient.class)
class AiRestClientTest {

    @Autowired
    private AiRestClient aiRestClient;

    @Autowired
    private CircuitBreakerRegistry circuitBreakerRegistry;

    @Value("${resilience4j.circuitbreaker.instances.aiService.waitDurationInOpenState}")
    private Duration waitDuration;

    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
            .options(wireMockConfig().port(8000))
            .build();

    private RestClient restClient() {
        return RestClient.builder()
                .baseUrl("http://localhost:" + wireMock.getPort())
                .build();
    }

    private void stub500Error(String url) {
        wireMock.stubFor(WireMock.post(url)
                .willReturn(WireMock.aResponse()
                        .withStatus(500)
                        .withBody("Internal Server Error")));
    }

    @Test
    void should_throw_InternalServerError_when_server_returns_500() {
        stub500Error("/ai/templates");

        Throwable thrown = catchThrowable(() -> restClient().post()
                .uri("/ai/templates")
                .body(new AiTemplateRequest(123L, "테스트 내용"))
                .retrieve()
                .body(AiTemplateResponse.class));

        then(thrown).as("서버가 500을 반환하면 InternalServerError 예외가 발생해야 한다")
                .isInstanceOf(HttpServerErrorException.InternalServerError.class);
    }

    @Test
    void should_open_circuitBreaker_after_consecutive_failures() {
        CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("aiService");

        cb.getEventPublisher()
                .onStateTransition(event -> System.out.println("[CB] 상태 전환: " + event.getStateTransition()))
                .onError(event -> System.out.println("[CB] 호출 실패: " + event.getEventType()));

        for (int i = 0; i < 5; i++) {
            assertThrows(RuntimeException.class, () ->
                    aiRestClient.createTemplate(1L, "fail-case"));
        }

        assertThat(cb.getState())
                .as("CircuitBreaker는 연속 실패 후 OPEN 상태가 되어야 한다")
                .isEqualTo(CircuitBreaker.State.OPEN);
    }

    @Test
    void should_transition_to_halfOpen_after_waitDuration() throws InterruptedException {
        CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("aiService");
        cb.transitionToOpenState();

        assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);

        Thread.sleep(waitDuration.toMillis() + 200);

        assertThat(cb.getState())
                .as("waitDuration 이후 HALF_OPEN 상태로 전환되어야 한다")
                .isEqualTo(CircuitBreaker.State.HALF_OPEN);
    }
}

📌 AiRestClientTest 코드 상세 설명

@ActiveProfiles("test")
@SpringBootTest
@Import(AiRestClient.class)
class AiRestClientTest {
  • @ActiveProfiles("test")
    application-test.yml 환경을 적용하도록 지정 (테스트 전용 DB, 설정 등 분리 가능)

  • @SpringBootTest
    → Spring 컨텍스트를 띄워 통합 테스트 실행
    실제 애플리케이션 구동과 유사한 환경에서 Bean 주입/의존성 동작 확인 가능

  • @Import(AiRestClient.class)
    AiRestClient 클래스를 Spring Bean으로 테스트 컨텍스트에 명시적으로 등록

    @Autowired
    private AiRestClient aiRestClient;

    @Autowired
    private CircuitBreakerRegistry circuitBreakerRegistry;

    @Value("${resilience4j.circuitbreaker.instances.aiService.waitDurationInOpenState}")
    private Duration waitDuration;
  • AiRestClient aiRestClient → 테스트 대상
  • CircuitBreakerRegistry → Resilience4j에서 CircuitBreaker 상태를 직접 가져와 확인할 수 있는 레지스트리
  • @Valueapplication.yml 에 정의된 waitDurationInOpenState 값을 주입받아 테스트에서 사용
    @RegisterExtension
    static WireMockExtension wireMock = WireMockExtension.newInstance()
            .options(wireMockConfig().port(8000))
            .build();
  • @RegisterExtension
    → JUnit5 확장 기능. WireMock 서버를 띄워 외부 HTTP 호출을 가짜(Mock) 서버로 대체
  • wireMockConfig().port(8000)
    → WireMock을 로컬 8000번 포트에서 실행
    private RestClient restClient() {
        return RestClient.builder()
                .baseUrl("http://localhost:" + wireMock.getPort())
                .build();
    }
  • RestClient.builder() → Spring 6의 HTTP 클라이언트
  • baseUrl("http://localhost:8000") → WireMock 서버를 호출하도록 설정
    private void stub500Error(String url) {
        wireMock.stubFor(WireMock.post(url)
                .willReturn(WireMock.aResponse()
                        .withStatus(500)
                        .withBody("Internal Server Error")));
    }
  • stub500Error → 특정 URL 호출 시 항상 500 응답을 반환하도록 WireMock에 스텁 등록
  • WireMock.post(url) → POST 요청 가로채기
  • .withStatus(500) → HTTP 500 에러를 응답하도록 지정

✅ 테스트 1: 서버가 500 반환 시 예외 발생 확인

    @Test
    void should_throw_InternalServerError_when_server_returns_500() {
        stub500Error("/ai/templates");

        Throwable thrown = catchThrowable(() -> restClient().post()
                .uri("/ai/templates")
                .body(new AiTemplateRequest(123L, "테스트 내용"))
                .retrieve()
                .body(AiTemplateResponse.class));

        then(thrown).as("서버가 500을 반환하면 InternalServerError 예외가 발생해야 한다")
                .isInstanceOf(HttpServerErrorException.InternalServerError.class);
    }
  • stub500Error("/ai/templates")/ai/templates 호출 시 500 응답
  • catchThrowable() → 예외를 잡아 Throwable 객체로 반환. (AssertJ 제공)
  • 기대 동작: 500 응답 시 HttpServerErrorException.InternalServerError 예외 발생

✅ 테스트 2: CircuitBreaker가 연속 실패 후 OPEN 전환

    @Test
    void should_open_circuitBreaker_after_consecutive_failures() {
        CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("aiService");

        cb.getEventPublisher()
                .onStateTransition(event -> System.out.println("[CB] 상태 전환: " + event.getStateTransition()))
                .onError(event -> System.out.println("[CB] 호출 실패: " + event.getEventType()));

        for (int i = 0; i < 5; i++) {
            assertThrows(RuntimeException.class, () ->
                    aiRestClient.createTemplate(1L, "fail-case"));
        }

        assertThat(cb.getState())
                .as("CircuitBreaker는 연속 실패 후 OPEN 상태가 되어야 한다")
                .isEqualTo(CircuitBreaker.State.OPEN);
    }
  • circuitBreakerRegistry.circuitBreaker("aiService")
    application.yml + Resilience4jConfig 에서 설정한 aiService CircuitBreaker 가져옴

  • cb.getEventPublisher()
    → 상태 전환 / 에러 발생 시 이벤트를 출력하도록 리스너 등록

  • for (int i = 0; i < 5; i++) ...
    → 연속 5번 실패 호출 발생시킴. (minimumNumberOfCalls=5)

  • 기대 동작:

    • 5번 호출 실패 후 → failureRateThreshold=50% 조건 만족
    • CircuitBreaker → OPEN 상태 전환

✅ 테스트 3: 일정 시간이 지나면 HALF_OPEN 전환

    @Test
    void should_transition_to_halfOpen_after_waitDuration() throws InterruptedException {
        CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("aiService");
        cb.transitionToOpenState();

        assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);

        Thread.sleep(waitDuration.toMillis() + 200);

        assertThat(cb.getState())
                .as("waitDuration 이후 HALF_OPEN 상태로 전환되어야 한다")
                .isEqualTo(CircuitBreaker.State.HALF_OPEN);
    }
  • cb.transitionToOpenState()
    → 강제로 OPEN 상태로 전환

  • Thread.sleep(waitDuration.toMillis() + 200)
    → 설정된 waitDurationInOpenState 시간(10초) + 여유 200ms 기다림

  • 기대 동작:

    • 일정 시간 지나면 CircuitBreaker가 HALF_OPEN 상태로 전환

🤔 400 Bad Request도 실패로 카운트되는 문제?

테스트 중 확인한 문제는 400 에러도 실패로 카운트된다는 것이었습니다.

[CB] 호출 실패: ERROR, 예외: HttpClientErrorException$BadRequest: 400 Bad Request ...
[CB] 상태 전환: State transition from CLOSED to OPEN

원인:

  • Resilience4j는 기본적으로 RuntimeException을 실패로 간주
  • HttpClientErrorException.BadRequestRuntimeException → 실패로 기록됨

해결:
recordException을 재정의하여 500대 서버 에러만 실패로 기록하도록 수정했습니다.

@Configuration
public class Resilience4jConfig {

    @Bean
    public CircuitBreakerConfigCustomizer aiServiceCircuitBreakerCustomizer() {
        return CircuitBreakerConfigCustomizer
                .of("aiService", builder -> builder
                        .recordException(throwable -> {
                            if (throwable instanceof HttpStatusCodeException ex) {
                                return ex.getStatusCode().is5xxServerError(); // 500대만 실패로 카운트
                            }
                            return true;
                        })
                );
    }
}

적용 후, 400 에러는 실패로 카운트되지 않고 정상 호출로 처리되었습니다.


profile
하루하루 성실하게

0개의 댓글