앞서 살펴본 Resilience4j 라이브러리를 적용해 보고자한다. Circuit Breaker 패턴과 Resilience4j에 대해서는 이전 글을 참고하면 된다. 전체 코드는 GitHub에서 볼 수 있다.
목차
- Resilience4j
1.1 환경 구성
1.2 프로퍼티 설정
1.3 커스터마이징- Circuit Breaker 기본 구현
2.1 @CircuitBreaker 적용
2.2 @TimeLimiter 적용
2.3 상태 확인 및 관리
2.4 Fallback 전략- 실전 예제
3.1 시나리오
3.2 Resilience4j 적용 코드
3.3 적용 결과
3.4 고려 사항
1. Resilience4j 환경 구성
1.1 의존성 추가
- gradle
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
1.2 프로퍼티 설정
CirbuitBreaker와 TimeLimiter 설정을 다음과 같이 지정하였다.
- application.yml
resilience4j: circuitbreaker: configs: default: registerHealthIndicator: true # Spring Actuator와 연동하여 Circuit Breaker 상태 모니터링 여부 설정 automaticTransitionFromOpenToHalfOpenEnabled: true # OPEN에서 HALF OPEN으로 자동 변환 여부 waitDurationInOpenState: 30s # OPEN을 유지할 시간 설정으로 이 시간을 유지 후 HALF_OPEN 상태로 변경 slidingWindowSize: 3 # 슬라이딩 윈도우의 크기 failureRateThreshold: 50 # 예외가 발생한 호출의 임계 비율 slidingWindowType: COUNT_BASED # 슬라이딩 윈도우의 타입 설정 slowCallRateThreshold: 80 # 응답 시간이 느린 호출의 임계 비율 slowCallDurationThreshold: 5s # 느린 호출율로 간주하기 위한 시간 설정. TimeLimiter의 timeoutDuration보다 작아야 함 timelimiter: configs: default: timeoutDuration: 6s # 요청이 완료되어야 하는 최대 시간으로 slowCallDurationThreshold보다 커야함 cancelRunningFuture: true # 타임아웃 발생 시 실행 중인 작업을 취소할지 여부
- Resilience4j 라이브러리의 CircuitBreaker는 횟수 기반 슬라이딩 윈도우와 시간 기반 슬라이딩 윈도우를 제공한다. 이때, 느린 호출율과 호출 실패율을 계산하여 설정한 임계값을 넘으면 CircuitBreaker 상태가 변경되기에 상황에 맞게 적절한 설정을 해야한다.
- TimeLimiter는 설정한 timeoutDuration이 지날 경우 응답을 더이상 기다리지 않고 타임아웃 예외를 발생시켜 API 호출이 지연될 경우의 성능 저하를 방지할 수 있다. CircuitBreaker 모듈과 함께 사용해 응답 지연에 대응할 수 있다.
- slidingWindowType: 호출 결과를 기록할 슬라이딩 윈도우의 타입 설정
- COUNT_BASED: 횟수 기반 기록
- TIME_BASED: 시간 기반 기록
CircuitBreaker 상태 모니터링을 위해 actuator 설정을 추가했다. 이 설정을 하면 http://localhost:8080/actuator/health를 통해 Circuit Breaker 상태를 확인할 수 있다.
- application.yml
management: health: circuitbreakers: enabled: true endpoints: enabled-by-default: false jmx: exposure: exclude: "*" web: exposure: include: prometheus,info,health endpoint: prometheus: enabled: true info: enabled: true health: enabled: true show-details: always
1.3 커스터마이징
커스텀 할 수 있는 설정들이 있는데 이 설정은 일단 가볍게 보고 이후에 다시 살펴봐도 좋다. CircuitBreakerNameResolver 인터페이스를 통해 인스턴스 식별과 커스텀할 수 있고, RecordFailurePredicate 인터페이스를 통해 예외에 따른 상태 기록 여부를 커스텀할 수 있다.
- HostNameCircuitBreakerNameResolver
@Component @Slf4j public class HostNameCircuitBreakerNameResolver implements CircuitBreakerNameResolver { @Override public String resolveCircuitBreakerName(String feignClientName, Target<?> target, Method method) { String url = target.url(); try { return new URL(url).getHost(); } catch (MalformedURLException e) { log.error("MalformedURLException is occurred: {}", url); return "default"; } } }
- CircuitBreaker의 이름을 URL에서 호스트명을 추출하여 사용하도록 설정하였다.
- URL 파싱에 실패하면 default를 사용하도록 하였다.
- 이를 통해 각 외부 서비스마다 독립적으로 Circuit Breaker를 관리할 수 있으며, 어떤 외부 서비스가 문제인지 빠르게 파악할 수 있다.
- DefaultExceptionRecordFailurePredicate
public class DefaultExceptionRecordFailurePredicate implements Predicate<Throwable> { @Override public boolean test(Throwable t) { // occurs in @CircuitBreaker TimeLimiter if (t instanceof TimeoutException) { return true; } // occurs in @OpenFein if (t instanceof RetryableException) { return true; } // 4xx 에러도 포함 if (t instanceof FeignException.FeignClientException) { return true; } return t instanceof FeignException.FeignServerException; } }
- 어떤 예외를 Fail로 기록할 것인지 결정하기 위한 Predicate 설정이다.
- true를 반환하면 요청 실패로 기록되고, 실패가 쌓이면 서킷이 OPEN 상태로 변경된다.
- 기본적으로 Resilience4j는 모든 예외 발생을 실패로 간주한다. 만약, 특정 예외에 대해서만 실패 처리를 하고 싶다면 이 설정에 적용하면 된다.
resilience4j: circuitbreaker: configs: default: record-failure-predicate: com.hannah.resilience4j.support.circuit.DefaultExceptionRecordFailurePredicate
2. Circuit Breaker 기본 구현
2.1 @CircuitBreaker 적용
@CircuitBreaker 어노테이션을 통해 CircuitBreaker 설정과 fallbackMethod를 지정할 수 있다. 간단한 예시를 보자. 다음 예시는 1을 입력하면 강제로 예외를 발생 시키도록 하였다.
이때, 주의할 점이 있다.
- fallback method는 동일한 클래스에 있어야 한다.
- @CircuitBreaker의 fallbackMethod 으로 지정한 이름과 fallback method 명이 같아야 한다.
- fallback method의 매개변수로 예외를 받아야한다. 만약, 받지 않으면 런타임 에러가 발생한다.
- CircuitBreakerTestService.java
@CircuitBreaker(name = "default", fallbackMethod = "circuitBreakerExceptionFallback") public Object circuitBreaker(int number){ if(number == 1){ throw new RuntimeException(); } return "Success"; } public Object circuitBreakerExceptionFallback(Exception e) { log.info("=====> circuitBreakerExceptionFallback"); return "circuitBreakerExceptionFallback"; }
위의 코드로 Circuit Breaker의 상태가 변경되는 것을 확인한 결과 다음과 같다.
- CLOSED
- OPEN
- HALF_OPEN
2.2 @TimeLimiter 적용
TimeLimiter는 @TimeLimiter 어노테이션을 통해 적용할 수 있다. 단, 앞서 살펴본 예시와 다른 점이 있는데 TimeLimiter를 적용한 메서드는 반드시 CompletableFuture를 리턴해야한다. CompletableFuture를 통해 비동기 작업의 타임아웃을 효과적으로 제어할 수 있다.
아래는 설정한 timeoutDuration 이상이 소요될 경우 타임아웃을 발생시키는 간단한 예시이다.
- CircuitBreakerTestService.java
@CircuitBreaker(name = "default", fallbackMethod = "circuitBreakerTimeoutFallback") @TimeLimiter(name = "default") public CompletableFuture<String> circuitBreakerTimeout(){ return CompletableFuture.supplyAsync(() -> { try { // 설정한 timeoutDuration 이상 걸릴 경우 테스트 Thread.sleep(7000); return "Success"; } catch (InterruptedException e) { throw new RuntimeException(e); } }); } public CompletableFuture<String> circuitBreakerTimeoutFallback(Exception e) { log.info("=====> circuitBreakerTimeoutFallback"); return CompletableFuture.completedFuture("circuitBreakerTimeoutFallback"); }
위의 코드로 Circuit Breaker의 상태가 변경되는 것을 확인한 결과 다음과 같다.
- CLOSED
- OPEN
2.3 상태 확인 및 관리
다음은 Circuit Breaker의 상태를 조회하고, 직접 설정을 변경하는 예시이다.
@Service
@Slf4j
@RequiredArgsConstructor
public class CircuitBreakerStateService {
private final CircuitBreakerRegistry circuitBreakerRegistry;
public CircuitBreaker.State circuitBreakerOpen(String name) {
circuitBreakerRegistry.circuitBreaker(name).transitionToOpenState();
return circuitBreakerRegistry.circuitBreaker(name).getState();
}
public CircuitBreaker.State circuitBreakerClose(String name) {
circuitBreakerRegistry.circuitBreaker(name).transitionToClosedState();
return circuitBreakerRegistry.circuitBreaker(name).getState();
}
public CircuitBreaker.State circuitBreakerHalfOpen(String name) {
circuitBreakerRegistry.circuitBreaker(name).transitionToHalfOpenState();
return circuitBreakerRegistry.circuitBreaker(name).getState();
}
public CircuitBreaker.State circuitBreakerState(String name) {
return circuitBreakerRegistry.circuitBreaker(name).getState();
}
public Map<String, CircuitBreaker.State> circuitBreakerAll() {
Seq<CircuitBreaker> circuitBreakers = circuitBreakerRegistry.getAllCircuitBreakers();
Map<String, CircuitBreaker.State> result = new HashMap<>();
for (CircuitBreaker circuitBreaker : circuitBreakers) {
result.put(circuitBreaker.getName(), circuitBreaker.getState());
}
return result;
}
}
2.4 Fallback 전략
실패에 대한 대응 방법으로 OpenFeign을 사용할 경우 fallback factory 혹은 fallback method를 통해 fallback을 구현할 수 있으며, 이를 통해 장애에 유연한 대응을 할 수 있다. 앞서 fallback method를 살펴 보았으므로, fallback factory를 사용한 예시를 살펴보자. OpenAI의 API를 사용하는 예시이다.
- OpenAiApi
@FeignClient(name = "chat-gpt-client-test", url = "https://api.openai.com/v1", configuration = {ChatHeaderFeignConfiguration.class}, fallbackFactory = OpenAiApiFallback.class ) @CircuitBreaker(name = "default") public interface OpenAiApi { @PostMapping(path = "/chat/completions") OpenAiRes openAiCompletions(@RequestBody OpenAiReq req); }
- OpenAiApiFallback
@Slf4j @Component public class OpenAiApiFallback implements FallbackFactory<OpenAiApi> { @Override public OpenAiApi create(Throwable cause) { return new OpenAiApi() { @Override public OpenAiRes openAiCompletions(OpenAiReq req) { if (cause instanceof FeignException) { FeignException feignException = (FeignException) cause; log.error("OpenAI API Feign 호출 실패. 상태 코드: {}, 예외 타입: {}, 메시지: {}", feignException.status(), cause.getClass().getName(), cause.getMessage(), cause); } else { log.error("OpenAI API 호출 실패. 예외 타입: {}, 메시지: {}", cause.getClass().getName(), cause.getMessage(), cause); } return OpenAiRes.createFallbackResponse("죄송합니다. 현재 서비스 이용이 어렵습니다. 잠시 후 다시 시도해주세요."); } }; } }
3. 실전 예제
3.1 시나리오
OpenAI API를 통한 LLM 답변 생성 기능에 Circuit Breaker 패턴을 적용해보자. LLM 답변 생성을 위해 OpenAI 서비스만 사용한다면 이는 단일 장애 지점이 될 수 있다. 만약, OpenAI에 장애가 발생한다면, 꼼짝없이 우리의 서비스로 장애가 전파될 것이다.
이 문제를 개선하기 위해 resilience4j의 CircuitBreaker와 TimeLimiter 모듈을 적용해보고 Fallback로직을 추가하고자 한다. 그리고 대안으로 Gemini의 API를 추가해보려고한다.
다양한 방법으로 이 문제를 해결할 수 있다. 필요에 따라, 동적으로 API를 사용하도록 설정 파일을 구성할 수도 있겠지만, OpenAI를 중점으로 이용한다는 가정 하에 다음과 같이 구성하였다.
3.2 Resilience4j 적용 코드
주요 부분만 살펴보도록 하자. 전체 코드는 GitHub에 있다.
먼저, OpenAI API와 Gemini API의 Request와 Response 값이 다른데 동일한 인터페이스를 통해 쉽게 교체할 수 있도록 LlmProvider 인터페이스를 만들어 추상화하였다.
- LlmProvider.java
public interface LlmProvider { CompletableFuture<LlmRes> generateResponse(LlmReq request); CompletableFuture<LlmRes> getFallbackProvider(LlmReq request, Throwable throwable); }
그리고 이를 구현하는데 OpenAI를 Primary로 사용하고, 장애 발생 시 Gemini로 자동 전환하도록 다음과 같이 구성하였다.
- OpenAiProvider.java
@Service @Slf4j @Primary @RequiredArgsConstructor public class OpenAiProvider implements LlmProvider{ private final OpenAiApi openAiApi; private final GeminiProvider geminiProvider; @CircuitBreaker(name = "openai", fallbackMethod = "getFallbackProvider") @TimeLimiter(name = "openai") @Override public CompletableFuture<LlmRes> generateResponse(LlmReq request) { return CompletableFuture.supplyAsync(() -> { OpenAiReq openAiReq = OpenAiReq.from(request); StopWatch stopWatch = new StopWatch(); stopWatch.start(); log.info("openAI Req: {}", openAiReq); OpenAiRes openAiRes = openAiApi.openAiCompletions(openAiReq); stopWatch.stop(); log.info("openAI Res: {}, {}ms", openAiRes, stopWatch.getTotalTimeMillis()); return LlmRes.from(request.getConversationId(), openAiRes); } ); } @Override public CompletableFuture<LlmRes> getFallbackProvider(LlmReq request, Throwable throwable) { log.error("openAI getFallbackProvider", throwable); return geminiProvider.generateResponse(request); } }
- GeminiProvider.java
@Service @Slf4j @RequiredArgsConstructor public class GeminiProvider implements LlmProvider{ private final GeminiAiApi geminiAiApi; @CircuitBreaker(name = "gemini", fallbackMethod = "getFallbackProvider") @TimeLimiter(name = "gemini") @Override public CompletableFuture<LlmRes> generateResponse(LlmReq request) { return CompletableFuture.supplyAsync(() -> { GeminiAiReq geminiAiReq = GeminiAiReq.from(request); StopWatch stopWatch = new StopWatch(); stopWatch.start(); log.info("gemini Req: {}", geminiAiReq); GeminiAiRes geminiAiRes = geminiAiApi.generateContent(request.getPromptDictionary().getModels().getFallback(), geminiAiReq); stopWatch.stop(); log.info("gemini Res: {}, {}ms", geminiAiRes, stopWatch.getTotalTimeMillis()); return LlmRes.from(request.getConversationId(), geminiAiRes); }); } @Override public CompletableFuture<LlmRes> getFallbackProvider(LlmReq request, Throwable throwable) { log.error("gemini getFallbackProvider", throwable); return CompletableFuture.supplyAsync(() -> LlmRes.builder() .conversationId(request.getConversationId()) .output("죄송합니다. 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.") .metadata(LlmRes.MetaData.builder().completionTokens(0) .promptTokens(0) .totalTokens(0) .build()) .build()); } }
3.3 실행 결과
위의 코드 적용 후, 각 상태에 대한 테스트 결과 다음과 같이 잘 동작하는 것을 볼 수 있다.
OpenAI API 호출하는 서킷의 상태가 CLOSED 일때 결과는 다음과 같다.
OpenAI API 호출에 문제가 있을 경우, 다음과 같이 gemini API를 호출한 것을 볼 수 있다.
마지막으로 OpenAI와 gemini API 호출에 둘 다 실패할 경우 다음과 같이 기본 응답을 반환한다.
3.4 고려 사항
추가적으로 Circuit Breaker 상태를 모니터링하기 위해 Grafana와 같은 시스템을 통해 모니터링하고 알림 설정을 해두면 장애를 즉시 파악하고 시스템을 안정적으로 관리할 수 있다.
전체 코드는 아래 GitHub에 있습니다.
GitHub - leehanna602/circuit-breaker-resilience4j
Contribute to leehanna602/circuit-breaker-resilience4j development by creating an account on GitHub.
github.com
관련글
- [Spring] Resilience4j를 통한 Circuit Breaker 패턴 적용 - (1)개념
- [Spring] Resilience4j를 통한 Circuit Breaker 패턴 적용 - (2)적용
'Spring Boot' 카테고리의 다른 글
[Spring] Spring Bean이란? (0) | 2025.03.06 |
---|---|
[Spring] SOLID 원칙 (1) | 2025.02.06 |
[Spring] Resilience4j를 통한 Circuit Breaker 패턴 적용 - (1)개념 (0) | 2024.12.28 |
[Spring] Redis를 이용한 대기열 관리 (0) | 2024.12.28 |
[Spring] Transaction 분리를 통한 성능 개선 (0) | 2024.12.27 |