외부 결제 API가 응답을 안 하면, 우리 서비스도 같이 멈춰야 하는 걸까요?

마이크로서비스 환경에서 하나의 서비스 장애가 연쇄적으로 전체 시스템을 마비시키는 것을 "장애 전파(Cascading Failure)"라고 합니다. Resilience4j는 서킷 브레이커, 리트라이, 벌크헤드 등의 패턴으로 이런 장애를 격리하고 시스템의 탄력성을 높여줍니다.

Resilience4j란

Resilience4j는 Java 애플리케이션을 위한 경량 장애 허용 라이브러리입니다. Netflix Hystrix의 후속으로, Java 8+ 함수형 프로그래밍을 기반으로 설계되었습니다.

제공하는 핵심 모듈:

  • CircuitBreaker: 장애 감지 후 요청 차단
  • Retry: 실패 시 자동 재시도
  • RateLimiter: 요청 속도 제한
  • Bulkhead: 동시 요청 수 제한
  • TimeLimiter: 타임아웃 설정

의존성

XML
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>

Circuit Breaker — 서킷 브레이커

상태 머신

PLAINTEXT
         실패율 ≥ 임계값
  CLOSED ──────────────→ OPEN
    ↑                      │
    │ 성공률 ≥ 임계값      │ 대기 시간 경과
    │                      ↓
    └──────────────── HALF_OPEN
         실패율 ≥ 임계값 → OPEN
  • CLOSED: 정상 상태. 모든 요청 통과. 실패율 모니터링 중
  • OPEN: 차단 상태. 모든 요청 즉시 실패. 백엔드 보호
  • HALF_OPEN: 탐색 상태. 제한된 요청만 통과시켜 복구 여부 확인

설정

YAML
resilience4j:
  circuitbreaker:
    instances:
      paymentService:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 10           # 최근 10개 요청 기준
        failureRateThreshold: 50         # 실패율 50% 이상이면 OPEN
        waitDurationInOpenState: 30s     # OPEN 상태 유지 시간
        permittedNumberOfCallsInHalfOpenState: 5  # HALF_OPEN에서 허용할 요청 수
        minimumNumberOfCalls: 5          # 최소 5번은 호출해야 판단
        recordExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
        ignoreExceptions:
          - com.example.BusinessException  # 비즈니스 예외는 실패로 카운트하지 않음

어노테이션 사용

JAVA
@Service
public class PaymentService {

    private final PaymentClient paymentClient;

    @CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
    public PaymentResult processPayment(PaymentRequest request) {
        return paymentClient.pay(request);
    }

    // Fallback 메서드 — 파라미터 + Throwable
    private PaymentResult paymentFallback(PaymentRequest request, Throwable t) {
        log.warn("결제 서비스 장애로 Fallback 실행: {}", t.getMessage());
        return PaymentResult.pending(request.getOrderId(),
                "결제 서비스가 일시적으로 이용 불가합니다. 잠시 후 자동으로 재시도됩니다.");
    }
}

Fallback 메서드는 원본 메서드와 ** 같은 파라미터 + Throwable**을 받아야 합니다.

Retry — 자동 재시도

일시적인 장애(네트워크 글리치 등)에 대해 자동으로 재시도합니다.

YAML
resilience4j:
  retry:
    instances:
      paymentService:
        maxAttempts: 3                # 최대 3번 시도
        waitDuration: 1s               # 재시도 간격
        exponentialBackoffMultiplier: 2 # 지수 백오프 (1s → 2s → 4s)
        retryExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
        ignoreExceptions:
          - com.example.BusinessException
JAVA
@Retry(name = "paymentService", fallbackMethod = "retryFallback")
public PaymentResult processPayment(PaymentRequest request) {
    return paymentClient.pay(request);
}

CircuitBreaker + Retry 조합

Retry를 CircuitBreaker 안에서 사용하면 "재시도해도 안 되면 서킷을 열어" 패턴을 구현할 수 있습니다.

JAVA
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback")
@Retry(name = "paymentService")
public PaymentResult processPayment(PaymentRequest request) {
    return paymentClient.pay(request);
}

실행 순서: Retry → CircuitBreaker. Retry가 모든 재시도를 소진한 후에도 실패하면 CircuitBreaker에 실패로 기록됩니다.

RateLimiter — 속도 제한

YAML
resilience4j:
  ratelimiter:
    instances:
      externalApi:
        limitForPeriod: 100           # 주기당 허용 요청 수
        limitRefreshPeriod: 1s         # 갱신 주기
        timeoutDuration: 500ms         # 허용 대기 시간
JAVA
@RateLimiter(name = "externalApi")
public ExternalData callExternalApi() {
    return externalClient.getData();
}

외부 API의 Rate Limit이 있을 때, 우리 쪽에서 미리 속도를 제한하여 429 에러를 방지합니다.

Bulkhead — 격벽

동시 호출 수를 제한하여 특정 서비스 호출이 전체 스레드 풀을 점유하는 것을 방지합니다.

세마포어 방식

