결제 실패시 재처리 시스템 구현

김재현·2025년 2월 9일
0

TIL

목록 보기
89/89

1. 문제 발생

현재 문제 상황


  • 예매시 좌석은 available 했지만 그 이후 과정(결제)에서 Fail하면 예매가 완전히 취소되어 해당 좌석을 다른 유저가 선점해버리는 문제 발생.
  • 따라서 유저는 유저 정보 입력부터 좌석 선택, 예매 시도까지 처음부터 다시 입력해야하는 문제.

2. 기능명세 정의

원인


  • 예매 시도 1트에 실패하면 예매 보류가 아닌 취소되어 상태가 리셋되어 버리기 때문에 문제 발생.

기능 명세


  • 전체 흐름

    1. 결제가 실패하면 관련 상태 ‘취소’가 아닌 ‘실패, 재시도 필요’ 로 변경, 유저에게 결제 실패 알림 발송

    2. 일정 시간 동안 해당 좌석은 해당 유저만 다시 결제 시도 할 수 있게 유지 (10분)

    3. 유저는 예매 상태를 조회하여 결제 실패를 확인 및 원인 파악

    4. 마일리지 충전 후 대기

    5. 시스템은 마일리지가 충분한지 확인 후 결제 재시도

    6. 예매 확정, 유저에게 결제 완료 알림 발송

  • 기능 명세

    1. 결제 실패 발생

      a. 실패 메세지를 Retry Queue에 등록

      • 재시도 횟수와 간격 정보(Exponential Backoff) 포함
      • 결제 실패 후 10분이 되면 마지막으로 재시도

      b. 유저에게 결제 실패 알림 발송

    2. Retry Queue에서 재시도
      a. Retry Worker가 Retry Queue에서 메시지를 꺼내어 결제 재시도
      b. 재시도 시, Exponential Backoff 방식에 따라 점진적으로 재시도 간격을 늘려감

    3. 결제 실패 시 취소

      a. 일정 횟수의 재시도 후에도 결제 실패가 해결되지 않으면, 결제 취소 처리

      • Booking, Seat, Payment 상태 변경

      b. 유저에게 결제 취소 알림 발송

    4. 만약 일정 횟수 이상의 재시도를 했음에도 불구하고 결제 취소가 실패한다면, 해당 메시지는 Dead Letter Queue (DLQ)로 이동
      a. 관리자는 DLQ에서 결제 실패 또는 취소에 대한 로그를 확인 가능

제약 조건


  • 리소스 제한
    • 스케줄러 및 재시도 큐 처리 방식은 서버 리소스를 효율적으로 활용해야 하며, 과도한 자원 소비를 피해야 함.
    • 스레드 풀 크기 및 서버 리소스 사용량에 제한이 있으므로, 이들을 과도하게 사용하는 방식은 지양해야 함.
  • 결제 재시도 횟수 및 간격
    • 결제 재시도는 최대 5번으로 제한되며, 재시도 간격은 Exponential Backoff 방식을 따라야 하므로 지연 시간에 따라 점차적으로 재시도 간격을 증가시켜야 함.
    • 결제 실패가 반복되면 Dead Letter Queue (DLQ)에 메시지가 이동되므로, 해당 큐에 대한 모니터링 및 관리가 필요.
  • 시간 기반 데이터 처리
    • 예매 취소 및 좌석 예약 상태 변경은 일정 시간이 지난 후 자동으로 처리되어야 하며, 이 과정에서 시스템에 불필요한 부하를 주지 않도록 유의해야 함.
  • 외부 시스템 의존성
    • 예매 및 결제 시스템의 외부 서비스(예: Redis, Kafka 등)와의 연동에 대한 안정성이 보장되어야 하며, 서비스 장애 시 시스템 전체에 영향이 미치지 않도록 복구 절차가 마련되어야 함.
  • 비즈니스 요구사항 준수
    • 결제 실패 후 일정 시간 동안만 예약을 보류하고, 그 이후에는 자동으로 취소되도록 처리.
    • 결제 재시도가 완료되면 즉시 유저에게 알림을 발송해야 하며, 알림 발송 시 부하를 고려한 시스템 설계 필요.

3. 코드 구현

Retry Queue

@RequiredArgsConstructor
@Slf4j
@Component
public class PaymentRetryWorker {

  private final PaymentScheduler paymentScheduler;
  private final PaymentService paymentService;

  private final int MAX_RETRY_COUNT = 5;
  private final int[] delays = {3, 2, 2, 2, 1}; // minutes

  @KafkaListener(groupId = "payment-retry-group", topics = "payment-retry-topic")
  public void processRetry(@Payload ApiResponse<PaymentRetryRequestDto> message) {

    ObjectMapper mapper = new ObjectMapper();
    PaymentRetryRequestDto requestDto
        = mapper.convertValue(message.getData(), PaymentRetryRequestDto.class);

    int retryCount = requestDto.retryCount();

    if (retryCount == 0) {
      long delay = (long) delays[0];
      retryCount++;
      schedulePaymentRetry(requestDto, retryCount, delay);
      paymentService.updatePaymentState(PaymentStatusEnum.IN_RETRY, requestDto.paymentId());
      return;
    }

    try {
      // 결제 재시도
      boolean success = retryPayment(requestDto);
      if (success) {
        log.info("Payment Retry Success, PaymentId: " + requestDto.paymentId());
      } else {
        if (retryCount >= MAX_RETRY_COUNT) {  // 예매 취소
          paymentService.processPaymentFail(PaymentRefundProcessRequestDto.from(requestDto));
          paymentService.sendPaymentDLQ(requestDto);
          return;
        }
        throw new RuntimeException("Payment failed");
      }
    } catch (RuntimeException e) {
      log.error("Payment Retry failed, PaymentId: " + requestDto.paymentId()
          + " , Retry Count: " + retryCount);
      long delay = (long) delays[retryCount - 1];
      retryCount++;
      schedulePaymentRetry(requestDto, retryCount, delay);
    }
  }

