전체 흐름
결제가 실패하면 관련 상태 ‘취소’가 아닌 ‘실패, 재시도 필요’ 로 변경, 유저에게 결제 실패 알림 발송
일정 시간 동안 해당 좌석은 해당 유저만 다시 결제 시도 할 수 있게 유지 (10분)
유저는 예매 상태를 조회하여 결제 실패를 확인 및 원인 파악
마일리지 충전 후 대기
시스템은 마일리지가 충분한지 확인 후 결제 재시도
예매 확정, 유저에게 결제 완료 알림 발송
기능 명세
결제 실패 발생
a. 실패 메세지를 Retry Queue에 등록
b. 유저에게 결제 실패 알림 발송
Retry Queue에서 재시도
a. Retry Worker가 Retry Queue에서 메시지를 꺼내어 결제 재시도
b. 재시도 시, Exponential Backoff 방식에 따라 점진적으로 재시도 간격을 늘려감
결제 실패 시 취소
a. 일정 횟수의 재시도 후에도 결제 실패가 해결되지 않으면, 결제 취소 처리
b. 유저에게 결제 취소 알림 발송
만약 일정 횟수 이상의 재시도를 했음에도 불구하고 결제 취소가 실패한다면, 해당 메시지는 Dead Letter Queue (DLQ)로 이동
a. 관리자는 DLQ에서 결제 실패 또는 취소에 대한 로그를 확인 가능
@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);
}
}
@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);
}
}
기능: 유지 시간이 경과한 좌석 예매를 취소.
작동 주기: 1분마다 실행, DB 조회 및 상태 수정.
문제점: 시스템 리소스를 많이 소모할 수 있음.
기능: 결제 실패 시 해당 요청을 실패 큐에 넣고, 일정 시간 간격으로 재시도.
선택 이유: 스케줄러 방식보다 리소스 효율성이 높고, 실패 재시도 로직을 더 유연하게 처리
기능: 지연이 필요한 시간 만큼 Thread.sleep(delay)
를 호출하여 구현.
문제점: 스레드가 유휴 상태로 남아있기 때문에 리소스 낭비가 발생.
기능: 지연된 메시지나 작업을 처리할 때 큐 또는 Sorted Set을 이용해 특정 시간이 지나면 처리.
문제점:
- 주기적으로 지연된 작업을 확인하는 스케줄러가 실행되어야 하므로, 추가적인 리소스 소모가 발생.
- Redis와 같은 외부 시스템을 사용하는 경우 네트워크 대기시간 및 추가적인 관리 비용이 발생할 수 있음.
기능: ScheduledExecutorService
선택 이유:
newScheduledThreadPool
은 생성 시점에만 값을 사용하여 스레드 풀을 초기화하므로,/actuator/refresh
을 호출해도@RefreshScope
와 @PostConstruct
를 함께 활용