카테고리 없음

PG사가 응답하지 않을 때: 테스트 코드로 보는 Circuit Breaker

yeonsu00 2025. 12. 5. 03:35

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 도입의 어려움 중 하나가 "진짜 동작하는지 확인하기 어렵다"인 것 같다. 

그래서 테스트를 통해 각 상태 전이를 강제로 유발해서 검증해야 한다. 

 

검증해야 할 테스트 시나리오는 아래과 같다. 

  1. CLOSED : 평소에는 정상적으로 호출되는가
  2. CLOSED -> OPEN : 실패율이 치솟으면 차단되는가
  3. OPEN (Fallback) : 차단된 상태에서 요청 시, 실제 PG 호출 없이 바로 Fallback이 동작하는가
  4. 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 상태로 돌아가는지 확인한다.