Jinnie devlog

외부 시스템 연동 지연, 장애, 실패 대응 (Failure-Ready Systems) 본문

교육

외부 시스템 연동 지연, 장애, 실패 대응 (Failure-Ready Systems)

Jinnnie 2025. 8. 22. 12:12

회사에서 업무를 하다 보면 외부 시스템과의 수많은 인터페이스를 경험하게 된다. 나는 통계 시스템을 담당하고 있는데, 하드웨어 장비, 통합인증, 인사 시스템 등으로 부터 데이터를 받아 통계를 생성하게 된다.

물론 결제와 같이 실시간으로 장애를 대응해야 하는 것들은 아니지만 인터페이스가 불가능 한 상태가 되거나 배치가 제 시간에 작동하지 않으면 통계가 틀어질 수 있어서 매일 아침 실패한 배치가 없는지 확인하고 있다.

 

커머스 시스템의 상품 주문-결제 과정에서는 사용자가 물건을 선택하여 주문을 진행하고, 결제까지의 프로세스를 타게 된다.

주문(Order) 과정 안에 결제(Payment) 로직이 껴있다 보니, 결제(PG 연동)가 실패하게 되었을 때 그에 대한 처리가 잘 안되어있다면 결제는 되지 않았는데 주문이 성공하거나 결제가 끝나지 않아 주문 상태가 계속 PENDING 되는 등 많은 문제가 발생할 것이다.

🚧 실무에서 겪는 문제

PG 서버가 일시적으로 느려지거나, 아예 응답을 주지 않는 상황이 종종 발생합니다. 이때 클라이언트가 끝까지 기다리면, 해당 요청은 스레드를 점유한 채 대기 상태로 남게 됩니다. 이런 요청이 수십~수백 개 쌓이면, 애플리케이션 전체가 마비될 수 있습니다. 외부 시스템(PG 등)이 응답을 지연시키거나 멈추는 경우, 요청을 끝까지 기다리면 스레드나 커넥션이 점유된 채로 대기하게 됩니다. 이런 요청이 누적되면 전체 시스템이 느려지거나 멈추게 되며, 장애가 외부에서 시작됐더라도 결국 내부 시스템 전체로 확산됩니다.

 

위와 같은 상황을 대비하기 위해 e커머스 프로젝트에 아래의 항목들을 적용해보았다.

  • Circuit Breaker
  • Timeout & Retry
  • Fallback 처리
  • Reconcile

Timeout

feign:
  httpclient:
    enabled: true
  client:
    config:
      pgClient:
        connectTimeout: 1000
        readTimeout: 2500
        loggerLevel: basic

 

Feign 클라이언트에 요청 제한 시간을 둬 무한 대기 차단

 connectTimeout=1000ms, readTimeout=2500ms → 타임아웃 발생 시 SocketTimeoutException.

 

Retry & Circuit Breaker

  retry:
    aspect-order: 2
    instances:
      pg:
        maxAttempts: 3
        waitDuration: 200ms
@Retry(name="pg") @CircuitBreaker(name="pg")
public PgPaymentDto getPaymentByTx(String transactionId) { ... }
  • Retry(Resilience4j): 타임아웃/네트워크 예외 등에서 최대 3회 재시도 후 실패 시 Fallback으로 넘어감.
  • Circuit Breaker(Resilience4j): 실패율 50%/윈도우 20, open 10s → 열리면 즉시 Fallback.
  • OPEN 상태에서는 즉시 예외(빠른 실패)로 전환되어 시스템 자원 보호
  • 내부적으로 Timeout→Retry→Circuit Breaker→Fallback이 단계적으로 작동.

Fallback 처리

@Retry(name="pg", fallbackMethod = "requestPaymentFallback")
public CreatePaymentResponse requestPayment(...)

