TL;DR
- 외부 시스템 장애로 인한 연쇄 장애를 막기 위해 Resilience4j를 활용한 Circuit Breaker 전략 도입
들어가며
PG와 같은 외부 시스템과의 연동은 불안정성을 가지고 있다. 네트워크 지연, 알 수 없는 타임아웃 등 다양한 장애 상황이 발생할 수 있다.
이때 우리의 서버가 무작정 응답을 기다리거나 계속해서 재요청을 보낸다면, 결국 전체 시스템의 장애로 이어질 수 있다.
이번 글에서는 Resilience4j를 사용해서 PG 결제 연동 시 발생할 수 있는 장애를 처리하는 방법과, 테스트 코드를 통해 Circuit Breaker가 어떻게 동작하는지에 대한 내용을 공유하고자 한다.
Resilience4j 설정
Circuit Breaker가 언제 열리고(Open), 언제 다시 닫힐지(Close) 결정하기 위해서 설정이 필요하다.
resilience4j:
# 1. Circuit Breaker 설정
circuitbreaker:
instances:
pgPayment:
registerHealthIndicator: true
slidingWindowSize: 20 # 최근 20건의 호출을 기준으로 판단
minimumNumberOfCalls: 10 # 최소 10건 이상 호출되어야 판단 시작
failureRateThreshold: 60 # 실패율 60% 도달 시 Open
waitDurationInOpenState: 30s # Open 상태에서 30초 대기 후 Half-Open 전환
permittedNumberOfCallsInHalfOpenState: 5 # Half-Open 상태에서 5번 확인
automaticTransitionFromOpenToHalfOpenEnabled: true
# 2. Time Limiter 설정
# 3. Retry 설정
- 슬라이딩 윈도우 : 최근 20건 중 50% 이상 실패하면 회로를 차단한다.
테스트 전략: 상태별 검증 시나리오
Circuit Breaker 도입의 어려움 중 하나가 "진짜 동작하는지 확인하기 어렵다"인 것 같다.
그래서 테스트를 통해 각 상태 전이를 강제로 유발해서 검증해야 한다.
검증해야 할 테스트 시나리오는 아래과 같다.
- CLOSED : 평소에는 정상적으로 호출되는가
- CLOSED -> OPEN : 실패율이 치솟으면 차단되는가
- OPEN (Fallback) : 차단된 상태에서 요청 시, 실제 PG 호출 없이 바로 Fallback이 동작하는가
- HALF-OPEN -> CLOSED : 일정 시간 후 일부 요청이 성공하면 다시 정상화되는가
테스트 코드 분석
Mockito와 Resilience4j의 CircuitBreakerRegistry를 활용해 테스트 코드를 작성했다.
테스트 시작 전: Circuit Breaker 초기화
테스트 간 간섭을 막기 위해 매 테스트 시작 전 Circuit Breaker 상태를 초기화해줬다.
이전 테스트가 다음 테스트에 영향을 주지 않도록, BeforeEach에서 초기화하고 강제로 Close 상태로 만들어 깨끗한 환경을 보장한다.
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
@BeforeEach
void setUp() {
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgPayment");
circuitBreaker.transitionToClosedState();
circuitBreaker.reset();
}
시나리오 1 : 정상 응답 시 Close 상태 유지
시스템이 정상적이라면 Circuit Breaker는 개입하면 안 된다.
이 테스트는 PG사가 정상 응답을 줄 때, Circuit Breaker가 상태 변화 없이 Close 상태를 유지하며 요청을 그대로 통과시키는지 확인한다.
@Test
@DisplayName("정상 응답 시 Circuit Breaker는 Close 상태를 유지한다")
void circuitBreakerStaysClosed_whenSuccess() throws Exception {
// given
PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
doReturn(successResponse)
.when(pgPaymentFeignClient)
.requestPayment(anyString(), any(PgPaymentDto.PgPaymentRequest.class));
// when
CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
PaymentResponse response = future.get(5, TimeUnit.SECONDS);
// then
assertThat(response.transactionKey()).isEqualTo("txn-key-123");
assertThat(response.status()).isEqualTo("PENDING");
verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
}
시나리오 2 : 실패율 임계치 초과 시 Open
설정된 슬라이딩 윈도우(20개)와 최소 호출 수(10개) 내에서 실패율이 60%를 넘는 상황을 테스트한다.
@Test
@DisplayName("설정: 슬라이딩 윈도우 20, 최소 호출 10, 실패율 60% - 10번 중 6번 실패하면 Open 상태")
void circuitBreakerOpens_whenFailureRateExceeds60Percent() throws Exception {
// given
PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
RuntimeException runtimeException = new RuntimeException("Internal Server Error");
doReturn(successResponse) // 1 - 성공
.doReturn(successResponse) // 2 - 성공
.doReturn(successResponse) // 3 - 성공
.doReturn(successResponse) // 4 - 성공
.doThrow(runtimeException) // 5 - 실패
.doThrow(runtimeException) // 6 - 실패
.doThrow(runtimeException) // 7 - 실패
.doThrow(runtimeException) // 8 - 실패
.doThrow(runtimeException) // 9 - 실패
.doThrow(runtimeException) // 10 - 실패
.when(pgPaymentFeignClient)
.requestPayment(anyString(), any());
// when
for (int i = 0; i < 10; i++) {
try {
pgPaymentClient.requestPayment(paymentRequest, userId).get(5, TimeUnit.SECONDS);
} catch (Exception e) {
}
}
// then
verify(pgPaymentFeignClient, times(10)).requestPayment(anyString(), any());
CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
PaymentResponse response = future.get(5, TimeUnit.SECONDS);
assertThat(response.transactionKey()).isNull();
assertThat(response.status()).isEqualTo("PENDING");
assertThat(response.reason()).contains("재시도 후에도 실패");
verify(pgPaymentFeignClient, times(10)).requestPayment(anyString(), any());
}
10번 호출 중 6번을 고의로 실패시킨 후,
11번 째 호출 시점에는 이미 Circuit Breaker가 Open 되었으므로, 실제 PG사(Mock)를 호출하지 않고 바로 fallback 메서드가 실행되는지 확인한다.
시나리오 3 : Half-Open 상태에서 성공 시 Close 상태로 전환
장애가 발생해서 Open 된 상태에서 일정 시간이 지나면 Half-Open 상태가 된다.
이때 들어온 요청이 성공하면 시스템이 복구된 것으로 간주한다.
@Test
@DisplayName("HalfOpen 상태에서 성공 시 Closed 상태로 전환된다")
void circuitBreakerTransitionsToClosed_whenHalfOpenSucceeds() throws Exception {
// given
PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
RuntimeException runtimeException = new RuntimeException("Internal Server Error");
doReturn(successResponse) // 1 - 성공
.doReturn(successResponse) // 2 - 성공
.doReturn(successResponse) // 3 - 성공
.doReturn(successResponse) // 4 - 성공
.doThrow(runtimeException) // 5 - 실패
.doThrow(runtimeException) // 6 - 실패
.doThrow(runtimeException) // 7 - 실패
.doThrow(runtimeException) // 8 - 실패
.doThrow(runtimeException) // 9 - 실패
.doThrow(runtimeException) // 10 - 실패
.when(pgPaymentFeignClient)
.requestPayment(anyString(), any());
for (int i = 0; i < 10; i++) {
try {
pgPaymentClient.requestPayment(paymentRequest, userId).get(5, TimeUnit.SECONDS);
} catch (Exception e) {
}
}
if (circuitBreakerRegistry != null) {
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgPayment");
circuitBreaker.transitionToHalfOpenState();
}
doReturn(successResponse)
.when(pgPaymentFeignClient)
.requestPayment(anyString(), any());
// when
CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
PaymentResponse response = future.get(5, TimeUnit.SECONDS);
// then
assertThat(response.transactionKey()).isEqualTo("txn-key-123");
assertThat(response.status()).isEqualTo("PENDING");
verify(pgPaymentFeignClient, times(11)).requestPayment(anyString(), any());
}
강제로 Half-Open 상태로 만든 뒤, 성공적인 요청을 보낸다.
요청이 성공했으므로 Circuit Breaker가 다시 Close 상태로 돌아와서 정상적으로 동작한다.
시나리오 4 : Half-Open 상태에서 실패 시 Open 상태로 다시 전환
Half-Open 상태에서 다시 요청이 실패한다면, 아직 외부 시스템이 복구되지 않은 것이다.
@Test
@DisplayName("HalfOpen 상태에서 실패 시 Open 상태로 다시 전환된다")
void circuitBreakerTransitionsToOpen_whenHalfOpenFails() throws Exception {
// given
PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
RuntimeException runtimeException = new RuntimeException("Internal Server Error");
doReturn(successResponse) // 1 - 성공
.doReturn(successResponse) // 2 - 성공
.doReturn(successResponse) // 3 - 성공
.doReturn(successResponse) // 4 - 성공
.doThrow(runtimeException) // 5 - 실패
.doThrow(runtimeException) // 6 - 실패
.doThrow(runtimeException) // 7 - 실패
.doThrow(runtimeException) // 8 - 실패
.doThrow(runtimeException) // 9 - 실패
.doThrow(runtimeException) // 10 - 실패
.when(pgPaymentFeignClient)
.requestPayment(anyString(), any());
for (int i = 0; i < 10; i++) {
try {
pgPaymentClient.requestPayment(paymentRequest, userId).get(5, TimeUnit.SECONDS);
} catch (Exception e) {
}
}
if (circuitBreakerRegistry != null) {
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgPayment");
circuitBreaker.transitionToHalfOpenState();
}
doThrow(runtimeException)
.when(pgPaymentFeignClient)
.requestPayment(anyString(), any());
// when
CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
PaymentResponse response = future.get(5, TimeUnit.SECONDS);
// then
assertThat(response.transactionKey()).isNull();
assertThat(response.status()).isEqualTo("PENDING");
assertThat(response.reason()).contains("재시도 후에도 실패");
verify(pgPaymentFeignClient, times(11)).requestPayment(anyString(), any());
}
Half-Open 상태에서 요청이 실패하도록 설정하고, 다시 Open 상태로 돌아가는지 확인한다.