Loopers

PG가 터져도 우리 서비스는 멀쩡해야 한다 🔥

그zi운아이 2025. 8. 22. 15:28

PG가 터져도 우리 서비스는 멀쩡해야 한다 🔥

Resilience4j로 결제 시스템 장애 방어선 구축한 썰

TL;DR

PG 시뮬레이터 연동하다가 외부 시스템 장애로 전체 서비스 마비 경험. Resilience4j의 Circuit Breaker, Retry, Bulkhead, Timeout 패턴으로 방어선 구축하고, 이벤트 기반 보상 트랜잭션과 스케줄러 복구로 완전체 만든 실전기. "PG 하나 죽어도 주문은 계속 받아야 한다"


🚨 사건의 발단: PG 하나가 터지니까 전체가 다운됐다

이번 6주차 과제에서 PG 시뮬레이터를 붙이면서 처음으로 "외부 의존성의 무서움"을 체감했습니다.

PG 시뮬레이터 스펙 (현실적으로 잔인함)

📊 PG 시뮬레이터 장애 시나리오
- 요청 성공 확률: 60% (40%는 그냥 실패)
- 요청 지연: 100ms ~ 500ms
- 처리 지연: 1s ~ 5s  
- 처리 결과: 성공 70%, 한도초과 20%, 카드오류 10%

 

 

처음엔 "그냥 RestTemplate으로 호출하면 되는 거 아니야?" 싶었는데...

초기 구현 (무방비 상태)

// 😱 이렇게 했다가 서비스 전체가 터짐
@Service
public class PaymentService {
    
    public void processPayment(PaymentRequest request) {
        // PG 호출 (타임아웃 설정 없음)
        PgResponse response = restTemplate.postForObject(pgUrl, request, PgResponse.class);
        
        if ("SUCCESS".equals(response.getStatus())) {
            // 성공 처리
        } else {
            // 실패 처리 
        }
        // PG가 죽으면 여기서 전체 주문 프로세스 멈춤 💥
    }
}

예상 참사들

  1. PG 응답 5초 지연 → 사용자가 결제 버튼 연타 → 중복 결제 발생
  2. PG 서버 500 에러 → 우리 주문 시스템도 500 에러로 전파
  3. 콜백 누락 → 결제는 성공했는데 주문은 PENDING 상태로 방치
  4. 재고 차감 후 PG 실패 → 재고는 없어졌는데 주문은 실패

이때 깨달았습니다: "외부 시스템은 언제든 죽을 수 있다. 그걸 전제로 설계해야 한다."


🛡 1차 방어선: Resilience4j 패턴 적용

타임아웃부터 잡자 (무한 대기는 곧 죽음)

# resilience4j.yml - 첫 번째 생명선
resilience4j:
  timelimiter:
    instances:
      pg-client:
        timeout-duration: 5s                # 5초 이상 기다리지 않음
        cancel-running-future: true
// FeignClient 레벨에서도 이중 보험
@Bean
public Request.Options pgRequestOptions() {
    return new Request.Options(
        1000, TimeUnit.MILLISECONDS,  // 연결 타임아웃 1초
        3000, TimeUnit.MILLISECONDS,  // 읽기 타임아웃 3초  
        true
    );
}

5초로 정한 이유:

  • PG 시뮬레이터 최대 응답시간이 5초
  • 사용자 경험상 5초 이상은 너무 길음
  • 재시도까지 고려하면 적절한 기준점

재시도 전략 (하지만 무작정 재시도하면 안 됨)

retry:
  instances:
    pg-client:
      max-attempts: 3                     # 최대 3회 (원본 1회 + 재시도 2회)
      wait-duration: 1s                   # 기본 1초 간격
      exponential-backoff-multiplier: 2   # 지수 백오프 (1s → 2s → 4s)
      randomized-delay-factor: 0.2        # ±20% 지터로 요청 분산
      retry-exceptions:
        - java.net.SocketTimeoutException
        - java.net.ConnectException  
        - feign.FeignException.InternalServerError
        - feign.FeignException.ServiceUnavailable
      ignore-exceptions:
        - feign.FeignException.BadRequest   # 4xx는 재시도 의미 없음
        - feign.FeignException.Unauthorized

 

지수 백오프 + 지터를 선택한 핵심 이유:

  • 단순 재시도는 장애난 PG 서버를 더 괴롭힘
  • 지수 백오프로 PG 서버 복구 시간 확보
  • 지터로 여러 서버의 재시도가 동시에 몰리지 않게 분산

Circuit Breaker (언제 포기할지 정하기)

