본문 바로가기
삵
Cloud Computing & MSA

Resilience4j로 MSA 장애 전파 막기

OOOooOOoo·2026년 9월 26일·조회 1

MSA로 서비스를 잘게 쪼개고 나면, 하나가 느려질 때 그 지연이 호출한 쪽으로 번지는 상황을 자주 만난다. 결제 서비스가 응답을 못 주면 주문 서비스 스레드가 거기서 대기하고, 그 스레드가 다 물리면 주문 서비스까지 같이 죽는 식이다. 이 글은 그 전파를 애노테이션 몇 개로 끊는 방법을 정리한다.

결론부터 말하자면, Spring Boot 3에서는 resilience4j-spring-boot3와 spring-boot-starter-aop를 넣고 @CircuitBreaker, @Retry, @TimeLimiter, @Bulkhead를 메서드에 붙이는 방식으로 처리한다. 설정은 application.yml의 instances 항목으로 인스턴스별로 관리하고, 상태는 Actuator 엔드포인트와 Micrometer 지표로 관측한다. 아래에서 개념 정의, 의존성, 설정, 애노테이션 적용, 관측을 차례로 살펴본다.

1. 네 가지 기능이 각각 무엇을 막나

Resilience4j는 장애 대응 패턴을 모듈로 제공하는 경량 라이브러리다. Netflix Hystrix가 유지보수를 멈춘 뒤 사실상 표준 자리를 이어받았다. 이 글에서 쓰는 네 가지는 역할이 다르다.

  • 서킷브레이커(CircuitBreaker): 실패율이 임계치를 넘으면 회로를 열어(OPEN) 호출 자체를 즉시 차단한다. 죽은 서비스를 계속 두드리지 않게 하는 차단기다.
  • 재시도(Retry): 일시적 오류를 정해진 횟수만큼 다시 시도한다. 네트워크 순단 같은 일회성 실패에 쓴다.
  • 타임리미터(TimeLimiter): 비동기 호출에 타임아웃을 걸어 정해진 시간을 넘으면 취소한다. 무한정 대기하는 스레드를 막는다.
  • 벌크헤드(Bulkhead): 동시 호출 수를 제한한다. 배의 격벽처럼, 한 서비스 호출이 스레드를 다 잡아먹어 다른 기능까지 마비되는 것을 막는다.

서킷브레이커의 상태는 세 가지다. CLOSED는 정상 통과, OPEN은 차단, HALF_OPEN은 회복 여부를 확인하려고 일부 호출만 흘려보내는 중간 상태다.

2. 의존성 추가

Spring Boot 3에서는 반드시 resilience4j-spring-boot3를 써야 한다. -spring-boot2를 넣으면 Jakarta 네임스페이스가 안 맞아 자동 설정이 붙지 않는다. 여기서 한 번 걸리기 쉽다. 애노테이션이 동작하려면 AOP 스타터도 필요하다.

// build.gradle
dependencies {
    implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    // 프로메테우스로 지표를 내보낼 경우
    runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
}

Spring Boot 3.x는 Java 17 이상을 요구한다. Resilience4j 2.x도 Java 17 기준이므로 런타임을 맞춰 둔다.

3. application.yml 설정

인스턴스 이름으로 정책을 나눈다. 아래 orderService는 뒤에서 애노테이션의 name과 연결된다.

resilience4j:
  circuitbreaker:
    instances:
      orderService:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 20
        minimum-number-of-calls: 10
        failure-rate-threshold: 50
        slow-call-rate-threshold: 100
        slow-call-duration-threshold: 2s
        wait-duration-in-open-state: 10s
        permitted-number-of-calls-in-half-open-state: 3
        automatic-transition-from-open-to-half-open-enabled: true
        register-health-indicator: true
  retry:
    instances:
      orderService:
        max-attempts: 3
        wait-duration: 500ms
        retry-exceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
  timelimiter:
    instances:
      orderService:
        timeout-duration: 2s
        cancel-running-future: true
  bulkhead:
    instances:
      orderService:
        max-concurrent-calls: 20
        max-wait-duration: 0

기본값을 알아두면 설정을 줄일 수 있다. 서킷브레이커는 sliding-window-size 100, minimum-number-of-calls 100, failure-rate-threshold 50%, wait-duration-in-open-state 60s가 기본이다. 기본 minimum-number-of-calls가 100이라 호출이 그만큼 쌓이기 전에는 회로가 절대 안 열린다. 테스트에서 서킷이 안 열린다고 헤맸다면 십중팔구 이 값 때문이다. 위 예시처럼 10 정도로 낮춰야 소규모 트래픽에서도 동작을 확인할 수 있다.

Retry는 max-attempts 3, wait-duration 500ms가 기본이다. TimeLimiter는 timeout-duration 1s, 세마포어 방식 Bulkhead는 max-concurrent-calls 25가 기본이다.

4. 애노테이션으로 걸기