  private void schedulePaymentRetry(PaymentRetryRequestDto requestDto, int retryCount, long delay) {
    PaymentRetryRequestDto updatedRequestDto
        = PaymentRetryRequestDto.from(requestDto, retryCount);
    paymentScheduler.scheduleRetryWithDelay(delay, updatedRequestDto);
  }

  private boolean retryPayment(PaymentRetryRequestDto requestDto) {
    return paymentService.retryPayment(requestDto);
  }

}

Scheduler

@Component
@Slf4j
@RequiredArgsConstructor
public class PaymentScheduler {

  @Value("${retry-payment.scheduler.thread-pool}")
  private int threadPool;

  private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(threadPool);
  private final PaymentService paymentService;

  @PostConstruct
  public void init() {
    this.scheduler = Executors.newScheduledThreadPool(threadPool);
  }

  @RefreshScope
  @PostConstruct // @RefreshScope 와 결합하면 refresh 엔드포인트 호출시 다시 호출되어 해당 빈의 필드 값이 다시 주입됨.
  public void refreshScheduler() {
    this.scheduler.shutdown();  // 기존 스케줄러 종료
    this.scheduler = Executors.newScheduledThreadPool(threadPool);  // 새로운 스케줄러 생성
    log.info("Scheduler thread pool has been refreshed with new size: " + threadPool);
  }

  public void scheduleRetryWithDelay(long delay, PaymentRetryRequestDto requestDto) {
    log.info("Payment retry is scheduled, PaymentId: " + requestDto.paymentId());
    scheduler.schedule(() -> {
      paymentService.sendPaymentRetry(requestDto);
    }, delay, TimeUnit.MINUTES);
  }

}

4. 문제 해결 및 성과

기능 고려 사항

  • 스캐줄러 or Retry Queue

    1. 스케줄러

    • 기능: 유지 시간이 경과한 좌석 예매를 취소.

    • 작동 주기: 1분마다 실행, DB 조회 및 상태 수정.

    • 문제점: 시스템 리소스를 많이 소모할 수 있음.

      2. 카프카 실패 큐 및 재시도

    • 기능: 결제 실패 시 해당 요청을 실패 큐에 넣고, 일정 시간 간격으로 재시도.

      • 방법: Retry Queue, Exponential Backoff (지수 백오프), DLQ(Dead Letter Queue) 사용.
      • 실패 후 일정 시간이 지나면 예매 취소 메서드 실행.
    • 선택 이유: 스케줄러 방식보다 리소스 효율성이 높고, 실패 재시도 로직을 더 유연하게 처리

  • Retry Queue의 Delay 구현

    1. Thread Sleep

    • 기능: 지연이 필요한 시간 만큼 Thread.sleep(delay) 를 호출하여 구현.

    • 문제점: 스레드가 유휴 상태로 남아있기 때문에 리소스 낭비가 발생.

      2. 외부 시스템을 활용 (Redis)

    • 기능: 지연된 메시지나 작업을 처리할 때 큐 또는 Sorted Set을 이용해 특정 시간이 지나면 처리.

    • 문제점:
      - 주기적으로 지연된 작업을 확인하는 스케줄러가 실행되어야 하므로, 추가적인 리소스 소모가 발생.
      - Redis와 같은 외부 시스템을 사용하는 경우 네트워크 대기시간 및 추가적인 관리 비용이 발생할 수 있음.

      3. 스케줄러를 이용한 비동기적 처리

    • 기능: ScheduledExecutorService

      • 스케줄러 풀을 사용하여 일정 시간 후 작업을 실행.
      • 스레드를 유휴 상태로 두지 않고, 작업을 큐에 넣어 지정된 시간이 지나면 자동으로 실행.
      • 리소스를 효율적으로 활용할 수 있도록 스레드를 재사용하고, 지연된 작업을 효율적으로 처리.
    • 선택 이유:

      • 리소스를 효율적으로 활용할 수 있도록 스레드를 재사용.
      • 서버 리소스를 낭비하지 않고, 여러 작업을 비동기적으로 처리할 수 있음.
  • Scheduler의 스레드풀을 동적으로 할당
    • 문제:
      • newScheduledThreadPool은 생성 시점에만 값을 사용하여 스레드 풀을 초기화하므로,
        Config Server에서 스레드 풀 개수를 변경하고 /actuator/refresh 을 호출해도
        새로운 스레드 풀로 동작하지 않는 문제
    • 해결:
      • @RefreshScope@PostConstruct 를 함께 활용

결과

  1. 결제 금액(마일리지) 부족 시 Retry 상태로 변환

  1. 사용자에게 결제 실패 및 원인을 전달

  1. 사용자가 실패 원인을 해결(마일리지 충전)한 뒤 자동으로 Retry되어 결제 및 예약 완료

profile
I live in Seoul, Korea, Handsome

0개의 댓글

관련 채용 정보