circuitbreaker:
  instances:
    pg-client:
      failure-rate-threshold: 50          # 실패율 50% 초과시 Circuit Open
      slow-call-rate-threshold: 70        # 느린 호출 70% 초과도 비정상으로 판단
      slow-call-duration-threshold: 3s    # 3초 이상을 느린 호출로 간주
      minimum-number-of-calls: 5          # 최소 5회 호출 후 상태 판단
      sliding-window-size: 10             # 최근 10개 호출 기준으로 판단
      wait-duration-in-open-state: 30s    # Circuit Open 후 30초 대기
      permitted-number-of-calls-in-half-open-state: 3  # Half-Open에서 3회 시도

 

50% 실패율을 기준으로 정한 이유:

  • PG 시뮬레이터 성공률이 60%라서 정상 상황에서도 40% 실패
  • 너무 낮게 설정하면 정상 상황에서도 Circuit이 열림
  • 50%를 넘으면 "정말 비정상"이라고 판단할 수 있는 기준점

Bulkhead (격리로 전체 시스템 보호)

bulkhead:
  instances:
    pg-client:
      max-concurrent-calls: 20            # PG 호출 전용 스레드 풀 20개
      max-wait-duration: 1s               # 1초 이상 대기하지 않음
// 모든 Resilience 패턴을 조합한 최종 형태
@Component
@RequiredArgsConstructor  
public class PgPaymentGateway {

    private final PgClient pgClient;

    @Retry(name = "pg-client")
    @Bulkhead(name = "pg-client")
    @CircuitBreaker(name = "pg-client", fallbackMethod = "requestPaymentFallback")
    public PgPaymentResponse requestPayment(String userId, PgPaymentRequest req) {
        PgApiResponse<PgPaymentResponse> res = pgClient.requestPayment(userId, req);
        
        if (!"success".equals(res.meta().result())) {
            throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 호출 실패");
        }
        
        return res.data();
    }

    // 🚨 여기가 핵심! Fallback에서 어떻게 처리할 것인가?
    private PgPaymentResponse requestPaymentFallback(String userId, PgPaymentRequest req, Throwable ex) {
        log.error("PG 결제 요청 완전 실패 - userId: {}, orderId: {}, error: {}", 
            userId, req.orderId(), ex.getMessage());
        throw new CoreException(ErrorType.INTERNAL_ERROR, "결제 시스템 일시 장애");
    }
}

🔄 2차 방어선: 이벤트 기반 보상 트랜잭션

Resilience 패턴으로 장애를 막았지만, 결제 실패시 이미 차감된 재고와 쿠폰을 어떻게 되돌릴 것인가?

초기 접근법 (강결합 지옥)

// ❌ 이렇게 했다가 유지보수 지옥 경험
@Service
public class PaymentService {
    
    private final StockService stockService;
    private final CouponService couponService;
    
    public void processPayment(PaymentCommand cmd) {
        try {
            // 1. 재고 차감
            stockService.reserve(cmd.getProductId(), cmd.getQuantity());
            
            // 2. 쿠폰 사용
            couponService.use(cmd.getCouponId(), cmd.getUserId());
            
            // 3. PG 호출
            pgGateway.requestPayment(cmd);
            
        } catch (Exception e) {
            // 🚨 여기서 수동 롤백... 강결합의 시작
            try {
                stockService.restore(cmd.getProductId(), cmd.getQuantity());
                couponService.release(cmd.getCouponId(), cmd.getUserId());
            } catch (Exception rollbackEx) {
                // 롤백도 실패하면? 😱
                log.error("롤백도 실패했다... 어떡하지?", rollbackEx);
            }
        }
    }
}

문제점들:

  • 결제 로직이 재고, 쿠폰 서비스에 강하게 결합
  • 롤백 순서 관리의 복잡성
  • 롤백 자체가 실패하면 데이터 불일치
  • 새로운 보상 로직 추가할 때마다 결제 코드 수정 필요

이벤트 기반 보상으로 해결

// ✅ 이벤트 발행으로 결합도 제거
@Component
public class CardPaymentStrategy implements PaymentStrategy {
    
    private final ApplicationEventPublisher eventPublisher;
    
    @Override
    public void pay(PaymentCommand cmd) {
        Long paymentId = paymentService.createInitiatedPayment(cmd);
        
        try {
            PgPaymentResponse resp = pgGateway.requestPayment(cmd.userId().value(), request);
            paymentService.updateToProcessing(paymentId, resp.transactionKey());
            
        } catch (Exception e) {
            // 실패시 이벤트만 발행하고 끝
            publishFailedEvent(cmd, paymentId, "PG 요청 실패", e.getMessage());
        }
    }
    
