본문 바로가기

Project

결제 시나리오 3. 악의적 선점 - 데이터 정합성 문제(2)

문제 상황

이전 트러블 슈팅을 통해 스케줄러가 조회한 후 결제 상태가 변경되었을 가능성을 고려하여 최신 상태를 다시 조회하는 로직을 추가함으로써 데이터 정합성 문제를 해결할 수 있었습니다.

그러나 여전히 결제 승인 요청과 스케줄러가 동시에 실행되는 경우 아주 짧은 시간 간격으로 정합성이 깨질 가능성이 남아있었습니다.

→ 여전히 발생할 수 있는 문제

  1. 사용자가 9분 5x초에 결제 진행 → CONFIRMING 상태로 변경
  2. 동일한 시점에 스케줄러 실행 → 기존 IN_PROGRESS 상태를 조회 후 취소 처리 시도
  3. 스케줄러가 findById()로 최신 상태를 다시 조회했지만, 아주 미세한 타이밍 차이로 CONFIRMING 상태 변경이 반영되기 직전이면 여전히 취소 진행
  4. 결제 승인 요청이 완료되었음에도, 결제 내역이 취소되는 문제 발생 가능성

트랜잭션의 순서와 타이밍에 따라 여전히 결제 성공 후 취소가 이루어질 수 있는 잠재적 문제가 남아 있습니다.


해결 과정

문제를 해결하기 위한 접근 방법으로 Redisson 기반의 락을 플래그로 활용하여 “현재 결제 진행 중” 이라는 신호를 시스템적으로 보장하는 방식을 적용하려 합니다.

이를 통해 스케줄러가 결제 취소를 시도하기 전에 해당 결제가 진행 중인지 여부를 락을 통해 확인하도록 보완할 수 있습니다.

특히 우리 팀에서는 이미 다른 팀원이 Redisson 기반의 락을 구현해 놓은 상태였기 때문에 새로운 락 구현을 할 필요 없이 기존의 로직을 재사용하는 것이 일관성 유지, 개발 효율성 측면에서 더 적절한 선택이라 판단했습니다.

[VerifyReqeust() 락 적용]

EX

  • 사용자 A가 “A 1번 좌석”을 선택하고 결제를 진행한다고 가정하겠습니다
  • 이때 해당 결제의 orderId = “order-1234”
  • verifyRequest()가 실행되면서 “현재 결제 진행 중”을 나타내기 위한 락을 생성합니다.

이 락이 걸려있는 동안은 동일한 orderId에 대한 다른 작업(스케줄러 취소 등)이 실행되지 않습니다.

@Transactional
@Lock(key = "#orderId", prefix = "payment_lock:") // 특정 orderId에 대한 락 설정
public void verifyRequest(String orderId, int amount) {
    Payment payment = paymentRepository.findByUid(orderId)
        .orElseThrow(() -> new CustomException(ServerErrorResponseCode.ORDER_UUID_NOT_FOUND));

		// 결제 상태를 CONFIRMING으로 변경
    payment.updatePaymentStatus(PaymentState.CONFIRMING);
		
		// PG사에서 요청한 금액과 DB 저장 금액이 다르면 예외 발생
    if (payment.getAmount() != amount) {
        throw new PaymentException(PaymentErrorResponseCode.PAYMENT_AMOUNT_MISMATCH);
    }
}
  1. 결제 검증이 진행 중일 때(IN_PROGRESS → CONFIRMING)락이 걸려있으므로 해당 orederId에는 다른 작업이 접근하지 못합니다.
  2. 특히 스케줄러(cancelTimeOutPayments())가 실행되더라도 락을 획득하지 못하므로 취소되지 않습니다.
  • 즉 “지금 이 결제는 진행 중이니 취소시키지마” 라는 플래그 역할을 락이 수행하는 것입니다.

위에서는 verifyRequest()에만 락을 걸면 해결될 것 같이 말했지만 사실은 의미가 없는 행동입니다. verifyRequest()에만 락을 걸면, 스케줄러가 이미 조회한 IN_PROGRESS 상태를 기반으로 결제를 취소할 가능성이 남아있습니다.

즉, 스케줄러(cancelTimeOutPayments)에서도 같은 orderId에 대한 락을 확인해야, 결제 진행 중인 상태를 보호할 수 있습니다. 따라서 두개의 메서드 모두 락을 확인해야 데이터 정합성을 완벽하게 보장할 수 있습니다.

[cancelTimeOutPayments() 락 적용]

Ex

  • 10분 후 스케줄러가 실행되면서 “order-1234”를 취소하려고 시도를 할 것입니다.
  • 하지만 이미 verifyRequest()에서 “payment_lock:order-1234”락이 걸려있는 상태
  • 스케줄러는 락을 획득할 수 없으므로 해당 결제 건을 취소하지 않고 넘어갈 것입니다.

    @Transactional
    @Lock(key = "#payment.getUid()", prefix = "payment_lock:")
    public void cancelTimeOutPayments(Payment payment) {
        Payment paymentInDB = paymentRepository.findById(payment.getId())
                .orElseThrow();

        if (paymentInDB.getState() != PaymentState.IN_PROGRESS) {
            return;
        }

        paymentInDB.updatePaymentStatus(PaymentState.CANCEL);
        paymentInDB.getOrder().updateState(OrderState.CANCELED);
        paymentInDB.getOrder().getTickets().forEach(orderTicket ->
                orderTicket.getTicket().updateState(SeatState.IDLE));
        paymentRepository.save(paymentInDB);
    }
  1. 스케줄러가 실행될 때 동일안 orderId에 대한 락이 걸려 있다면 락을 획득하지 못하고 넘어갑니다.
  2. 즉 verifyRequest()가 진행 중인 동안 스케줄러가 결제를 취소하는 문제를 방지합니다.
  3. 락이 해제된 후 다시 스케줄러가 실행되었을 때 상태를 확인하고 취소할지 결정합니다.

verifyRequest()에서 락이 해제되었을 경우 스케줄러 동작 방식

  1. verifyRequest()에서 검증이 완료되고 CONFIRMING으로 상태가 변경됨.
  2. 스케줄러는 해당 orderId에 대해 Lock 체크를 하고 다시 DB에서 조회를 함.
  3. DB에서 orderId의 상태가 이미 IN_PROGRESS → CONFIRMING으로 변경되었으므로 결제 취소 로직을 실행하지 않고 그냥 넘어감

결론 및 회고

  • 결제 승인 요청(verifyRequest)과 스케줄러(cancelTimeOutPayments)는 서로 직접 호출하지 않지만 같은 결제 데이터(orderId)를 다루므로, 동시에 실행되면 정합성이 깨질 가능성이 있습니다.
  • 따라서 Redisson 기반의 락을 활용하여, "현재 결제가 진행 중이므로 취소하면 안 된다"는 **신호(플래그)**를 설정하는 것이 최적의 해결책이었습니다.
  • 이를 통해 스케줄러가 결제를 임의로 취소하는 문제를 방지하고, 결제 과정의 데이터 정합성을 보장할 수 있게 되었습니다.