왜 Resilience4j인가? — 마이크로서비스의 장애 전파를 코드로 차단
마이크로서비스 환경에서 하나의 외부 API가 느려지면, 해당 API를 호출하는 서비스의 스레드가 고갈되고, 연쇄적으로 전체 시스템이 마비된다. 이것이 Cascading Failure(연쇄 장애)다. Resilience4j는 Netflix Hystrix의 후속으로, 경량·함수형·Spring Boot 네이티브 지원을 특징으로 하는 장애 격리(fault tolerance) 라이브러리다.
Resilience4j는 네 가지 핵심 패턴을 제공한다: Circuit Breaker(서킷 브레이커), Retry(재시도), Rate Limiter(호출 속도 제한), Bulkhead(격벽). 각각 독립적으로 쓸 수도 있고, Spring AOP 심화에서 다룬 것처럼 어노테이션 기반으로 조합할 수도 있다.
1. 의존성 설정과 Spring Boot 통합
<!-- pom.xml -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Actuator 연동 (선택) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
spring-boot-starter-aop가 반드시 필요하다. Resilience4j의 @CircuitBreaker, @Retry 등의 어노테이션은 Spring AOP 프록시를 통해 동작하므로, @Transactional 심화에서 다룬 self-invocation 함정이 동일하게 적용된다.
2. Circuit Breaker — 장애 서비스 호출을 자동으로 차단
2-1. 상태 머신: CLOSED → OPEN → HALF_OPEN
Circuit Breaker는 전기 회로의 차단기처럼 동작한다:
- CLOSED (정상): 모든 호출이 통과. 실패율을 슬라이딩 윈도우로 추적
- OPEN (차단): 호출을 즉시 거부하고 fallback 실행. 설정된 대기 시간 후 HALF_OPEN으로 전환
- HALF_OPEN (시험): 설정된 수만큼 호출을 허용하여 복구 여부 판단. 성공하면 CLOSED, 실패하면 다시 OPEN
2-2. YAML 설정
# application.yml
resilience4j:
circuitbreaker:
configs:
default: # 기본 설정 (모든 인스턴스에 적용)
slidingWindowType: COUNT_BASED # COUNT_BASED 또는 TIME_BASED
slidingWindowSize: 10 # 최근 10건 기준
minimumNumberOfCalls: 5 # 최소 5건 이상이어야 실패율 계산
failureRateThreshold: 50 # 실패율 50% 이상이면 OPEN
slowCallRateThreshold: 80 # 느린 호출 80% 이상이면 OPEN
slowCallDurationThreshold: 3s # 3초 이상이면 "느린 호출"
waitDurationInOpenState: 30s # OPEN → HALF_OPEN 대기 시간
permittedNumberOfCallsInHalfOpenState: 3 # HALF_OPEN에서 시험 호출 수
automaticTransitionFromOpenToHalfOpenEnabled: true
recordExceptions: # 실패로 기록할 예외
- java.io.IOException
- java.util.concurrent.TimeoutException
- org.springframework.web.client.HttpServerErrorException
ignoreExceptions: # 무시할 예외 (실패 카운트 안 함)
- com.example.BusinessException
instances:
paymentService: # 인스턴스별 오버라이드
baseConfig: default
failureRateThreshold: 30 # 결제는 더 민감하게
waitDurationInOpenState: 60s
inventoryService:
baseConfig: default
slidingWindowType: TIME_BASED
slidingWindowSize: 60 # 최근 60초 기준
2-3. 어노테이션 적용
@Service
@RequiredArgsConstructor
public class PaymentGateway {
private final PaymentClient paymentClient;
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
public PaymentResult charge(PaymentRequest req) {
return paymentClient.charge(req); // 외부 결제 API 호출
}
// fallback 메서드: 원본과 같은 파라미터 + Exception 파라미터
private PaymentResult paymentFallback(PaymentRequest req, Exception ex) {
log.warn("Payment circuit open, fallback triggered: {}", ex.getMessage());
// 전략 1: 기본 응답 반환
return PaymentResult.pending("서킷 브레이커 활성화, 잠시 후 재시도하세요");
// 전략 2: 큐에 넣고 비동기 처리
// paymentQueue.enqueue(req);
// return PaymentResult.queued();
}
// 특정 예외 타입별 fallback (더 구체적인 것이 우선)
private PaymentResult paymentFallback(PaymentRequest req, CallNotPermittedException ex) {
log.error("Circuit is OPEN for paymentService");
throw new ServiceUnavailableException("결제 서비스 일시 중단");
}
}
2-4. COUNT_BASED vs TIME_BASED 슬라이딩 윈도우
| 기준 | COUNT_BASED | TIME_BASED |
|---|---|---|
| slidingWindowSize 의미 | 최근 N건 | 최근 N초 |
| 트래픽 적을 때 | 오래된 실패도 윈도우에 남음 | 시간 경과 시 자연 소멸 |
| 트래픽 많을 때 | 빠르게 윈도우 갱신 | 메모리 사용량 증가 가능 |
| 권장 사용처 | 일정한 트래픽의 동기 API | 트래픽 변동이 큰 서비스 |
3. Retry — 일시적 장애의 자동 복구
네트워크 순단, DNS 타임아웃, 502 Bad Gateway 같은 일시적(transient) 장애는 재시도만으로 해결되는 경우가 많다. Resilience4j Retry는 재시도 횟수, 대기 시간, 백오프 전략을 선언적으로 설정한다.
resilience4j:
retry:
configs:
default:
maxAttempts: 3 # 최초 호출 포함 총 3회
waitDuration: 500ms # 재시도 간 대기
enableExponentialBackoff: true # 지수 백오프 활성화
exponentialBackoffMultiplier: 2 # 500ms → 1s → 2s
exponentialMaxWaitDuration: 5s # 최대 대기 시간
retryExceptions: # 재시도 대상 예외
- java.io.IOException
- java.util.concurrent.TimeoutException
ignoreExceptions: # 재시도하지 않을 예외
- com.example.InvalidRequestException # 400 에러는 재시도해도 같은 결과
instances:
inventoryService:
baseConfig: default
maxAttempts: 5 # 재고 서비스는 5회까지
@Service
public class InventoryService {
@Retry(name = "inventoryService", fallbackMethod = "checkStockFallback")
public StockInfo checkStock(String sku) {
return inventoryClient.getStock(sku);
}
private StockInfo checkStockFallback(String sku, Exception ex) {
log.warn("Inventory check failed after retries for SKU={}: {}", sku, ex.getMessage());
return StockInfo.unknown(sku); // 재고 불명 상태 반환
}
}
멱등성(Idempotency) 주의: Retry는 읽기(GET) 또는 멱등한 쓰기에만 적용해야 한다. 결제 API처럼 멱등하지 않은 호출을 재시도하면 중복 결제가 발생한다. 멱등 키(idempotency key)를 지원하는 API가 아니라면 Retry 대신 Circuit Breaker만 사용하라.
4. Rate Limiter — 호출 속도 제한으로 과부하 방지
외부 API에 초당 호출 제한(rate limit)이 있거나, 내부 서비스를 과부하로부터 보호하려면 Rate Limiter를 쓴다.
resilience4j:
ratelimiter:
configs:
default:
limitForPeriod: 50 # 주기당 최대 50건
limitRefreshPeriod: 1s # 매 1초마다 리셋
timeoutDuration: 2s # 허용될 때까지 최대 2초 대기
instances:
smsService:
baseConfig: default
limitForPeriod: 10 # SMS는 초당 10건 제한
timeoutDuration: 5s
@Service
public class NotificationService {
@RateLimiter(name = "smsService", fallbackMethod = "smsFallback")
public void sendSms(String phone, String message) {
smsClient.send(phone, message);
}
private void sendSms(String phone, String message, RequestNotPermitted ex) {
log.warn("SMS rate limit exceeded, queueing for later");
smsQueue.enqueue(new SmsRequest(phone, message)); // 큐에 넣어 나중에 전송
}
}
주의: Resilience4j의 Rate Limiter는 단일 인스턴스 기준이다. Kubernetes에서 3개 Pod이 돌면 실제 호출은 3배가 된다. 클러스터 전역 rate limiting이 필요하면 Redis 기반의 분산 Rate Limiter(Bucket4j + Redis 등)를 써야 한다.
5. Bulkhead — 동시 호출 수 격리
Bulkhead(격벽)는 선박의 수밀 격벽처럼, 하나의 서비스 호출이 전체 스레드 풀을 잡아먹지 않도록 동시 호출 수를 제한한다. 느린 외부 API가 모든 Tomcat 스레드를 점유하는 것을 방지한다.
resilience4j:
bulkhead:
configs:
default:
maxConcurrentCalls: 25 # 동시 최대 25건
maxWaitDuration: 500ms # 슬롯 확보 대기 시간
instances:
reportService:
baseConfig: default
maxConcurrentCalls: 5 # 리포트 생성은 무거우므로 5건만
thread-pool-bulkhead: # 별도 스레드 풀 격리
configs:
default:
maxThreadPoolSize: 10
coreThreadPoolSize: 5
queueCapacity: 20
keepAliveDuration: 100ms
@Service
public class ReportService {
// Semaphore Bulkhead (기본) — 호출 스레드에서 실행, 동시 수만 제한
@Bulkhead(name = "reportService", fallbackMethod = "reportFallback")
public Report generateReport(ReportRequest req) {
return reportEngine.generate(req); // 무거운 리포트 생성
}
// Thread Pool Bulkhead — 별도 스레드 풀에서 실행 (CompletableFuture 반환 필수)
@Bulkhead(name = "reportService", type = Bulkhead.Type.THREADPOOL,
fallbackMethod = "reportFallbackAsync")
public CompletableFuture<Report> generateReportAsync(ReportRequest req) {
return CompletableFuture.completedFuture(reportEngine.generate(req));
}
private Report reportFallback(ReportRequest req, BulkheadFullException ex) {
throw new ServiceBusyException("리포트 생성 요청이 많습니다. 잠시 후 재시도하세요.");
}
}
| 타입 | 격리 방식 | 반환 타입 | 사용 시점 |
|---|---|---|---|
| Semaphore | 호출 스레드 그대로, 세마포어로 동시 수 제한 | 동기 | 대부분의 경우 (기본값) |
| Thread Pool | 별도 스레드 풀에서 실행 | CompletableFuture | 호출 스레드를 완전히 보호해야 할 때 |
6. 패턴 조합 — 어노테이션 실행 순서
Resilience4j 어노테이션은 고정된 실행 순서를 따른다. 순서를 이해하지 못하면 의도치 않은 동작이 발생한다.
// 실행 순서 (바깥 → 안쪽):
// Retry → CircuitBreaker → RateLimiter → Bulkhead → 실제 메서드
@Retry(name = "paymentService") // 4. 전체를 재시도
@CircuitBreaker(name = "paymentService") // 3. 실패율 추적 & 차단
@RateLimiter(name = "paymentService") // 2. 호출 속도 제한
@Bulkhead(name = "paymentService") // 1. 동시 호출 제한 (가장 안쪽)
public PaymentResult charge(PaymentRequest req) {
return paymentClient.charge(req);
}
왜 이 순서인가?
- Bulkhead가 가장 안쪽: 실제 호출의 동시 수를 직접 제한
- RateLimiter: Bulkhead에 들어가기 전 초당 호출 수 제한
- CircuitBreaker: 실패율을 추적하고 임계치 초과 시 차단
- Retry가 가장 바깥: CircuitBreaker의
CallNotPermittedException까지 포함해 재시도할 수 있음
# 실행 순서 커스터마이징 (application.yml)
resilience4j:
circuitbreaker:
circuitBreakerAspectOrder: 1 # 숫자가 작을수록 바깥쪽
retry:
retryAspectOrder: 2
ratelimiter:
rateLimiterAspectOrder: 3
bulkhead:
bulkheadAspectOrder: 4
7. Fallback 설계 패턴 — 그냥 null 반환하지 마라
Fallback은 장애 시 사용자 경험을 유지하는 핵심이다. “에러가 났으니 null 반환”은 최악의 패턴이다.
// 패턴 1: 캐시된 데이터 반환
private ProductInfo getProductFallback(Long id, Exception ex) {
return productCache.getIfPresent(id); // 로컬 캐시에서 stale 데이터 반환
}
// 패턴 2: 기본값 반환
private ExchangeRate getExchangeRateFallback(String currency, Exception ex) {
return ExchangeRate.lastKnown(currency); // 마지막 알려진 환율
}
// 패턴 3: 큐에 넣고 비동기 처리
private void sendNotificationFallback(Notification noti, Exception ex) {
retryQueue.enqueue(noti); // 나중에 재전송
log.warn("Notification queued for retry: {}", noti.getId());
}
// 패턴 4: 대체 서비스 호출
private PaymentResult chargeFallback(PaymentRequest req, Exception ex) {
return backupPaymentGateway.charge(req); // 보조 결제 게이트웨이
}
// 패턴 5: 의미 있는 에러 반환 (클라이언트가 처리 가능하도록)
private PaymentResult chargeFallback(PaymentRequest req, CallNotPermittedException ex) {
throw new ServiceTemporarilyUnavailableException(
"결제 서비스 일시 중단",
Duration.ofSeconds(30) // retry-after 힌트 포함
);
}
8. Actuator & Prometheus 모니터링
Resilience4j는 Spring Boot Actuator와 자동 통합되며, Micrometer 커스텀 메트릭으로 Prometheus에 자동 노출된다.
# application.yml
management:
endpoints:
web:
exposure:
include: health, circuitbreakers, circuitbreakerevents, retries, ratelimiters
health:
circuitbreakers:
enabled: true # health endpoint에 서킷 상태 포함
metrics:
distribution:
percentiles-histogram:
resilience4j.circuitbreaker.calls: true
Prometheus 핵심 메트릭
# Circuit Breaker 상태 (0=CLOSED, 1=OPEN, 2=HALF_OPEN)
resilience4j_circuitbreaker_state{name="paymentService"} 0
# 호출 결과별 카운트
rate(resilience4j_circuitbreaker_calls_total{name="paymentService",kind="successful"}[5m])
rate(resilience4j_circuitbreaker_calls_total{name="paymentService",kind="failed"}[5m])
# 실패율 (%)
resilience4j_circuitbreaker_failure_rate{name="paymentService"}
# Retry 횟수
rate(resilience4j_retry_calls_total{name="inventoryService",kind="successful_with_retry"}[5m])
# Bulkhead 가용 슬롯
resilience4j_bulkhead_available_concurrent_calls{name="reportService"}
# Grafana Alert 예시: 서킷 OPEN 시 알림
resilience4j_circuitbreaker_state{name="paymentService"} == 1
9. 테스트 전략 — 장애 상황을 단위 테스트로 검증
@SpringBootTest
class PaymentGatewayResilienceTest {
@Autowired
private PaymentGateway paymentGateway;
@MockBean
private PaymentClient paymentClient;
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
@BeforeEach
void reset() {
circuitBreakerRegistry.circuitBreaker("paymentService").reset();
}
@Test
void 서킷_브레이커_OPEN_시_fallback_호출() {
// 실패율 임계치를 초과하도록 연속 실패 발생
when(paymentClient.charge(any()))
.thenThrow(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR));
// minimumNumberOfCalls만큼 호출하여 서킷 OPEN 유도
for (int i = 0; i < 10; i++) {
paymentGateway.charge(new PaymentRequest(...));
}
CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentService");
assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
// OPEN 상태에서 호출하면 fallback 반환
PaymentResult result = paymentGateway.charge(new PaymentRequest(...));
assertThat(result.getStatus()).isEqualTo("PENDING");
// 실제 클라이언트는 더 이상 호출되지 않음
verify(paymentClient, times(10)).charge(any());
}
@Test
void Retry_성공_시_최종_결과_반환() {
when(paymentClient.charge(any()))
.thenThrow(new IOException("Connection reset")) // 1차 실패
.thenThrow(new IOException("Connection reset")) // 2차 실패
.thenReturn(PaymentResult.success()); // 3차 성공
PaymentResult result = paymentGateway.charge(new PaymentRequest(...));
assertThat(result.isSuccess()).isTrue();
verify(paymentClient, times(3)).charge(any());
}
}
10. 운영 체크리스트
| 항목 | 권장 설정 | 위반 시 증상 |
|---|---|---|
| Circuit Breaker | 외부 API 호출에 필수 적용 | 느린 API가 전체 스레드 풀 고갈 → 연쇄 장애 |
| Retry 대상 | 멱등한 호출만 (GET, 멱등 키 POST) | 중복 결제, 중복 주문 |
| Exponential Backoff | Retry에 지수 백오프 + 최대 대기 설정 | 장애 서비스에 재시도 폭탄 |
| Fallback | 의미 있는 대체 응답 (캐시/기본값/큐잉) | 사용자에게 500 에러 노출 |
| 모니터링 | 서킷 상태 Grafana 알림 설정 | 서킷 OPEN을 뒤늦게 발견 |
| self-invocation | 어노테이션은 외부 빈에서만 호출 | Resilience4j 패턴이 무시됨 |
| Rate Limiter 분산 | K8s 환경에서 인스턴스 수 고려 | 총 호출이 외부 API 제한 초과 |
마무리 — 장애는 막는 것이 아니라 격리하는 것이다
마이크로서비스에서 외부 의존성 장애는 피할 수 없다. 중요한 것은 장애가 전파되지 않도록 격리하는 것이다. Circuit Breaker로 장애 서비스 호출을 차단하고, Retry로 일시적 장애를 자동 복구하며, Rate Limiter로 과부하를 방지하고, Bulkhead로 동시 호출을 격리하라.
Resilience4j의 네 가지 패턴을 올바른 순서(Retry → CircuitBreaker → RateLimiter → Bulkhead)로 조합하고, 각 패턴에 적절한 fallback을 설계하면, 부분 장애가 전체 시스템을 마비시키는 Cascading Failure를 선언적으로 차단할 수 있다. 특히 recordExceptions과 ignoreExceptions을 정밀하게 설정하여 비즈니스 예외(400 에러)와 인프라 예외(500, timeout)를 구분하는 것이 운영 수준의 핵심이다.