    private void publishFailedEvent(PaymentCommand cmd, Long paymentId, String reason, String detail) {
        paymentService.updateToFailed(paymentId, reason);
        
        // 이벤트 발행 (강결합 제거!)
        eventPublisher.publishEvent(new PaymentFailedEvent(
            paymentId, cmd.orderId(), cmd.userId(), reason, detail
        ));
    }
}
// ✅ 별도 핸들러에서 보상 처리 (관심사 분리)
@Component
@RequiredArgsConstructor
public class OrderEventHandler {

    private final OrderTransactionService orderTransactionService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void onPaymentFailed(PaymentFailedEvent event) {
        log.info("결제 실패 이벤트 수신 - orderId: {}, reason: {}", event.orderId(), event.reason());
        orderTransactionService.handlePaymentFailed(event);
    }
}

@Service
@RequiredArgsConstructor
public class OrderTransactionService {

    private final CompensationService compensationService;
    private final OrderService orderService;

    @Transactional(propagation = Propagation.REQUIRES_NEW)  // 🔥 이게 핵심!
    public void handlePaymentFailed(PaymentFailedEvent event) {
        Order order = orderService.getOrder(event.orderId());
        
        if (order == null || order.getStatus().isFinal()) return;  // 이미 처리됨
        if (!order.getStatus().canPayFail()) return;              // 상태 불일치
        
        // 주문 상태 변경
        orderService.markPaymentFailed(order, event.reason());
        
        // 보상 트랜잭션 실행
        compensationService.reverseFor(order);
        
        log.info("결제 실패 보상 처리 완료 - orderId: {}", event.orderId());
    }
}
// ✅ 실제 보상 로직 (확장 가능한 구조)
@Service
@RequiredArgsConstructor
public class CompensationService {

    private final ProductService productService;
    private final CouponService couponService;
    private final OrderService orderService;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void reverseFor(Order order) {
        
        // 1. 재고 복원
        restoreStock(order.getId());
        
        // 2. 쿠폰 복원 (사용된 쿠폰이 있을 때만)
        if (order.getUsedCouponId() != null) {
            releaseCoupons(order.getUserId(), order.getUsedCouponId());
        }
        
        log.info("보상 트랜잭션 완료 - orderId: {}", order.getId());
    }

    private void restoreStock(Long orderId) {
        List<OrderItem> orderItems = orderService.getOrder(orderId).getItems();
        for (OrderItem item : orderItems) {
            productService.restoreStock(item.getProductId(), item.getQuantity());
        }
    }

    private void releaseCoupons(UserId userId, Long usedCouponId) {
        couponService.releaseSpecificCoupon(usedCouponId, userId.value());
    }
}

REQUIRES_NEW 트랜잭션을 선택한 핵심 이유:

  • 결제 실패 처리와 보상 처리를 완전히 분리
  • 보상 처리 실패가 결제 실패 기록을 롤백시키지 않음
  • 각각 독립적으로 재시도 가능
  • 트랜잭션 경계 명확히 분리로 데드락 위험 감소

⏰ 3차 방어선: 스케줄러 기반 상태 복구

가장 까다로운 문제: 비동기 콜백의 불확실성

PG 시스템 특성상 결제 요청 → 즉시 응답(PENDING) → 나중에 콜백으로 최종 결과 전달하는 구조입니다. 그런데 네트워크 이슈로 콜백이 안 오면? 결제는 성공했는데 우리 시스템은 모르는 상황 발생.

주기적 상태 동기화로 해결

@Service
@RequiredArgsConstructor
public class PaymentScheduler {

    private final PaymentStatePort paymentStatePort;
    private final PgPaymentGateway pgGateway;
    private final ApplicationEventPublisher eventPublisher;