YAML
resilience4j:
  bulkhead:
    instances:
      paymentService:
        maxConcurrentCalls: 20        # 최대 동시 호출 20개
        maxWaitDuration: 500ms         # 대기 시간
JAVA
@Bulkhead(name = "paymentService")
public PaymentResult processPayment(PaymentRequest request) {
    return paymentClient.pay(request);
}

스레드 풀 방식

YAML
resilience4j:
  thread-pool-bulkhead:
    instances:
      paymentService:
        maxThreadPoolSize: 10
        coreThreadPoolSize: 5
        queueCapacity: 20
방식격리 수준오버헤드비동기 지원
세마포어동시 호출 수 제한낮음제한적
스레드 풀별도 스레드 풀 격리높음우수

TimeLimiter — 타임아웃

비동기 호출에 타임아웃을 설정합니다.

YAML
resilience4j:
  timelimiter:
    instances:
      paymentService:
        timeoutDuration: 3s            # 3초 타임아웃
        cancelRunningFuture: true      # 타임아웃 시 실행 중인 Future 취소
JAVA
@TimeLimiter(name = "paymentService")
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback")
public CompletableFuture<PaymentResult> processPaymentAsync(PaymentRequest request) {
    return CompletableFuture.supplyAsync(() -> paymentClient.pay(request));
}

모니터링

Resilience4j는 Actuator, Micrometer와 통합되어 상태를 모니터링할 수 있습니다.

YAML
management:
  endpoints:
    web:
      exposure:
        include: health, circuitbreakers, retries
  health:
    circuitbreakers:
      enabled: true
PLAINTEXT
GET /actuator/circuitbreakers
GET /actuator/circuitbreakerevents

Prometheus/Grafana와 연동하면 서킷 상태, 실패율, 요청 수 등을 시각화할 수 있습니다.

실무 패턴 조합

JAVA
@Service
public class OrderService {

    // 결제: 재시도 → 서킷 브레이커 → 타임아웃
    @TimeLimiter(name = "payment")
    @CircuitBreaker(name = "payment", fallbackMethod = "paymentFallback")
    @Retry(name = "payment")
    @Bulkhead(name = "payment")
    public CompletableFuture<PaymentResult> processPayment(PaymentRequest request) {
        return CompletableFuture.supplyAsync(() -> paymentClient.pay(request));
    }

    // Fallback
    private CompletableFuture<PaymentResult> paymentFallback(
            PaymentRequest request, Throwable t) {
        log.warn("결제 Fallback: {}", t.getMessage());
        return CompletableFuture.completedFuture(
                PaymentResult.pending(request.getOrderId()));
    }
}

** 실행 순서 **: Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → 실제 호출

실무 팁

  • slidingWindowSize 와 failureRateThreshold 는 트래픽 패턴에 맞게 조절하세요
  • Fallback에서 **대안 로직 **(캐시 반환, 큐에 적재 등)을 구현하세요
  • ignoreExceptions 로 비즈니스 예외(잘못된 입력 등)는 실패로 카운트하지 마세요
  • Retry에 지수 백오프 를 반드시 설정하여 장애 서비스에 부하를 주지 마세요

주의할 점

1. Retry와 CircuitBreaker를 조합할 때 어노테이션 순서에 따라 동작이 달라진다

Resilience4j에서 어노테이션 순서에 따라 실행 순서가 결정됩니다. Retry가 CircuitBreaker 바깥에 있으면 서킷이 열린 상태에서도 재시도가 발생하여 불필요한 CallNotPermittedException이 반복됩니다. 일반적으로 Retry → CircuitBreaker 순서(Retry가 안쪽)가 적절합니다.

2. Fallback 메서드의 시그니처가 원본과 다르면 런타임에 예외가 발생한다

Fallback 메서드는 원본 메서드와 동일한 파라미터 + Throwable(또는 특정 예외 타입)을 받아야 합니다. 파라미터 타입이나 순서가 다르면 NoSuchMethodException이 발생하는데, 컴파일 시점에는 잡히지 않아 운영 중에 장애가 발생할 수 있습니다.

3. ignoreExceptions를 설정하지 않으면 비즈니스 예외도 실패로 카운트된다

잘못된 입력(400 Bad Request)이나 인증 실패(401) 같은 비즈니스 예외가 서킷 브레이커의 실패율에 포함되면, 정상적인 요청 거부가 많아질 때 서킷이 열려서 모든 요청이 차단됩니다. 비즈니스 예외는 반드시 ignoreExceptions에 등록하여 실패 카운트에서 제외하세요.

정리

  • CircuitBreaker 는 CLOSED → OPEN → HALF_OPEN 상태 전환으로 장애를 감지하고 격리합니다
  • Retry 는 일시적 장애에 자동 재시도하며, 지수 백오프로 부하를 분산합니다
  • Bulkhead 는 동시 호출 수를 제한하여 장애 전파를 방지합니다
  • 실무에서는 여러 패턴을 조합 하여 사용하며, Fallback에서 대안 로직을 제공합니다
댓글 로딩 중...