서비스 메서드에 애노테이션을 붙이고 fallbackMethod로 대체 응답을 지정한다. 폴백 메서드는 원본과 반환 타입이 같고, 마지막 인자로 Throwable(또는 잡을 예외 타입)을 받아야 한다.

@Service
public class OrderClient {

    private final RestClient restClient;

    public OrderClient(RestClient restClient) {
        this.restClient = restClient;
    }

    @CircuitBreaker(name = "orderService", fallbackMethod = "fallback")
    @Retry(name = "orderService")
    @Bulkhead(name = "orderService")
    public PaymentResult pay(Long orderId) {
        return restClient.get()
                .uri("http://payment/pay/{id}", orderId)
                .retrieve()
                .body(PaymentResult.class);
    }

    private PaymentResult fallback(Long orderId, Throwable t) {
        // 회로가 열렸거나 재시도까지 실패하면 여기로 온다
        return PaymentResult.pending(orderId);
    }
}

@TimeLimiter는 동기 반환 타입에는 걸 수 없다. 반환을 CompletableFuture로 바꿔야 한다. 별도 스레드에서 실행되므로 정해진 시간을 넘으면 취소된다.

@TimeLimiter(name = "orderService")
@CircuitBreaker(name = "orderService", fallbackMethod = "asyncFallback")
public CompletableFuture<PaymentResult> payAsync(Long orderId) {
    return CompletableFuture.supplyAsync(() ->
            restClient.get()
                    .uri("http://payment/pay/{id}", orderId)
                    .retrieve()
                    .body(PaymentResult.class));
}

private CompletableFuture<PaymentResult> asyncFallback(Long orderId, Throwable t) {
    return CompletableFuture.completedFuture(PaymentResult.pending(orderId));
}

5. 애스펙트 적용 순서를 알고 써라

여러 애노테이션을 한 메서드에 붙이면 감싸는 순서가 정해져 있다. 공식 문서 기준 바깥에서 안쪽으로 이렇게 중첩된다.

Retry( CircuitBreaker( RateLimiter( TimeLimiter( Bulkhead( 실제_메서드 ) ) ) ) )

Retry가 가장 바깥, Bulkhead가 가장 안쪽이다. 재시도가 서킷브레이커를 감싸므로, 한 번 호출에서 재시도가 일어나면 각 시도가 서킷브레이커를 통과한다. 여기서 주의할 점이 있다. 서킷이 OPEN이면 호출이 CallNotPermittedException으로 즉시 거부되는데, 이 예외가 재시도 대상인지는 Retry 설정에 달렸다. Retry의 retry-exceptions를 지정하지 않으면 기본적으로 모든 예외를 재시도하므로 CallNotPermittedException도 다시 시도한다. 위 3절처럼 retry-exceptions를 IOException, TimeoutException으로 좁혀 두면 회로가 열렸을 때 재시도가 죽은 서비스를 두드리지 않는다. 재시도로 서킷을 계속 건드리기 싫다면 이 설정을 반드시 좁혀라.

순서를 바꾸고 싶으면 각 설정의 순서 프로퍼티로 조정할 수 있다. 값이 작을수록 바깥쪽이다.

6. Actuator와 Micrometer로 상태 관측

Actuator를 열면 서킷 상태와 이벤트를 조회할 수 있다.

management:
  endpoints:
    web:
      exposure:
        include: health, circuitbreakers, circuitbreakerevents, metrics, prometheus
  endpoint:
    health:
      show-details: always

/actuator/circuitbreakers는 각 인스턴스의 현재 상태를 준다.

$ curl -s localhost:8080/actuator/circuitbreakers | jq
{
  "circuitBreakers": {
    "orderService": {
      "state": "CLOSED",
      "failureRate": "0.0%",
      "slowCallRate": "0.0%",
      "bufferedCalls": 4,
      "failedCalls": 0
    }
  }
}

Micrometer 지표는 /actuator/prometheus로 나간다. 상태 전이나 실패율에 알림을 걸 때 이 지표를 쓴다.

$ curl -s localhost:8080/actuator/prometheus | grep resilience4j_circuitbreaker_state
resilience4j_circuitbreaker_state{name="orderService",state="closed",} 1.0
resilience4j_circuitbreaker_state{name="orderService",state="open",} 0.0
resilience4j_circuitbreaker_state{name="orderService",state="half_open",} 0.0

resilience4j_circuitbreaker_state는 현재 상태에 1, 나머지에 0을 준다. 프로메테우스 알림은 resilience4j_circuitbreaker_state{state="open"} == 1 같은 식으로 건다. 실패율은 resilience4j_circuitbreaker_failure_rate, 재시도는 resilience4j_retry_calls, 벌크헤드 여유는 resilience4j_bulkhead_available_concurrent_calls로 본다.

7. 헬스에 서킷 상태를 반영할 때 주의점

헬스 인디케이터를 켜려면 설정 두 개가 필요하다. 인스턴스별 register-health-indicator: true(3절에 포함)와 전역 스위치다.

management:
  health:
    circuitbreakers:
      enabled: true