    @Scheduled(cron = "${payment.scheduler.sweep.cron:0 */5 * * * *}")  // 5분마다
    @Transactional
    public void sweepPending() {
        log.info("🔍 PENDING 결제 상태 복구 시작");

        List<Payment> pendingPayments = paymentStatePort.loadPending();
        if (pendingPayments.isEmpty()) {
            log.debug("복구할 PENDING 결제 없음");
            return;
        }

        log.info("복구 대상 PENDING 결제: {}건", pendingPayments.size());

        for (Payment payment : pendingPayments) {
            try {
                // PG API로 실제 상태 확인
                PgPaymentStatusResponse statusResponse = pgGateway.getPaymentByOrderId(
                    payment.getUserId().value(),
                    "ORDER_" + payment.getOrderId()
                );

                String status = statusResponse.status() == null ? "" : statusResponse.status().toUpperCase();
                String txKey = safeTransactionKey(statusResponse.transactionKey(), payment.getTransactionKey());

                switch (status) {
                    case "SUCCESS" -> {
                        paymentStatePort.updateToCompleted(payment.getId(), txKey);
                        eventPublisher.publishEvent(PaymentCompletedEvent.of(
                            payment.getId(), payment.getOrderId(), payment.getUserId(), txKey));
                        log.info("✅ PENDING → SUCCESS 복구 - paymentId={}, txKey={}", payment.getId(), txKey);
                    }
                    case "FAILED" -> {
                        String reason = safeReason(statusResponse.reason(), "PG 상태조회: 실패");
                        paymentStatePort.updateToFailed(payment.getId(), reason);
                        eventPublisher.publishEvent(PaymentFailedEvent.of(
                            payment.getId(), payment.getOrderId(), payment.getUserId(), reason, txKey));
                        log.info("❌ PENDING → FAILED 복구 - paymentId={}, reason={}", payment.getId(), reason);
                    }
                    case "PENDING" -> {
                        log.debug("⏳ 여전히 PENDING - paymentId={}", payment.getId());
                        // 타임아웃 체크 로직 추가 가능
                    }
                    default -> {
                        log.warn("⚠️ 알 수 없는 PG 상태 - paymentId={}, status={}", payment.getId(), status);
                    }
                }

            } catch (CoreException ex) {
                // PG 조회도 실패하면 해당 결제를 실패 처리
                String reason = "PG 상태 조회 실패: " + ex.getMessage();
                paymentStatePort.updateToFailed(payment.getId(), reason);
                eventPublisher.publishEvent(PaymentFailedEvent.of(
                    payment.getId(), payment.getOrderId(), payment.getUserId(), reason, null));
                log.error("🚨 PG 조회 실패로 PENDING → FAILED 처리 - paymentId={}", payment.getId(), ex);
            }
        }

        log.info("✅ PENDING 결제 상태 복구 완료");
    }

    private static String safeTransactionKey(String pgTxKey, String currentTxKey) {
        return (pgTxKey != null && !pgTxKey.isBlank()) ? pgTxKey : currentTxKey;
    }

    private static String safeReason(String pgReason, String fallback) {
        return (pgReason == null || pgReason.isBlank()) ? fallback : pgReason;
    }
}

 

5분 주기로 정한 근거:

  • 사용자 경험상 결제 후 5분이면 충분히 기다릴 수 있는 시간
  • PG 시스템 부하를 고려한 적절한 간격 (너무 자주 호출하면 PG에서 차단 가능)
  • 실시간성보다는 최종 일관성 확보가 목표

🧪 실전 테스트: 정말 방어가 되나?

Circuit Breaker 동작 확인

@Test
@DisplayName("PG 서버 장애 시 Circuit Breaker 동작 검증")
void circuit_breaker_opens_on_pg_failure() {
    // given: PG가 계속 500 에러를 뱉는 상황
    when(pgClient.requestPayment(any(), any()))
        .thenThrow(new FeignException.InternalServerError("PG 서버 장애", 
            Request.create(Request.HttpMethod.POST, "", Map.of(), null, Charset.defaultCharset(), null), 
            null, null));

    // when: 연속으로 여러 번 결제 시도 
    for (int i = 0; i < 10; i++) {
        assertThrows(CoreException.class, () -> 
            cardPaymentStrategy.pay(createPaymentCommand()));
    }

    // then: Circuit이 열리고 Fallback으로 빠르게 실패
    // 더 이상 PG 호출 없이 즉시 실패 응답
}

@Test  
@DisplayName("PG 타임아웃 시 재시도 후 최종 실패")
void pg_timeout_triggers_retry_then_fails() {
    // given: PG가 타임아웃을 발생시키는 상황
    when(pgClient.requestPayment(any(), any()))
        .thenThrow(new SocketTimeoutException("PG 타임아웃"));

    // when: 결제 시도
    assertThrows(CoreException.class, () -> 
        cardPaymentStrategy.pay(createPaymentCommand()));

    // then: 재시도가 3회 발생했는지 확인
    verify(pgClient, times(3)).requestPayment(any(), any());
}

보상 트랜잭션 동작 확인

