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

Circuit Breaker는 3개의 상태를 가집니다
@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() → 실패 시 실행되는 대체 메서드
@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() → 어떤 예외를 실패로 볼지 정의
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번만 허용운영/개발/테스트 환경마다 다르게 적용
resilience4j:
circuitbreaker:
instances:
aiService:
slidingWindowSize: 5
minimumNumberOfCalls: 3
waitDurationInOpenState: 5s
resilience4j:
circuitbreaker:
instances:
aiService:
slidingWindowSize: 50
minimumNumberOfCalls: 10
waitDurationInOpenState: 60s
resilience4j:
circuitbreaker:
instances:
aiService:
slidingWindowSize: 3
minimumNumberOfCalls: 1
waitDurationInOpenState: 1s
<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);
}
}
@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 상태를 직접 가져와 확인할 수 있는 레지스트리@Value → application.yml 에 정의된 waitDurationInOpenState 값을 주입받아 테스트에서 사용 @RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().port(8000))
.build();
@RegisterExtensionWireMock 서버를 띄워 외부 HTTP 호출을 가짜(Mock) 서버로 대체wireMockConfig().port(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 에러를 응답하도록 지정 @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 제공)HttpServerErrorException.InternalServerError 예외 발생 @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)
기대 동작:
failureRateThreshold=50% 조건 만족 @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 기다림
기대 동작:
테스트 중 확인한 문제는 400 에러도 실패로 카운트된다는 것이었습니다.
[CB] 호출 실패: ERROR, 예외: HttpClientErrorException$BadRequest: 400 Bad Request ...
[CB] 상태 전환: State transition from CLOSED to OPEN
원인:
RuntimeException을 실패로 간주HttpClientErrorException.BadRequest도 RuntimeException → 실패로 기록됨해결:
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 에러는 실패로 카운트되지 않고 정상 호출로 처리되었습니다.