여기서 흔히 오해하는 지점이 있다. 기본 설정에서 서킷이 OPEN이 돼도 /actuator/health 전체는 UP(200)을 유지한다. Resilience4j의 allowHealthIndicatorToFail 기본값이 false이기 때문이다. 이 경우 OPEN은 DOWN이 아니라 커스텀 상태 CIRCUIT_OPEN으로, HALF_OPEN은 CIRCUIT_HALF_OPEN으로 표시되고, 이들은 전체 헬스를 실패로 만들지 않는다.

$ curl -s localhost:8080/actuator/health | jq '.status, .components.circuitBreakers'
"UP"
{
  "status": "UP",
  "details": {
    "orderService": {
      "status": "CIRCUIT_OPEN",
      "details": { "failureRate": "60.0%", "state": "OPEN" }
    }
  }
}

즉 헬스가 DOWN이 되는 것에 의존해 알림을 걸었다면, 서킷이 열려도 알림이 울리지 않는다. 서킷 OPEN을 헬스 DOWN으로 만들려면 인스턴스 설정에 allow-health-indicator-to-fail: true를 켜야 한다. 이때 OPEN은 DOWN, HALF_OPEN은 UNKNOWN, CLOSED는 UP으로 매핑된다.

resilience4j:
  circuitbreaker:
    instances:
      orderService:
        register-health-indicator: true
        allow-health-indicator-to-fail: true

다만 헬스가 DOWN이 되면 쿠버네티스 라이브니스/레디니스 프로브가 파드를 죽이거나 트래픽에서 빼는 부작용이 있으니, 프로브 대상 엔드포인트와 알림용 판단을 분리해서 쓰는 편을 권한다. 개인적으로는 헬스는 기본값(UP 유지)으로 두고 알림은 6절의 Micrometer 지표로 거는 쪽을 쓴다.

8. 자주 걸리는 함정

  • 같은 클래스 내부 호출: 애노테이션은 Spring AOP 프록시로 동작한다. 같은 빈 안에서 this.pay()로 부르면 프록시를 안 거쳐 애노테이션이 무시된다. 다른 빈으로 분리해 호출해야 한다.
  • 폴백 시그니처 불일치: 폴백 메서드 인자가 원본과 안 맞으면 런타임에 NoSuchMethodException이 난다. 마지막에 Throwable 인자를 붙였는지 확인한다.
  • TimeLimiter를 동기 메서드에 부착: 반환이 CompletableFuture가 아니면 타임아웃이 걸리지 않는다.
  • 기본 minimum-number-of-calls 100: 호출량이 적은 서비스에서 서킷이 안 열리는 대부분의 원인이다.

9. 정리

Spring Boot 3에서는 resilience4j-spring-boot3와 AOP 스타터를 넣고 애노테이션으로 서킷브레이커, 재시도, 타임아웃, 벌크헤드를 건다. 정책은 application.yml의 instances로 인스턴스별로 관리하고, 상태는 /actuator/circuitbreakers와 Micrometer 지표로 본다. 헬스 반영은 기본이 UP 유지라는 점을 알고, 알림 기준을 헬스에 걸지 지표에 걸지 의도에 맞게 정하면 된다.

자주 묻는 질문

Spring Boot 3에서 resilience4j-spring-boot2를 쓰면 안 되나?

안 된다. Spring Boot 3는 javax에서 jakarta 네임스페이스로 넘어갔다. spring-boot2 아티팩트는 이를 따르지 않아 자동 설정과 애노테이션 처리가 붙지 않는다. 반드시 resilience4j-spring-boot3(2.x)를 쓴다.

서킷이 OPEN인데 /actuator/health가 UP으로 나오는 이유는?

allowHealthIndicatorToFail 기본값이 false이기 때문이다. 이 경우 OPEN은 커스텀 상태 CIRCUIT_OPEN으로 표시되고 전체 헬스는 UP(200)을 유지한다. OPEN을 DOWN으로 반영하려면 해당 인스턴스에 allow-health-indicator-to-fail: true를 켜야 한다.

애노테이션 여러 개를 붙이면 적용 순서는?

바깥에서 안쪽으로 Retry, CircuitBreaker, RateLimiter, TimeLimiter, Bulkhead 순으로 감싼다. Retry가 가장 바깥이라 재시도할 때마다 서킷브레이커를 통과한다. 순서는 각 기능의 순서 프로퍼티로 조정할 수 있다.

@TimeLimiter가 동작하지 않는다.

TimeLimiter는 비동기 타입에만 걸린다. 메서드 반환을 CompletableFuture(또는 리액티브 타입)로 바꿔야 한다. 동기 반환 타입에는 타임아웃이 적용되지 않는다.

테스트에서 서킷이 열리지 않는다.

기본 minimum-number-of-calls가 100이라 그만큼 호출이 쌓이기 전에는 실패율 계산이 시작되지 않는다. sliding-window-size와 minimum-number-of-calls를 10 안팎으로 낮추면 소규모 호출에서도 회로가 열린다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.