@Test
@DisplayName("결제 실패 시 재고와 쿠폰이 정상 복원되는가")
void payment_failure_triggers_compensation() {
    // given: 재고와 쿠폰을 사용한 주문
    Order order = createOrderWithStockAndCoupon();
    PaymentFailedEvent event = new PaymentFailedEvent(1L, order.getId(), order.getUserId(), "PG 실패", null);

    // when: 결제 실패 이벤트 발생
    orderEventHandler.onPaymentFailed(event);

    // then: 보상 트랜잭션이 실행되었는지 확인
    verify(productService).restoreStock(order.getItems().get(0).getProductId(), 
        order.getItems().get(0).getQuantity());
    verify(couponService).releaseSpecificCoupon(order.getUsedCouponId(), order.getUserId().value());
}

스케줄러 복구 동작 확인

@Test
@DisplayName("콜백 누락된 SUCCESS 결제가 스케줄러로 복구되는가")
void scheduler_recovers_missing_callback() {
    // given: PENDING 상태인 결제 (콜백이 안온 상황)
    Payment pendingPayment = createPendingPayment();
    when(paymentStatePort.loadPending()).thenReturn(List.of(pendingPayment));
    
    // PG 조회하면 실제로는 SUCCESS 상태
    when(pgGateway.getPaymentByOrderId(any(), any()))
        .thenReturn(new PgPaymentStatusResponse("SUCCESS", "tx_123", null));

    // when: 스케줄러 실행
    paymentScheduler.sweepPending();

    // then: SUCCESS로 상태 변경되고 완료 이벤트 발행
    verify(paymentStatePort).updateToCompleted(pendingPayment.getId(), "tx_123");
    verify(eventPublisher).publishEvent(any(PaymentCompletedEvent.class));
}

 


💡 실무 관점에서 배운 것들

1. Fallback 전략은 단순 실패가 아니다

현재는 Fallback에서 그냥 예외만 던지고 있는데, 실무에서는 더 정교한 전략이 필요합니다:

// 🤔 현재: 단순 실패 처리
private PgPaymentResponse requestPaymentFallback(String userId, PgPaymentRequest req, Throwable ex) {
    throw new CoreException(ErrorType.INTERNAL_ERROR, "결제 시스템 일시 장애");
}

// 🚀 실무에서 고려할 수 있는 전략들:
// 1. 다른 PG사로 자동 라우팅
// 2. 대체 결제 수단 제안 (포인트 결제 등)
// 3. 오프라인 결제 안내
// 4. 나중에 처리 예약 (결제 대기 큐)

2. 보상 트랜잭션의 실패는 어떻게 처리할 것인가?

현재는 보상 트랜잭션이 실패하면 로그만 남기는데, 실무에서는 더 견고한 처리가 필요:

// 고려해볼 점들:
// 1. Dead Letter Queue로 실패한 보상 작업 저장
// 2. 수동 보상 처리를 위한 관리자 도구
// 3. 보상 작업의 멱등성 보장 (여러 번 실행해도 안전)
// 4. 부분 보상 실패 시 처리 방안 (재고는 복원됐는데 쿠폰은 실패)

3. 모니터링과 알림이 생명선

아무리 Resilience 패턴을 잘 적용해도 장애 상황을 빠르게 감지하고 대응해야 합니다:

# 실무에서 모니터링해야 할 지표들
circuit_breaker_state: CLOSED/OPEN/HALF_OPEN 상태 변화
retry_attempts: 재시도 횟수 및 성공률 추이  
pending_payment_count: PENDING 상태 결제 건수
compensation_failure_rate: 보상 트랜잭션 실패율
pg_response_time_p95: PG 응답시간 95퍼센타일
callback_missing_rate: 콜백 누락률

4. 설정값에 정답은 없다 (상황에 맞게)

Resilience4j 설정은 시스템 특성과 비즈니스 요구사항에 따라 달라져야 합니다:

// 내가 고려한 기준들:
// - 타임아웃: 사용자 경험 vs PG 응답시간 특성
// - 재시도 횟수: 네트워크 복구 가능성 vs 전체 응답시간
// - Circuit Breaker 임계값: 일시적 장애 vs 시스템 장애 구분점
// - 벌크헤드 크기: 동시 결제 처리량 vs 시스템 리소스

🏁 마무리: 장애는 언제나 온다

이번 PG 연동 과제를 통해 가장 크게 깨달은 점은 "외부 시스템 장애는 선택이 아니라 필수"라는 것입니다.

🎯 적용한 패턴들의 효과

  • Circuit Breaker: PG 장애가 전체 시스템을 마비시키지 않음
  • Retry + Exponential Backoff: 일시적 네트워크 이슈 극복
  • Bulkhead: PG 호출이 다른 기능에 영향 주지 않음
  • 이벤트 기반 보상: 결제 실패 시 안전한 상태 복구
  • 스케줄러 복구: 콜백 누락 상황에서도 최종 일관성 보장