private CreatePaymentResponse requestPaymentFallback(CreatePaymentRequest req, Throwable t) {
    return new CreatePaymentResponse(null, "RETRY_LATER");
}
  • 재시도 모두 실패하거나 Cercuit Breaker가 열려 CallNotPermittedException이 발생해도 최종적으로 @Retry의 fallbackMethod가 호출됨.
  • 예외를 발생시키는게 아니라 “확정 보류” 신호를 올려보낸다. 실제 확정은 콜백/리컨실에서 한다.

Reconcile(리컨실 보정)

@Scheduled(fixedDelayString = "${payments.recon.fixed-delay}")
@Transactional
public void run() {
    LocalDateTime cutoff = LocalDateTime.now().minusSeconds(10);
    List<PaymentEntity> targets = paymentRepository.findReconTargets(cutoff, batchSize);

    for (PaymentEntity payment : targets) {
        if (payment.getPgTxId() == null) continue;

        var dto = gateway.getPaymentByTx(payment.getPgTxId()); // <-- 여기서 다시 Adapter 호출(Timeout/Retry/CB 적용)
        if (dto == null || dto.status() == null) continue;

        switch (dto.status()) {
            case "APPROVED" -> { ... confirmApproved(...); publish(PaymentApprovedEvent) ... }
            case "DECLINED", "CANCELED", "EXPIRED" -> { ... confirmDeclined(...); publish(PaymentDeclinedEvent) ... }
            default -> log.debug("pending ... status={}", dto.status());
        }
    }
}
  • 미확정 결제를 주기적으로 재조회해 최종 상태를 확정한다.
  • 대상 선별: 최근 10초 내 변경 제외, 배치 크기 제한
  • 어댑터 Fallback이 던진 UNKNOWN/RETRY_LATER 신호 때문에 결제가 미확정 상태로 남음
  • 리컨실이 그 미확정들을 findReconTargets(...)로 뽑아 getPaymentByTx(...)를 다시 호출 → 성공 시 최종 확정, 실패 시 다음 주기

전체 결제 흐름

  1. 클라이언트 → PaymentFacade.create()
  2. Facade → PgFeignAdapter.requestPayment()
    • Timeout → Retry → Circuit Breaker 보호
  3. 분기
    • 성공: pgTxId 수신 → 결제는 REQUESTED(미확정) 으로 저장
    • 실패: Fallback(RETRY_LATER) → 확정 보류
  4. PG 콜백 수신 시
    • APPROVED → confirmApproved + PaymentApprovedEvent
    • DECLINED → confirmDeclined + PaymentDeclinedEvent
      (이미 확정이면 무시 = 멱등)
  5. 리컨실(주기 배치)
    • 미확정 건만 뽑아 getPaymentByTx() 재조회 → 성공 시 최종 확정, 아니면 다음 주기

 

더 생각해볼 것들

특정 상품에 대한 결제를 진행한다고 가정할 때, 주문 요청을 할 당시 재고를 미리 선점 한 상태에서 결제가 이루어져야 하는 지 아니면 결제까지 완료된 이후에 재고차감이 되어야 하는지 궁금하였는데, 어떤 서비스냐에 따라 다르게 생각해야 한다고 한다.

 

예를 들어서 티켓팅이나 좌석 예매 같은 경우 주문 창에 들어가면 일정 시간동안 내 재고와 자리가 선점된 것 처럼 볼 수 있는데 이런 경우들이 재고를 미리 차감시킨 후 결제가 이루어지지 않으면 다시 재고를 원복시키는 경우이고, 일반적인 상품들의 경우는 딱히 선점할 필요가 없으니 결제 시점에 재고를 차감시켜도 큰 무리가 없을 것이다.

사용자 관점에서는 물론 결제 후에 재고가 없어서 결제가 취소되면 기분이 안 좋겠지만, 회사 입장에서는 일단 돈을 받아내야 한다고 말씀해주신 부분이 재밌었다. 맞는 말이다! 

이번에는 결제 완료 시에 재고, 포인트가 차감되는 방법으로 구현을 했지만 추후에 재고를 예약(선점) 하는 과정도 구현해 보면 좋을 것 같다.