외부 API로 portone 결제 시스템을 사용한다.
타임아웃, 재시도, 서킷 브레이커를 통해 외부 API의 사용을 논의하겠다.
외부 서비스에 장애가 발생하는 경우 우리 서비스의 다른 기능까지도 영향을 줄 수 있다.
쓰레드에서 영원히 외부 API의 호출을 기다릴 수는 없기 때문이다.
따라서 외부 서비스가 비정상적이라 판단될 때 외부 서비스를 기다리지말고 즉시 차단한다.
서킷 브레이커에는 Closed, Open, Half-Open의 3가지 상태가 있다.
전기 회로의 스위치를 생각하면 되는데, 스위치가 닫혀있다는 것은 즉, 회로가 연결되었다는 뜻이므로, Closed 상태는 연결이 되어있다는 상태라 할 수 있다.
서킷브레이커는 기본적으로는 외부 서비스와 연결된 상태(closed)로 시작한다.
그러다 연결 실패에 대한 기준점을 넘기기 시작하면, 상태를 open으로 바꾼다. 즉 더 통하지 않도록 스위치를 열어버린다. 즉 외부 서비스의 장애가 있다는 것으로 간주하고 빠른 실패(fail fast)를 발생시킨다.
half-open은 회로를 닫을까 열까 고민하는 단계이다. 일부 연결만 시도하고 회로를 닫을지 열지 결정한다.
이미 발명한 바퀴를 재발명할 필요가 있을까?
나는 아주 잘 굴러가는 Resilience4j라는 바퀴를 사용하고자 한다.
Resilience4j는 함수형 프로그래밍을 위한 경량 라이브러리이다.
서킷 브레이커 외에도 retry, Rate Limiter 등의 기능을 제공한다.
dependencies {
implementation "io.github.resilience4j:resilience4j-spring-boot3:${resilience4jVersion}"
}
Resilience4j는 슬라이딩 윈도우 기반으로 작동한다.
2개의 슬라이딩 윈도우가 존재하는데 두 방식 모두 Subtract-on-Evict 방식으로 관리한다
Subtract-on-Evict 방식이란?
들어오는 값을 더하고, 나가는 값을 빼는 방식으로 O(1)의 시간복잡도를 가지는 방식
예를 들어 [1,2,3,4,5]에서 window 크기가 3인 상태에서 [1,2,3]에서 [2,3,4]로 윈도우가 움직일 때, 새로 들어오는 값인 4를 더하고, 빠지는 값인 1을 빼고 이전의 윈도우 값과 더하는 방식이다. 즉 윈도우가 [1,3,6]에서 [3,6,9]로 변하게 된다.
앞서 말했듯 임계값 이상의 실패가 있으면 서킷브레이커는 열림 상태로 변경된다.
서킷브레이커가 OPEN 상태가 되면 CallNotPermittedException 예외를 바탕으로 거절한다.
CircuitBreakerRegistry를 원래 직접 설정해야하지만, 스프링부트에서 설정값을 통해 처리할 수 있도록 지원해준다.
https://resilience4j.readme.io/docs/circuitbreaker#create-and-configure-a-circuitbreaker
설정값에 대한 소개는 여기서 찾아볼 수 있다.
@RequiredArgsConstructor
@Component
public class PortOneClient {
private final PaymentClient paymentClient;
@CircuitBreaker(name = "portOne")
public CompletableFuture<Payment> getPayment(String paymentId){
return paymentClient.getPayment(paymentId);
}
@CircuitBreaker(name = "portOne")
public CompletableFuture<CancelPaymentResponse> cancelPayment(String paymentId, String reason){
return paymentClient.cancelPayment(
paymentId,
null, //amount
null, //taxFreeAmount
null, // vatAmount
reason,
null, // requester
null, //promotionDiscountRetaionOption
null, // currentCancellableAmount
null //refundAccount
);
}
}
sdk를 사용하는 부분에 걸어주었다.
Resilience4j는 예외를 기록해서 실패율에 반영한다
기본적으로 모든 예외를 실패로 간주한다.
그런데 sdk에서 발생하는 비즈니스 예외까지 실패할 필요는 없기 때문에 조치를 취해주어야한다
PortOneException을 ignore로 해주자
PortOneException을 상속받는 UnknownException은 어떻게 해야할까?
https://javadoc.io/doc/io.portone/server-sdk/latest/-port-one%20-server%20-s-d-k%20for%20-j-v-m/io.portone.sdk.server.errors/-unknown-exception/index.html
https://server-sdk-js.portone.io/classes/Errors.UnknownError.html
java sdk에는 설명이 아직 다 작성되지 않아서 js sdk의 문서를 참고하자면
주로 sdk나 서버 내부 오류로 인해서 발생하는 예외를 상정하는 것을 확인할 수 있다.
즉 서버 내부 오류로 인해서 호출이 실패했다면 서킷 브레이커를 열어야할까?
어쨌든 정상적인 로직을 수행할 수 없는 상태를 의미하기 때문에 여는 것이 맞다고 생각했다.
resilience4j:
circuitbreaker:
instances:
portOne:
# 1
slidingWindowSize: 10
failureRateThreshold: 50
# 2
waitDurationInOpenState: 10000
permittedNumberOfCallsInHalfOpenState: 3
# 3
slowCallDurationThreshold: 3000
# 4
recordExceptions:
- java.lang.Throwable
- io.portone.sdk.server.errors.UnknownException
ignoreExceptions:
- io.portone.sdk.server.errors.PortOneException