프로젝트에서 Resilience4j의 CircuitBreaker, Bulkhead를 적용하면서 어노테이션 기반(@CircuitBreaker, @Bulkhead)이 아닌 Java Config + 직접 호출 방식으로 구조화한 경험을 공유합니다.
왜 AOP 방식을 사용하지 않았는가?
기존에는 아래와 같이 AOP 기반으로 사용하고 있었습니다.
@CircuitBreaker(name = "selectList", fallbackMethod = "selectListFallback")
@Bulkhead(name = "selectList", type = Bulkhead.Type.SEMAPHORE, fallbackMethod = "selectListFallback")
public List<String> selectList(RequestDTO param) { ... }
public List<String> selectListFallback(RequestDTO param, Throwable throwable) { ... }
하지만 다음과 같은 불편이 있었습니다.
- 메서드 시그니처가 fallback 때문에 강제됨
- 내부 메서드 호출 시 AOP 미적용 이슈 (this.method() 문제)
- CircuitBreaker/Bulkhead 등 조합 순서 제어 어려움
- 마지막 성공 응답을 이용한 캐싱 fallback이 불편함
직접 호출 방식(Java Config + Decorator 조합)으로 전환
핵심 설계 목표
- @CircuitBreaker, @Bulkhead 없이 직접 제어
- fallback 메서드 없이 마지막 성공값으로 자동 복구
- 설정은 application.yml이 아닌 Java Config로 통일
- actuator 상태 확인 가능
설정 구성 (Java Config 기반)
1. pom.xml
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
2. application.yml 설정
management:
health:
circuitbreakers:
enabled: true
metrics:
tags:
application: ${spring.application.name}
distribution:
percentiles-histogram:
resilience4j.circuitbreaker.calls: true
resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
eventConsumerBufferSize: 5
- 위 설정은 Java Config로 변환 불가능
3. CircuitBreaker 설정
@Configuration
public class CircuitBreakerConfiguration {
@Bean
public CircuitBreakerConfig defaultCircuitBreakerConfig() {
return CircuitBreakerConfig.custom()
.failureRateThreshold(10)
.slidingWindowSize(2)
.minimumNumberOfCalls(2)
.recordExceptions(RuntimeException.class)
.ignoreExceptions(IllegalArgumentException.class)
.build();
}
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry(CircuitBreakerConfig config) {
return CircuitBreakerRegistry.of(config);
}
}
Config 커스텀 설정 ▽
@Configuration
public class CircuitBreakerConfiguration {
public CircuitBreakerConfig.Builder defaultCircuitBreakerConfigBuilder() {
return CircuitBreakerConfig.custom()
.failureRateThreshold(10)
.slidingWindowSize(2)
.minimumNumberOfCalls(2)
.recordExceptions(RuntimeException.class)
.ignoreExceptions(IllegalArgumentException.class)
;
}
@Bean
public CircuitBreakerConfig defaultCircuitBreakerConfig() {
return defaultCircuitBreakerConfigBuilder().build();
}
@Bean
public CircuitBreakerConfig lowThresholdCircuitBreakerConfig() {
return defaultCircuitBreakerConfigBuilder()
.failureRateThreshold(5)
.build();
}
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry(
CircuitBreakerConfig defaultCircuitBreakerConfig,
CircuitBreakerConfig lowThresholdCircuitBreakerConfig
) {
Map<String, CircuitBreakerConfig> configs = new HashMap<>();
configs.put("default", defaultCircuitBreakerConfig);
configs.put("lowThreshold", lowThresholdCircuitBreakerConfig);
return CircuitBreakerRegistry.of(configs);
}
}
Event Listener 설정 ▽
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry(CircuitBreakerConfig config) {
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
// 생성될 때마다 이벤트 리스너 붙이기
registry.getEventPublisher().onEntryAdded(entry -> registerEventListeners(entry.getAddedEntry()));
return registry;
}
// @Bean
// public MeterRegistry meterRegistry(CircuitBreakerRegistry circuitBreakerRegistry) {
// final SimpleMeterRegistry meterRegistry = new SimpleMeterRegistry();
// TaggedCircuitBreakerMetrics.ofCircuitBreakerRegistry(circuitBreakerRegistry).bindTo(meterRegistry);
// return meterRegistry;
// }
private void registerEventListeners(CircuitBreaker circuitBreaker) {
final String name = circuitBreaker.getName();
circuitBreaker.getEventPublisher()
.onSuccess(event -> {
log.debug("### CircuitBreaker [{}] onSuccess", name);
})
.onFailureRateExceeded(event -> {
log.debug("### CircuitBreaker [{}] onFailureRateExceeded", name);
})
.onSlowCallRateExceeded(event -> {
log.debug("### CircuitBreaker [{}] onSlowCallRateExceeded", name);
})
.onCallNotPermitted(event -> {
log.debug("### CircuitBreaker [{}] onCallNotPermitted", name);
})
.onIgnoredError(event -> {
log.debug("### CircuitBreaker [{}] onIgnoredError", name);
})
.onError(event -> {
// recordExceptions 에 정의된 Exception 발생 시 실행
final Throwable throwable = event.getThrowable();
log.error("### CircuitBreaker [{}] | error message : {}", name, throwable.getMessage(), throwable);
})
.onStateTransition(event -> {
// Circuit 상태 변화 시 실행
final CircuitBreaker.State fromState = event.getStateTransition().getFromState();
final CircuitBreaker.State toState = event.getStateTransition().getToState();
CircuitBreaker.Metrics metrics = circuitBreaker.getMetrics();
log.error("################ CircuitBreaker {} | fromState : {} | toState : {} | metrics : {}", name, fromState, toState, metrics);
});
}
AOP를 사용하고 싶다면? ▽
만약 @CircuitBreaker 어노테이션을 사용하고 싶다면, CircuitBreakerAspect 설정을 추가하면됩니다.
@Bean
public CircuitBreakerAspect circuitBreakerAspect(
CircuitBreakerRegistry circuitBreakerRegistry,
List<CircuitBreakerAspectExt> circuitBreakerAspectExtList,
BeanFactory factory
) {
final SpelResolver spelResolver = new DefaultSpelResolver(new SpelExpressionParser(), new DefaultParameterNameDiscoverer(), factory);
return new CircuitBreakerAspect(
new CircuitBreakerConfigurationProperties(),
circuitBreakerRegistry,
circuitBreakerAspectExtList,
new FallbackExecutor(spelResolver, new FallbackDecorators(List.of(new DefaultFallbackDecorator()))),
spelResolver
);
}
4. Bulkhead 설정
@Configuration
public class BulkheadConfiguration {
@Bean
public BulkheadConfig defaultBulkheadConfig() {
return BulkheadConfig.custom()
.maxConcurrentCalls(20)
.maxWaitDuration(Duration.ofMillis(5))
.build();
}
@Bean
public BulkheadRegistry bulkheadRegistry(BulkheadConfig config) {
return BulkheadRegistry.of(config);
}
}
Event Listener 설정 ▽
@Bean
public BulkheadRegistry bulkheadRegistry(BulkheadConfig defaultBulkheadConfig) {
BulkheadRegistry registry = BulkheadRegistry.of(defaultBulkheadConfig);
// 생성될 때마다 이벤트 리스너 붙이기
registry.getEventPublisher().onEntryAdded(entry -> registerEventListeners(entry.getAddedEntry()));
return registry;
}
private void registerEventListeners(Bulkhead bulkhead) {
bulkhead.getEventPublisher()
.onCallPermitted(event ->
log.debug("###### Bulkhead [{}] permitted", event.getBulkheadName())
)
.onCallRejected(event ->
log.error("###### Bulkhead [{}] rejected", event.getBulkheadName())
)
.onCallFinished(event ->
log.debug("###### Bulkhead [{}] finished", event.getBulkheadName())
);
}
공통 처리 모듈
ResilienceExecutor
@Slf4j
@Service
@RequiredArgsConstructor
public class ResilienceExecutor {
private final CircuitBreakerRegistry circuitBreakerRegistry;
private final BulkheadRegistry bulkheadRegistry;
private final ResilienceCacheManager cacheManager;
public Builder start(String name, String key) {
return new Builder(circuitBreakerRegistry, bulkheadRegistry, cacheManager, name, key);
}
public static class Builder {
private final CircuitBreakerRegistry cbRegistry;
private final BulkheadRegistry bhRegistry;
private final ResilienceCacheManager cacheManager;
private final List<ResilienceDecorator<?>> decorators = new ArrayList<>();
private final String name;
private final String key;
private Builder(CircuitBreakerRegistry cbRegistry, BulkheadRegistry bhRegistry, ResilienceCacheManager cacheManager, String name, String key) {
this.cbRegistry = cbRegistry;
this.bhRegistry = bhRegistry;
this.cacheManager = cacheManager;
this.name = name;
this.key = key;
}
public Builder circuitBreaker() {
CircuitBreaker cb = cbRegistry.circuitBreaker(name);
decorators.add(supplier -> CircuitBreaker.decorateSupplier(cb, supplier));
return this;
}
// Config 커스텀 사용 시 사용
// public Builder circuitBreaker(String... configNames) {
// if (configNames.length > 1) {
// throw new IllegalArgumentException("circuitBreaker requires at most one config");
// }
// final String configName = (configNames.length == 1) ? configNames[0] : "default";
//
// CircuitBreaker cb = cbRegistry.circuitBreaker(name, configName);
// decorators.add(supplier -> CircuitBreaker.decorateSupplier(cb, supplier));
// return this;
// }
public Builder bulkhead() {
Bulkhead bh = bhRegistry.bulkhead(name);
decorators.add(supplier -> Bulkhead.decorateSupplier(bh, supplier));
return this;
}
public <T> T run(Supplier<T> logic) {
Supplier<T> wrapped = logic;
for (int i = decorators.size() - 1; i >= 0; i--) {
wrapped = ((ResilienceDecorator<T>) decorators.get(i)).apply(wrapped);
}
try {
T result = wrapped.get();
if (name != null && result != null) {
cacheManager.put(name, key, result);
}
return result;
} catch (Exception e) {
log.warn("### fallback: name={}, key={}, error={}", name, key, e.toString());
return cacheManager.get(name, key);
}
}
}
@FunctionalInterface
private interface ResilienceDecorator<T> {
Supplier<T> apply(Supplier<T> supplier);
}
}
- 추천 동작 순위 : TargetFunction > BulkHead > TimeLimiter > RateLimiter > CircuitBreaker > Retry
마지막 성공값 저장소
ResilienceCacheManager
@Component
public class ResilienceCacheManager {
private final Map<String, Map<String, Object>> cache = new ConcurrentHashMap<>();
public <K, V> void put(String name, K key, V value) {
cache.computeIfAbsent(name, n -> new ConcurrentHashMap<>())
.put(key.toString(), value);
}
@SuppressWarnings("unchecked")
public <K, V> V get(String name, K key) {
Map<String, Object> inner = cache.get(name);
return inner != null ? (V) inner.get(key.toString()) : null;
}
}
사용 예시
public List<String> selectListWithResilience(RequestDTO param) {
return resilienceExecutor
.start("selectList", param.toString())
.bulkhead()
.circuitBreaker()
.run(
() -> this.selectList(param)
);
}
- CircuitBreaker + Bulkhead 적용
- 실패 시 마지막 성공 값으로 fallback
- AOP 없이 설정값 적용 및 actuator 확인 가능
결과 및 장점
| 항목 | 효과 |
| 설정 적용 | Java Config로 직접 제어 가능 (YML보다 강력) |
| fallback 구조 | 별도 메서드 없이 캐시 자동 복원 |
| actuator 연동 | /actuator/health/circuitBreakers 확인 가능 |
| 테스트 용이성 | 직접 호출 구조로 디버깅/테스트 쉬움 |
| 재사용성 | 모든 호출에 일관된 패턴으로 적용 가능 |
[Reference]
https://resilience4j.readme.io/docs/circuitbreaker
https://mein-figur.tistory.com/entry/resilience4j-circuit-breaker-setting
https://docs.spring.io/spring-cloud-circuitbreaker/docs/current/reference/html/spring-cloud-circuitbreaker-resilience4j.html
https://godekdls.github.io/Resilience4j/spring-boot-2-getting-started/
'spring' 카테고리의 다른 글
| [SpringBoot] 선언형 HTTP 클라이언트 OpenFeign이란? (0) | 2025.07.02 |
|---|---|
| [SpringBoot3] MultipartException, FileCountLimitExceededException 발생 이슈 (0) | 2025.06.27 |
| [SpringBoot] logback xml을 java configuration으로 변환하기 (0) | 2024.11.04 |
| [SpringBoot] Spring Profile과 환경별 resource 설정 (0) | 2024.11.04 |
| Spring MVC의 PathPattern (AntPathMatcher, PathPatternParser) (0) | 2024.05.08 |
댓글