티스토리 뷰
resilience4j-spring-boot, spring-cloud-circuitbreaker 어떤걸 사용해야할까
LichKing 2026. 2. 27. 15:08MSA 환경이 보편화되면서 Circuit Breaker 는 필수가 되어가고있다. java + spring 환경에서는 Netflix 가 만든 Hystrix 를 시작으로 Circuit Breaker 가 대중화됐고, 요즘은 Resilience4j 가 사실상 표준역할을 하고 있다.
Resilience4j 를 spring 에 통합해서 사용하는 방법은 대표적으로 두가지 방법이 있는데, spring cloud 에서 제공하는 spring cloud circuitbreaker resilience4j 를 사용하는 방법과 Resilience4j 가 제공하는 resilience4j spring boot 를 사용하는 방법이 있다.
// resilience4j spring boot
dependencies {
implementation("io.github.resilience4j:resilience4j-spring-boot3:2.3.0")
}
// spring cloud
dependencies {
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j:2025.1.0")
}
두가지 모두 Circuit Breaker 를 구현하고, 구현체로 Resilience4j 를 이용하기 때문에 어떤 구현을 사용하든 상관없다고 생각했었다. 그리고 그렇다면 spring cloud 를 사용하는게 더 나을거라고 생각했다.
spring cloud 에 의존해서 Circuit Breaker 를 사용하면 CircuitBreakerFactory, CircuitBreaker 와 같은 인터페이스들도 spring cloud 가 제공해주는걸 사용하기 때문에 코드 모양이 살짝 달라진다.
// resilience4j spring boot
@Test
void http_요청을_실행한다() {
HttpClient httpClient = new HttpClient();
CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("test");
assertThatNoException().isThrownBy(() -> {
cb.executeRunnable(() -> httpClient.call());
});
}
// spring cloud
@Test
void http_요청을_실행한다() {
HttpClient httpClient = new HttpClient();
CircuitBreaker cb = circuitBreakerFactory.create("test");
assertThatNoException().isThrownBy(() -> {
cb.run(() -> httpClient.call());
});
}
CircuitBreaker 를 각자의 방식으로 생성하고, 그 안에서 http 호출을 일으킨다. 참고로 HttpClient 코드는 임의의 구현이다.
class HttpClient {
public String call() {
try {
Thread.sleep(2000);
} catch(Exception e) {
}
return "success";
}
}
# TimeoutException
두 테스트 코드는 모두 예외가 발생하지 않으면 통과하는 테스트다. 현재 Circuit Breaker 관련한 별도의 설정은 아무것도 진행하지 않았다. 이 상태로 테스트를 실행하면 Resilience4j 를 사용한 테스트는 성공하지만 spring cloud 를 사용하는 테스트는 실패한다.
Circuit Breaker 관점에서만 보면 HttpClient 는 2초간 스레드를 블락한 뒤 리턴을 수행하기 때문에 문제될 여지가 없다. 지금은 임의로 스레드를 블락하게 했지만 실제로 외부 http 호출이 일어난다고 가정하면 http client 에 설정하는 timeout 이 동작할거고, timeout 이 발생하면 예외가 발생하면서 Circuit Breaker 가 동작하게 될 것이다. 때문에 성공한 Resilience4j 가 정상으로 보인다. spring cloud 는 왜 실패할까?
Caused by: java.util.concurrent.TimeoutException: TimeLimiter 'test' recorded a timeout exception.
at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:206)
스택트레이스를 확인해보면 TimeoutException 이라는게 발생했다. 엥? 그 어떤 타임아웃도 설정한게 없는데?
spring cloud 는 자동 구성에서 TimeLimiter 도 구성하게 되는데, 이 TimeLimiter 의 기본설정이 1초이다. 이 때문에 스레드 블락 2초를 대기하다가 TimeLimiter 가 먼저 끊게된다. 지금이야 샘플 코드를 기반으로 설명하니 문제없지만 이런 구성을 모른채로 Circuit Breaker 를 사용한다면 http client timeout 을 1초 넘게 잡아놓은 상태에서도 TimeLimiter 가 요청을 끊어버리는 문제가 발생할 수 있다.
TimeLimiter 까지 사용하게되면 timeout 설정이 복잡해지니 해당 설정을 끄고싶을 수 있다(내가 그랬다). 이때는 설정파일에서 프로퍼티로 제어할 수 있다.
spring:
cloud:
circuitbreaker:
resilience4j:
disable-time-limiter: true
해당 프로퍼티를 설정해주고 테스트를 실행하면 테스트는 성공한다. 참고로 해당 프로퍼티의 기본값은 false 이다.
# NoFallbackAvailableException
다른 상황을 재현하기위해 코드를 변경해보자.
class HttpClient {
public String call() {
throw new CustomException();
}
}
class CustomException extends RuntimeException {}
// resilience4j spring boot
@Test
void http_요청을_실행한다() {
HttpClient httpClient = new HttpClient();
CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("test");
assertThatThrownBy(() -> {
cb.executeRunnable(() -> httpClient.call());
}).isInstanceOf(CustomException.class);
}
// spring cloud
@Test
void http_요청을_실행한다() {
HttpClient httpClient = new HttpClient();
CircuitBreaker cb = circuitBreakerFactory.create("test");
assertThatThrownBy(() -> {
cb.run(() -> httpClient.call());
}).isInstanceOf(CustomException.class);
}
스레드를 블락하면 HttpClient 는 이제 호출하면 예외를 일으키도록 변경되었다. 테스트 코드는 예외가 발생했을때 의도한 예외가 발생했는지를 검증한다. 테스트를 실행하면 또 Resilience4j 만 성공하고, spring cloud 는 실패한다. spring cloud 는 CustomException 이 아니라 NoFallbackAvailableExceptipn 을 일으킨다. spring cloud 의 CircuitBreaker 인터페이스는 fallback 을 요구하기 때문이다. 마땅히 넣을 fallback 정책이 없는데? 그래도 일단은 fallback 을 구현해서 전달해보자.
# ExecutionException
@Test
void http_요청을_실행한다() {
HttpClient httpClient = new HttpClient();
CircuitBreaker cb = circuitBreakerFactory.create("test");
assertThatThrownBy(() -> {
cb.run(() -> httpClient.call(), e -> { throw (RuntimeException) e; });
}).isInstanceOf(CustomException.class);
}
run 메서드는 두번째 파라미터로 fallback 을 요구한다. 람다를 이용해서 전달받은 예외를 그대로 다시 던지도록 구현했다.
하지만 그럼에도 테스트는 통과하지 않는다. 스택트레이스를 보면 fallback 구현했기 때문에 이전에 발생한 NoFallbackAvailiableException 은 보이지 않는다. 대신에 이번엔 ExecutionException 이 발생했음을 볼 수 있다.
도대체 이건 왜 발생한걸까? 그리고 이 테스트는 어떻게 통과시킬 수 있을까?
일단 아까 설정했던 disable-time-limiter 프로퍼티를 기본값인 false 로 변경한 다음에 테스트를 다시 실행해보자.
그럼 비로소 테스트가 통과한다.
# Resilience4j, Spring Cloud
Resilience4j 를 Circuit Breaker 정도로만 알고있는 경우가 많은데 Resilience4j 는 그 외에도 Bulkhead, RateLimiter, Retry, TimeLimiter, Cache 까지도 제공하는 꽤나 방대한 라이브러리이다. CircuitBreaker 를 직접 생성해서 사용한다면 resilience4j spring boot 를 사용할때는 CircuitBreakerRegistry 라는 인터페이스를, spring cloud circuitbreaker resilience4j 를 사용하면 CircuitBreakerFactory 라는 인터페이스를 사용해서 CircuitBreaker 를 생성하게 된다.(참고로 두 가지 생성으로 인해 생성하는 CircuitBreaker 객체는 클래스명만 같을뿐 패키지가 아예 다른 별도의 클래스를 기반으로 생성된다.)
이때 CircuitBreakerRegistry 를 사용해서 생성하는 CircuitBreaker 는 Circuit Breaker 에만 충실한 객체를 주지만 CircuitBreakerFactory 가 생성하는 CircuitBreaker 는 spring cloud 가 한번 더 추상화를 해서 TimeLimiter 랑 통합한 CircuitBreaker 객체를 주게된다. 이 차이 때문에 첫번째 이슈였던 TimeoutException 이 발생하는 것이다.
그럼 또 의문이 하나 생긴다. 위에서 ExecutionException 이 발생했을때 disable-time-limiter 를 false 로 바꿨더니 정상동작하는 이유는 뭘까? TimeLimiter 는 내부에서 예외가 발생했을때 ExecutionException 이 발생하면 예외를 한꺼풀 까서 전파하도록 구현되어있다. disable-time-limiter 속성을 true 로 해서 TimeLimiter 사용을 거부했을땐 TimeLimiter 에 구현되어 있는 예외를 까는 로직이 동작하지 않게 되기 때문에 ExecutionException 이 그대로 전달된 것이다.
때문에 본 예외가 전파되길 바란다면 직접 ExecuteException 을 한번 까서 전파하도록 로직을 작성해야 한다.
# ExecutorService
그럼 위 설정들만 넣어주면 resilience4j spring boot 를 사용하는 것과 spring cloud circuitbreaker resilience4j 를 사용하는게 똑같아진걸까?
그렇지 않다. spring cloud 는 TimeLimiter 동작을 위해 스레드풀을 사용한다. 이 스레드풀에 대한 힌트는 CircuitBreakerFactory 의 구현체인 Resilience4JCircuitBreakerFactory 에서 확인할 수 있다.
기본값이 Executors.newCachedThreadPool() 로 되어있는데 이 설정은 엔터프라이즈 환경에서는 지양하는 설정이다. 무한대로 스레드를 만들 위험이 있기 때문이다. 때문에 아래처럼 직접 만드는 스레드 풀을 넣어주는게 좋다.
@Bean
public Customizer<Resilience4JCircuitBreakerFactory> customizer() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
return factory -> factory.configureExecutorService(executor.getThreadPoolExecutor());
}
아니면 설정파일에서 스레드풀을 사용하지 않도록 설정할 수 있다.
spring:
cloud:
circuitbreaker:
resilience4j:
disable-thread-pool: true
다만 스레드풀을 사용하는 이유는 TimeLimiter 때문인데, TimeLimiter 를 사용하지 않는다면 몰라도 사용한다면 스레드풀을 사용하지 않는것보단 별도의 스레드풀을 만들어서 주입해주는걸 권장한다.
개인적으로는 위 스레드풀에 대한 내용이 spring cloud circuitbreaker 공식 가이드에 자세히 나와있지 않은 부분이 상당히 불만이었다.
# 정리
Circuit Breaker 를 적용해야하는데 spring boot 에 사용할 수 있는 방법이 Resilience4j 가 제공하는 방법과 spring cloud 가 제공하는 방법 두가지가 있어 처음엔 별 생각없이 사용했었다. 그러다가 spring cloud 를 사용할때 TimeLimiter 때문에 TimeoutException 을 맞게됐고, 이 예외가 왜 발생하는지를 파악해보다가 두 방식의 다른점들을 알게되어 정리하게 된 글이다.
Resilience4j 가 제공하는 방법은 사실 큰 문제 없이 직관적인데 반해 spring cloud 가 제공하는 방법이 한번 더 추상화가 들어가고, 그리고 spring cloud 팀의 사상이 더 접목된 방식(fallback 필수, 암묵적인 TimeLimiter, thread pool 설정)이라 잘 모르고 사용하는 경우 어려움이 더 많았다. 개인적으로는 TimeLimiter 를 사용한 타임아웃 설정보다는 내부에서 사용하는 http client 의 타임아웃 설정만 바라보는게 운영상 더 편한 부분이 많아 spring cloud 보단 resilience4j spring boot 를 사용하고 있다.
'Java > spring' 카테고리의 다른 글
| 실전에서 OOP하기(트래픽 제어 구현) (2) | 2025.10.08 |
|---|---|
| RestClient 내부 ObjectMapper 설정 (2) | 2025.07.13 |
| spring boot RestClient 설정하기 (0) | 2025.01.29 |
| Spring bean 에서 다형성을 활용할 수 있을까 (0) | 2024.05.26 |
| spring boot 3 migration#04 spring boot 3 resilience4j 버전이 안올라간다면 (1) | 2024.01.28 |
- Total
- Today
- Yesterday
- Jackson
- OOP
- Spring
- TEST
- Kotlin
- spring cloud
- generics
- EffectiveJava
- 정규표현식
- backend개발환경
- programming
- DesignPattern
- Design Pattern
- db
- http
- go-core
- code
- Git
- toby
- java8
- javascript
- JPA
- frontend개발환경
- mariadb
- frontcode
- clean code
- java
- MySQL
- JavaScript Core
- servlet
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |

