Spring Resilience4j
마이크로서비스 환경에서 외부 서비스 호출은 실패할 수 있다. 한 서비스의 장애가 연쇄적으로 전파돼 전체 시스템이 다운되는 “연쇄 장애(Cascading Failure)”가 가장 위험하다. Resilience4j는 이를 방어하는 경량 내결함성(Fault Tolerance) 라이브러리다.
비유: 전기 두꺼비집(Circuit Breaker)을 생각하라. 과부하가 걸리면 자동으로 전기를 차단해 화재를 방지한다. Resilience4j는 서비스 호출에도 이 두꺼비집을 달아준다. 외부 서비스가 불안정하면 회로를 열어 요청을 차단하고, 일정 시간 후 조심스럽게 다시 연결을 시도한다.
의존성
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
<!-- Actuator 메트릭 연동 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
Circuit Breaker
개념과 상태 전이
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN : 실패율 임계치 초과\n(예: 50% 이상 실패)
OPEN --> HALF_OPEN : 대기 시간 경과\n(예: 30초 후)
HALF_OPEN --> CLOSED : 시험 요청 성공\n(예: 10회 중 8회 성공)
HALF_OPEN --> OPEN : 시험 요청 실패
CLOSED : CLOSED\n정상 동작\n모든 요청 통과
OPEN : OPEN\n회로 차단\n즉시 fallback 반환
HALF_OPEN : HALF_OPEN\n제한적 요청 허용\n회복 테스트 중
CLOSED: 정상 상태. 모든 요청 허용. 실패율 모니터링.
OPEN: 차단 상태. 모든 요청 즉시 거부 → fallback 실행.
→ 외부 서비스가 이미 불안정하므로 더 이상 요청하지 않음
→ 외부 서비스 회복 시간 확보
HALF-OPEN: 회복 테스트. 제한된 수의 요청만 허용.
→ 성공하면 CLOSED, 실패하면 다시 OPEN
설정
resilience4j:
circuitbreaker:
instances:
user-service:
# 슬라이딩 윈도우 타입: COUNT_BASED(호출 수) / TIME_BASED(시간)
sliding-window-type: COUNT_BASED
# 슬라이딩 윈도우 크기: 최근 10회 호출 기준
sliding-window-size: 10
# OPEN 전환 실패율 임계치 (%)
failure-rate-threshold: 50
# OPEN → HALF_OPEN 대기 시간
wait-duration-in-open-state: 30s
# HALF_OPEN에서 허용할 시험 호출 수
permitted-number-of-calls-in-half-open-state: 5
# 슬라이딩 윈도우 시작에 필요한 최소 호출 수
minimum-number-of-calls: 5
# 느린 호출 임계치 (이 이상 걸리면 실패로 간주)
slow-call-duration-threshold: 3s
# 느린 호출 비율 임계치 (%)
slow-call-rate-threshold: 80
# 특정 예외는 무시 (실패 통계에 미포함)
ignore-exceptions:
- com.example.BusinessException
# Circuit Breaker 이벤트 버퍼 크기
event-consumer-buffer-size: 10
사용 예시
@Service
public class OrderService {
private final UserServiceClient userServiceClient;
@CircuitBreaker(name = "user-service", fallbackMethod = "getUserFallback")
public UserDto getUser(Long userId) {
return userServiceClient.getUser(userId);
}
// fallback 메서드: 원래 메서드와 동일한 반환 타입, 마지막 파라미터에 Throwable 추가
private UserDto getUserFallback(Long userId, Throwable throwable) {
log.warn("Circuit breaker activated for userId: {}, reason: {}",
userId, throwable.getMessage());
// 캐시된 기본값 반환 또는 기본 객체
return UserDto.builder()
.id(userId)
.name("Unknown")
.build();
}
}
상태 모니터링
# Circuit Breaker 상태 조회
curl http://localhost:8080/actuator/circuitbreakers
# 상태 이벤트 스트림
curl http://localhost:8080/actuator/circuitbreakerevents/user-service
{
"circuitBreakerName": "user-service",
"state": "CLOSED",
"failureRate": "20.0%",
"slowCallRate": "0.0%",
"bufferedCalls": 10,
"failedCalls": 2,
"successfulCalls": 8
}
Retry
개념
일시적 오류(네트워크 순단, DB 타임아웃)는 즉시 재시도하면 성공할 수 있다. Retry는 지정된 횟수만큼 자동으로 재시도한다.
설정
resilience4j:
retry:
instances:
payment-service:
# 최대 재시도 횟수 (첫 시도 포함)
max-attempts: 3
# 재시도 간격
wait-duration: 500ms
# 지수 백오프 (재시도마다 대기 시간 증가)
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
# 최대 대기 시간 (지수 백오프 상한)
exponential-max-wait-duration: 5s
# 재시도할 예외 종류
retry-exceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
# 재시도하지 않을 예외 (비즈니스 예외 등)
ignore-exceptions:
- com.example.PaymentDeclinedException
사용 예시
@Service
public class PaymentService {
@Retry(name = "payment-service", fallbackMethod = "paymentFallback")
@CircuitBreaker(name = "payment-service") // Retry + Circuit Breaker 조합
public PaymentResult processPayment(PaymentRequest request) {
return paymentClient.process(request);
}
private PaymentResult paymentFallback(PaymentRequest request, Throwable t) {
log.error("Payment failed after retries: {}", t.getMessage());
// 결제 실패 처리: 대기열에 넣거나 오류 반환
return PaymentResult.failed("결제 서비스가 일시적으로 불가합니다.");
}
}
Retry + Exponential Backoff 흐름
1회 시도 → 실패
500ms 대기
2회 시도 → 실패
1000ms 대기 (500 × 2)
3회 시도 → 실패
→ 최종 실패, fallback 실행
재시도마다 대기 시간이 증가 → 외부 서비스에 과부하를 주지 않음
Jitter 추가 권장: 여러 인스턴스가 동시에 재시도하는 Thunder Herd 방지
Bulkhead
개념
한 서비스 호출이 스레드/세마포어를 독점해 다른 서비스 호출까지 막히는 상황을 방지한다.
비유: 선박의 격벽(Bulkhead). 한 구획에 물이 들어와도 격벽이 다른 구획으로 번지는 것을 막는다.
스레드 풀 방식 (ThreadPoolBulkhead)
resilience4j:
thread-pool-bulkhead:
instances:
slow-external-api:
# 스레드 풀 크기
max-thread-pool-size: 10
# 핵심 스레드 수
core-thread-pool-size: 5
# 대기 큐 용량
queue-capacity: 20
# 유휴 스레드 유지 시간
keep-alive-duration: 20ms
세마포어 방식 (SemaphoreBulkhead)
resilience4j:
bulkhead:
instances:
inventory-service:
# 동시 호출 허용 수
max-concurrent-calls: 20
# 포화 시 대기 시간 (0이면 즉시 거부)
max-wait-duration: 100ms
@Service
public class InventoryService {
@Bulkhead(name = "inventory-service", type = Bulkhead.Type.SEMAPHORE)
public InventoryDto getInventory(Long productId) {
return inventoryClient.getInventory(productId);
}
@Bulkhead(name = "slow-external-api", type = Bulkhead.Type.THREADPOOL)
@CircuitBreaker(name = "slow-external-api")
public CompletableFuture<ExternalData> callSlowApi(String param) {
return CompletableFuture.supplyAsync(() ->
externalApiClient.getData(param)
);
}
}
Rate Limiter
개념
단위 시간당 최대 요청 수를 제한한다. 외부 API 쿼터를 지키거나, 내부 서비스 보호에 사용한다.
설정
resilience4j:
ratelimiter:
instances:
external-api:
# 갱신 주기 (이 기간마다 허용 횟수 리셋)
limit-refresh-period: 1s
# 주기당 허용 요청 수
limit-for-period: 100
# 허용 대기 시간 (초과 시 RateLimiterException)
timeout-duration: 500ms
@Service
public class ExternalApiService {
@RateLimiter(name = "external-api", fallbackMethod = "rateLimitFallback")
public ApiResponse callExternalApi(String query) {
return externalApiClient.query(query);
}
private ApiResponse rateLimitFallback(String query, RequestNotPermitted ex) {
log.warn("Rate limit exceeded for query: {}", query);
return ApiResponse.rateLimited("요청이 너무 많습니다. 잠시 후 재시도하세요.");
}
}
TimeLimiter
개념
비동기 호출에 타임아웃을 적용한다. 응답이 느린 서비스가 스레드를 무한정 점유하지 못하도록 한다.
resilience4j:
timelimiter:
instances:
report-service:
# 타임아웃 시간
timeout-duration: 3s
# 타임아웃 시 Future 취소 여부
cancel-running-future: true
@Service
public class ReportService {
@TimeLimiter(name = "report-service", fallbackMethod = "reportFallback")
@CircuitBreaker(name = "report-service")
public CompletableFuture<Report> generateReport(ReportRequest request) {
return CompletableFuture.supplyAsync(() ->
reportClient.generate(request) // 3초 초과 시 TimeoutException
);
}
private CompletableFuture<Report> reportFallback(
ReportRequest request, Throwable t) {
log.warn("Report generation timed out: {}", t.getMessage());
return CompletableFuture.completedFuture(
Report.cached(request.getId())
);
}
}
어노테이션 우선순위 조합
여러 어노테이션을 함께 쓸 때 실행 순서가 중요하다.
@Retry(name = "service") // 4. 가장 바깥: 전체를 재시도
@CircuitBreaker(name = "service") // 3. 회로 차단
@RateLimiter(name = "service") // 2. 속도 제한
@Bulkhead(name = "service") // 1. 가장 안쪽: 동시성 제어
public Result callService(Request request) {
return client.call(request);
}
실행 순서 (안쪽 → 바깥쪽):
요청 → Bulkhead → RateLimiter → CircuitBreaker → Retry → 실제 호출
graph LR
REQ[요청] --> BH[Bulkhead\n동시성 제한]
BH --> RL[Rate Limiter\n속도 제한]
RL --> CB[Circuit Breaker\n회로 차단]
CB --> RT[Retry\n재시도]
RT --> SVC[실제 서비스 호출]
SVC -->|실패| RT
RT -->|최대 재시도 초과| CB
CB -->|실패율 임계치 초과| OPEN[OPEN 상태\nfallback 실행]
프로그래매틱 방식
어노테이션 없이 직접 제어할 수 있다.
@Service
public class OrderService {
private final CircuitBreakerRegistry circuitBreakerRegistry;
private final RetryRegistry retryRegistry;
public OrderService(CircuitBreakerRegistry cbRegistry,
RetryRegistry retryRegistry) {
this.circuitBreakerRegistry = cbRegistry;
this.retryRegistry = retryRegistry;
}
public OrderDto createOrder(OrderRequest request) {
CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("inventory");
Retry retry = retryRegistry.retry("inventory");
// 데코레이터 패턴으로 조합
Supplier<InventoryDto> inventorySupplier = CircuitBreaker
.decorateSupplier(cb, () -> inventoryClient.check(request.getProductId()));
Supplier<InventoryDto> retryableSupplier = Retry
.decorateSupplier(retry, inventorySupplier);
try {
InventoryDto inventory = retryableSupplier.get();
return processOrder(request, inventory);
} catch (CallNotPermittedException e) {
// Circuit Breaker OPEN 상태
return OrderDto.queued(request);
}
}
}
이벤트 리스너
Circuit Breaker 상태 변화를 실시간으로 감지해 알림을 보낼 수 있다.
@Component
public class CircuitBreakerEventListener {
private final CircuitBreakerRegistry circuitBreakerRegistry;
private final AlertService alertService;
@PostConstruct
public void subscribeEvents() {
CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("user-service");
cb.getEventPublisher()
.onStateTransition(event -> {
log.warn("Circuit Breaker state changed: {} -> {}",
event.getStateTransition().getFromState(),
event.getStateTransition().getToState());
if (event.getStateTransition().getToState() == CircuitBreaker.State.OPEN) {
// Slack/PagerDuty 알림 발송
alertService.sendAlert("Circuit Breaker OPEN: user-service");
}
})
.onFailureRateExceeded(event ->
log.error("Failure rate exceeded: {}%", event.getFailureRate())
)
.onSlowCallRateExceeded(event ->
log.warn("Slow call rate exceeded: {}%", event.getSlowCallRate())
);
}
}
극한 시나리오
시나리오 1: 연쇄 장애 (Cascading Failure) 방어
상황: Payment Service가 느려짐 (3초 응답)
Circuit Breaker 없이:
Order Service → Payment 호출 스레드 점유
→ Order Service 스레드 풀 고갈
→ Order Service도 응답 불가
→ API Gateway도 타임아웃
→ 전체 시스템 다운
Circuit Breaker + Bulkhead 있을 때:
Payment Service 느려짐
→ TimeLimiter로 3초 후 타임아웃
→ Circuit Breaker: 실패율 50% 초과 → OPEN
→ 이후 Payment 요청은 즉시 fallback 반환
→ Order Service 스레드 해방 → 정상 동작 유지
→ 30초 후 HALF_OPEN → Payment 회복 테스트
시나리오 2: Circuit Breaker 튜닝
문제: Circuit Breaker가 너무 민감해 자주 OPEN됨
증상: 일시적 오류에도 서킷이 열려 정상 요청도 차단
튜닝 방법:
1. minimum-number-of-calls 늘리기 (최소 30회 이상 관찰)
2. failure-rate-threshold 높이기 (50% → 70%)
3. slow-call-duration-threshold 늘리기 (1s → 5s)
4. wait-duration-in-open-state 조정 (30s → 60s)
문제: Circuit Breaker가 너무 둔감해 장애가 전파됨
튜닝 방법:
1. sliding-window-size 줄이기
2. failure-rate-threshold 낮추기
3. minimum-number-of-calls 줄이기
시나리오 3: Fallback 전략 설계
// 계층적 fallback 전략
@CircuitBreaker(name = "product-service", fallbackMethod = "getProductFromCache")
public ProductDto getProduct(Long productId) {
return productServiceClient.getProduct(productId);
}
// 1차 fallback: 캐시에서 조회
private ProductDto getProductFromCache(Long productId, Exception e) {
return productCache.get(productId)
.orElseGet(() -> getProductFromDB(productId, e));
}
// 2차 fallback: DB에서 직접 조회
private ProductDto getProductFromDB(Long productId, Exception e) {
try {
return productRepository.findById(productId)
.map(ProductDto::fromEntity)
.orElseGet(() -> getProductDefault(productId, e));
} catch (Exception dbException) {
return getProductDefault(productId, dbException);
}
}
// 3차 fallback: 기본값 반환
private ProductDto getProductDefault(Long productId, Exception e) {
log.error("All fallbacks exhausted for productId: {}", productId, e);
return ProductDto.unavailable(productId);
}
Actuator 메트릭 및 모니터링
management:
health:
circuitbreakers:
enabled: true
endpoints:
web:
exposure:
include: health, metrics, circuitbreakers, retries
metrics:
tags:
application: ${spring.application.name}
주요 메트릭 (Prometheus/Grafana):
resilience4j_circuitbreaker_state Circuit Breaker 상태 (0=CLOSED, 1=OPEN, 2=HALF_OPEN)
resilience4j_circuitbreaker_failure_rate 실패율
resilience4j_circuitbreaker_calls_total 총 호출 수
resilience4j_retry_calls_total 재시도 호출 수
resilience4j_bulkhead_available_concurrent_calls 사용 가능한 동시 호출 슬롯
resilience4j_ratelimiter_available_permissions 남은 요청 허용 수