들어가면서 이번 글에서는 지난 외부 서비스 장애에 대응하는 방법에서 소개드렸던 장애 전파를 막아주는 서킷 브레이커를 실제 프로젝트에 적용해 보겠습니다. 서킷 브레이커의 소개는 지난 글에서 다루었기 때문에 동작원리와 소개는 간단히 설명한 후 바로 적용해 보겠습니다.
현재 코드
현재 OpenFeign을 사용하여 외부 API를 요청하고 있습니다. 또한 Resilience4J 모듈 중 하나인 Retry를 사용하여 재시도 로직을 추가하였습니다.
@FeignClient(
name = "TossPaymentClient",
url = "https://api.tosspayments.com/v1/payments"
)
public interface TossPaymentClient {
@Retry(name = TOSS_PAYMENT_RETRY)
@PostMapping(value = "/confirm")
PaymentResponse confirmPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody TossPaymentConfirmRequest request
);
@Retry(name = TOSS_PAYMENT_RETRY)
@GetMapping(value = "/{paymentKey}")
PaymentResponse fetchPaymentInfo(
@PathVariable(name = "paymentKey") String paymentKey
);
@Retry(name = TOSS_PAYMENT_RETRY)
@PostMapping(value = "/{paymentKey}/cancel")
void refundPayment(
@PathVariable(name = "paymentKey") String paymentKey,
@RequestBody TossPaymentRefundRequest request
);
}
하지만 현재 코드에서 외부 서비스의 장애가 발생하여 재시도 마저 다 실패할 경우 외부 서비스의 장애가 전파되는 현상이 되게 됩니다. 그렇기 때문에 서킷 브레이커를 발생해 일정 비율 이상 실패할 경우 요청을 보내지 않게 해주는 서킷 브레이커를 적용해 보겠습니다.
Resilience4J 서킷 브레이커 (Circuit Breaker)
Resilience4J의 서킷 브레이커는 CLOSED, OPEN, HALF OPEN으로 이루어진 상태가 존재합니다.
서킷 브레이커는 슬라이딩 윈도우 방식을 사용해 호출 결과를 저장하고 집계합니다. 집계 방식은 2가지 방식이 존재합니다.
- 카운트 기반 슬라이딩 윈도우 : 마지막 N개의 호출 결과를 집계합니다.
- 시간 기반 슬라이딩 윈도우 : 마지막 N초의 호출 결과를 집계합니다.
FORCED_OPEN과 DISABLED
Resilience4J의 서킷 브레이커는 특수한 상태 2가지를 더 지원합니다. FORCED_OPEN과 DISABLED 상태입니다.
FORCED_OPEN 상태는 서킷 브레이커가 항상 OPNE 되어 있는 상태입니다. 반면에 DISABLED 상태는 서킷 브레이커가 항상 CLOSED 되어 있는 상태입니다. 이 2가지 상태에서는 서킷 브레이커의 상태가 변경되지 않으며, 메트릭도 기록되지 않습니다. 이러한 상태를 변경하기 위해서는 상태 전환을 트리거하거나 서킷브레이커를 재설정해야 합니다.
서킷 브레이커 구성
서킷 브레이커는 공식 문서에서 확인할 수 있습니다. 정리하면 아래와 같습니다.
Config property | Default value | Description |
failureRateThreshold | 50 | 실패한 요청의 임계값을 백분율로 구성 |
slowCallRateThreshold | 100 | 지연된 요청의 임계치를 설정 |
slowCallDurationThreshold | 60000 [ms] | 지연된 요청의 기준 시간 설정 |
permittedNumberOfCallsInHalfOpenState | 10 | Half Open 상태에서 몇개의 요청을 통해 상태 전환을 판단할지 설정 |
maxWaitDurationHlafOpenState | 0 [ms] | Open 상태로 전환되기 전 Half Open 상테에 머무룰 수 있는 시간 |
slidingWindowType | COUNT_BASED | 집계에 사용되는 슬라이딩 윈도우를 설정 |
slidingWindowSize | 100 | 요청 결과를 기록하는데 사용하는 슬라이딩 윈도우의 크기 |
minimumNumberOfCalls | 100 | 오류나 실패율을 계싼하기 전 필요한 최소 요청 수 |
waitDurationOpenState | 60000 [ms] | Open 상태에서 Half Open 상태로 전환되기 전 기다려야하는 시간 |
automaticTransitionFromOpenToHalfOpenEnabled | false | true로 설정하면 서킷 브레이커 상태가 자동으로 Half Open 상태로 전환 |
추가로 실패와 성공 예외를 설정할 수 있는 속성들도 있지만 공식 문서에서 확인할 수 있습니다.
Resilience4J 서킷 브레이커 적용하기
그럼 이제 실제 서비스에 서킷 브레이커를 적용해 보겠습니다.
build.gradle 및 application.yml 설정
먼저 서킷 브레이커 모듈을 사용하기 위해 의존성을 추가해 주도록 하겠습니다.
implementation "org.springframework.boot:spring-boot-starter-aop"
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
aop 의존성을 추가한 이유는 서킷 브레이커 모듈에서 지정한 예외가 발생했을 경우 fallbackMethod를 실행해야 하기 때문입니다.
다음으로 application.yml 설정을 해주겠습니다.
resilience4j:
circuitbreaker:
configs:
default:
sliding-window-type: COUNT_BASED
registerHealthIndicator: true
slidingWindowSize: 5
minimumNumberOfCalls: 3
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
waitDurationInOpenState: 5s
failureRateThreshold: 30
eventConsumerBufferSize: 10
recordFailurePredicate: com.jelly.zzirit.global.config.CircuitRecordFailurePredicate
instances:
tossPaymentClient:
baseConfig: default
application.yml 뿐만 아니라 직접 Config 클래스를 만들어서 설정해 줄 수 있습니다. 이 설정은 자신의 환경에 맞게 설정하는 것이 중요합니다. 필자의 경우 결제는 굉장히 중요한 요소이기 때문에 실패율이 30%만 되어도 서킷을 오픈하도록 설정하였습니다.
서킷 브레이커 적용
이제 설정한 서킷 브레이커를 외부 API를 호출하는 곳에서 적용해 보겠습니다. 서킷 브레이커를 적용하는 방법은 어노테이션을 이용하는 방법과 코드로 구현하는 방법이 존재하는데 필자는 어노테이션을 사용하여 적용하였습니다.
어노테이션을 사용한 이유는 먼저 코드로 구현할 경우 서킷 브레이커를 직접 주입받고 함수형 인터페이스를 사용해서 코드를 작성해야 하기 때문에 코드의 양이 많아지고 중복 코드가 발생합니다. 반면에 어노테이션을 사용하면 name 속성과 fallbackMethod 속성만 설정해 주면 되기 때문에 훨씬 간단해집니다.
@FeignClient(
name = "TossPaymentClient",
url = "https://api.tosspayments.com/v1/payments"
)
public interface TossPaymentClient {
Logger log = LoggerFactory.getLogger(TossPaymentClient.class);
@Retry(name = TOSS_PAYMENT_RETRY)
@CircuitBreaker(name = "tossPaymentClient", fallbackMethod = "fallbackConfirm")
@PostMapping(value = "/confirm")
PaymentResponse confirmPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody TossPaymentConfirmRequest request
);
default PaymentResponse fallbackConfirm(String idempotencyKey, TossPaymentConfirmRequest request, Throwable t) {
log.error("Toss 결제 승인 요청 오류 : idempotencyKey = {}, paymentKey = {}, message = {}",
idempotencyKey, request.paymentKey(), t.getMessage());
throw new InvalidOrderException(BaseResponseStatus.TOSS_SERVER_FAILED);
}
default PaymentResponse fallbackConfirm(String idempotencyKey, TossPaymentConfirmRequest request, RetryableException e) {
log.error("Toss 결제 재시도 실패 : idempotencyKey = {}, paymentKey = {}, message = {}",
idempotencyKey, request.paymentKey(), e.getMessage());
throw new InvalidOrderException(BaseResponseStatus.TOSS_RETRY_FAILED);
}
}
여기서 @CircuitBreaker의 name은 yml에서 작성한 인스턴스 이름입니다. fallbackMethod는 서킷이 오픈되어 있을 때 실행할 메소드를 나타냅니다.
fallback 메소드는 작성하는 방법 간단합니다. @CircuitBreaker에 작성한 fallbackMethod와 똑같은 이름의 메소드를 만들면 됩니다.
하지면 여기서 주의해야 할 점이 존재합니다. 바로 파라미터와 반환 값의 타입이 일치해야 합니다. 즉 메소드 시그니처들이 모두 일치해야 합니다. 꼭 Throwable이나 Exception을 사용하지 않아도 되지만 같은 파라미터 타입을 받기는 해야 합니다.
정상적으로 서킷 브레이커와 fallback이 적용되었다면, 위 사진처럼 3번의 요청을 기준으로 실패율을 계산하고 30%가 넘으면 서킷을 열어 예외 메시지를 던져주었습니다.
우선순위 설정
필자가 원하는 플로우 아래와 같습니다.
- 요청 결과를 응답받지 못하는 경우 특정 시간 동안 n번 재시도
- 실패율이 기준치를 넘어가면 서킷이 오픈
위 사진에서는 정삭적으로 재시도와 서킷 브레이커가 작동하지만 별도의 우선순위를 정하지 않으면 서킷 브레이커의 우선순위가 재시도보다 높기 때문에 재시도를 진행하지 않고 바로 서킷 브레이커만 동작하게 됩니다. Resilience4J 전체 모듈의 우선순위는 아래와 같습니다.
- TargerFunction
- BulkHead
- TimeLimiter
- RateLimiter
- CircuitBreaker
- Retry
위 우선순위로 인해 재시도가 동작하지 않기 때문에 yml에서 우선순위를 설정해 주었습니다.
resilience4j:
circuitbreaker:
circuit-breaker-aspect-order: 1
retry:
retry-aspect-order: 2
그 결과 재시도가 정상적으로 서킷 브레이커보다 먼저 동작하였으며 재시도 실패율이 일정 기준을 넘어가면 서킷이 오픈되어 다른 요청은 시도조차 하지 않도록 동작하게 되었습니다.
'Spirng' 카테고리의 다른 글
[Spring] Resilience4J Retry 적용 및 재시도 요청 분산시키기 (0) | 2025.06.23 |
---|---|
[Spring] Redis 캐시로 성능 개선하기 (1) | 2025.06.13 |
[Spring] 어드민 수정 동시성 제어하기 (With. JPA 락) (0) | 2025.06.08 |
[Spring] N + 1 문제를 위한 쿼리 카운터 개발 (0) | 2025.05.22 |
[Docs] Spring RestDocs와 SwaggerUI 함께 사용하기 (0) | 2025.05.16 |