티스토리 뷰

MSA 환경이 보편화되면서 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 를 사용하고 있다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/02   »
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
글 보관함