Resilience4j는 MSA에서 서비스의 신뢰성을 보장하기 위한 라이브러리다.
이 라이브러리는 서킷 브레이커, Rate Limiter, Retry, Bulkhead 등 다양한 패턴을 제공하여 외부 서비스나 자원에 의존하는 서비스들이 장애 상황에서도 안정적으로 작동할 수 있도록 돕는다.
이러한 이유로, 안정성은 물론 확장성과 경량성을 챙길 수 있는 Resilience4j를 선택하게 되었다.
📌 Resilience4j의 다양한 기능
1. Retry
실패한 요청을 일정 횟수만큼 재시도하는 패턴이다. 단순히 재시도하는 것뿐만 아니라, 재시도 간격을 설정하거나 점진적 대기 시간(Exponential Backoff) 을 적용하여 재시도 간 요청 부하를 줄일 수 있다. 외부 서비스나 자원에 일시적인 문제가 있을 때 바로 실패로 처리하지 않고, 정해진 횟수만큼 재시도하여 장애를 최소화할 수 있다.
2. Rate Limiting
특정 시간 동안 시스템이 처리할 수 있는 요청의 수를 제한하는 패턴이다. 시스템이 과부하에 빠지지 않도록 초당 혹은 분당 처리할 수 있는 최대 요청 수를 설정한다. 초과된 요청은 대기시키거나 거부할 수 있으며, 이는 주로 DDoS 공격이나 과도한 트래픽으로 인해 서비스가 불안정해지는 상황을 방지하기 위해 사용된다.
3. Bulkhead
시스템의 자원을 격리하여 한 부분에서 발생한 장애가 다른 부분에 영향을 미치지 않도록 하는 패턴이다. 예를 들어, 특정 서비스에 할당된 쓰레드 풀이나 연결 수를 제한함으로써 다른 서비스나 기능이 영향을 받지 않도록 보호할 수 있다. 이를 통해 서비스 간 격리성을 강화하여 시스템의 안정성을 높일 수 있으며, 하나의 서비스에서 장애가 발생해도 나머지 서비스는 정상적으로 작동할 수 있도록 보장한다.
build.gradle 파일에 Resilience4j 관련 의존성을 추가한다.
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
application.yml 파일에 서킷 브레이커의 동작 방식을 정의한다.
resilience4j:
circuitbreaker:
instances:
productServiceClient:
slidingWindowSize: 10
failureRateThreshold: 40
waitDurationInOpenState: 10000
permittedNumberOfCallsInHalfOpenState: 3
minimumNumberOfCalls: 10
eventConsumerBufferSize: 10
Feign 클라이언트에 서킷 브레이커를 적용하려면 @CircuitBreaker 어노테이션을 사용하면 된다. 각 메서드에 적용하며, 장애가 발생했을 경우 대체할 Fallback 메서드를 지정할 수 있다.
@FeignClient(name = "product-service")
public interface ProductServiceClient {
Logger logger = LoggerFactory.getLogger(ProductServiceClient.class);
@CircuitBreaker(name = "productServiceClient", fallbackMethod = "checkOptionExistsFallback")
@GetMapping("/product-service/options/{optionId}/exists")
ResponseEntity<Boolean> checkOptionExists(@PathVariable("optionId") Long optionId);
@CircuitBreaker(name = "productServiceClient", fallbackMethod = "getOptionStockFallback")
@GetMapping("/product-service/options/{optionId}/inventory")
ResponseEntity<Integer> getOptionStock(@PathVariable("optionId") Long optionId);
@CircuitBreaker(name = "productServiceClient", fallbackMethod = "getOptionDetailsFallback")
@GetMapping("/product-service/options/{optionId}/details")
ResponseEntity<OptionDetailDTO> getOptionDetails(
@PathVariable("optionId") Long optionId);
@CircuitBreaker(name = "productServiceClient", fallbackMethod = "getMaxPurchaseLimitFallback")
@GetMapping("/product-service/options/{optionId}/max-purchase-limit")
ResponseEntity<Integer> getMaxPurchaseLimit(
@PathVariable("optionId") Long optionId);
@CircuitBreaker(name = "productServiceClient", fallbackMethod = "updateOptionStockFallback")
@PutMapping("/product-service/options/{optionId}/inventory")
ResponseEntity<ResponseDTO<Void>> updateOptionStock(@PathVariable("optionId") Long optionId,
@RequestBody int stock);
// Fallback Methods
default ResponseEntity<Boolean> checkOptionExistsFallback(Long optionId,
Throwable throwable) {
logError(throwable);
return ResponseEntity.ok(false);
}
default ResponseEntity<Integer> getOptionStockFallback(Long optionId,
Throwable throwable) {
logError(throwable);
return ResponseEntity.ok(0);
}
default ResponseEntity<OptionDetailDTO> getOptionDetailsFallback(Long optionId,
Throwable throwable) {
logError(throwable);
OptionDetailDTO fallbackOption = OptionDetailDTO.builder()
.optionId(optionId)
.optionType("N/A")
.availability(false)
.stock(0)
.productId(-1L)
.productName("Unknown Product")
.build();
return ResponseEntity.ok(fallbackOption);
}
default ResponseEntity<Integer> getMaxPurchaseLimitFallback(Long optionId,
Throwable throwable) {
logError(throwable);
return ResponseEntity.ok(1);
}
default ResponseEntity<Void> updateOptionStockFallback(Long optionId, int stock,
Throwable throwable) {
logError(throwable);
return ResponseEntity.ok().build();
}
private void logError(Throwable throwable) {
if (throwable instanceof FeignException) {
logger.error("product-service에 문제 발생! fallback method 호출 : {}", throwable.getMessage());
} else {
logger.error("product-service 에러! {}", throwable.getMessage());
}
}
}
위 코드에서 @CircuitBreaker 어노테이션을 통해 productServiceClient 서킷 브레이커가 동작하도록 설정했다. 만약 장애가 발생하면 지정된 Fallback 메서드가 호출되며, 이를 통해 서비스의 안정성을 유지할 수 있다.
Fallback 메서드는 장애 상황에서 호출되는 대체 로직을 처리한다.
위 코드에서는, checkOptionExistsFallback 메서드를 통해 서비스가 정상적으로 작동하지 않더라도 기본적으로 false 값을 반환하도록 했다.
또한 Fallback 메서드 호출시 로그로 "product-service 에러!"를 남기도록 구현하였다.
📌 Fallback 메서드
단순히 장애 발생 시 예외를 처리하는 역할을 넘어, 서비스 복구 전까지 대체 경로를 제공하거나 캐시된 데이터를 반환하는 방식으로 활용할 수 있다.
이를 통해 사용자에게는 장애가 발생하지 않은 것처럼 보이게 할 수 있으며, 장애가 복구될 때까지 서비스의 연속성을 보장할 수 있다.
또한, Fallback 메서드 내부에서 장애 상황을 로그로 남겨, 시스템 복구 이후 장애 원인을 분석하는 데 도움을 줄 수 있다.
order-service와 통신하는 product-service를 내려 장애 상황을 연출하였다.
product-service에 문제가 생기자 503 에러가 난다.
우선 Spring Actuator 를 통해 서킷브레이커의 상태를 먼저 체크해보자.
어떠한 요청도 보내지 않았으니 서킷브레이커는 CLOSED 상태이다.
그럼 이제 실제로 예상대로 동작하는지 확인해보도록 하자
503에러를 내보내지 않고 잘 동작한다.
(해당 404에러는 order-service에 따로 예외처리를 해둔 것으로 정상적인 동작이다.)
서킷브레이커가 OPEN 상태로 전환되어 있는 것을 확인할 수 있다.
예상대로 HALF_OPEN 상태로 전환되었다.
이제 product-server를 다시 띄운 후, 요청 성공시 서킷브레이커 상태가 다시 CLOSED로 변환되는지 확인해보자.
이로써 product-service 장애 발생 시, 클라이언트는 Fallback 메서드를 통해 정상적인 응답을 받을 수 있으며, 서킷 브레이커를 통해 장애가 다른 서비스로 전파되지 않도록 요청을 차단하는 기능이 완성되었다.