결제 취소: 데이터 정합성 문제

gminnimk·2025년 9월 22일

문제 해결

목록 보기
17/18

결제 취소에 숨은 데이터 정합성 함정과 해결

외부 API와 데이터베이스가 엮여있는 결제 취소 기능에서 어떻게 데이터가 어긋날 수 있는지, 그리고 이 문제를 어떻게 해결하는지 보도록 하겠습니다.



문제 상황: 고객은 환불받았는데, 우리 DB는 "결제 완료"?

  1. 외부 결제 API(이번 예제에서는 PortOne)에 환불 요청을 보낸다.
  2. DB에 해당 결제 상태를 'CANCELLED'로 변경한다.

이 두 가지 작업을 하나의 Spring @Transactional 메서드 안에서 처리하는 코드가 있었습니다.

// 문제가 있는 초기 코드
@Transactional
public void cancelPayment(String impUid) {
    // 1. 외부 PortOne API에 환불 요청 (성공)
    portoneClient.cancel(impUid);

    // 2. 우리 DB 상태를 'CANCELLED'로 변경 (실패)
    Payment payment = paymentRepository.findByImpUid(impUid).get();
    payment.changeStatus("CANCELLED");
    paymentRepository.save(payment);
}

만약 2번(DB 업데이트) 과정에서 일시적인 DB 장애(Deadlock, Connection Pool 부족 등)로 오류가 발생하면 어떻게 될까요?

@Transactional의 특성상 DB 작업에 실패하면 트랜잭션 전체가 롤백 됩니다. 즉, payment의 상태 변경은 없었던 일이 됩니다.

하지만, 이미 성공한 1번(외부 API 호출)은 롤백할 수 없습니다!

이후 결과는

  • 현실 세계 (PortOne): 고객은 실제로 환불을 받았습니다.
  • 우리 시스템 (DB): 트랜잭션이 롤백되어 여전히 'PAID'(결제 완료) 상태로 남아있습니다.

고객은 돈을 돌려받았지만 우리 시스템에는 여전히 유효한 결제처럼 남아있는, 심각한 데이터 불일치가 발생한 것입니다. 이 데이터는 나중에 정산, 재고 관리, 사용자 알림 등 모든 곳에서 문제를 일으키게 됩니다.



근본 원인: 롤백되지 않는 외부 API 호출

문제의 원인은 트랜잭션의 제어 범위를 벗어나는 작업(외부 API 호출)DB 작업을 하나의 트랜잭션으로 묶었기 때문입니다.

@Transactional은 데이터베이스의 ACID 원칙을 지키기 위한 도구이지만, 오직 해당 트랜잭션 내의 DB 작업에만 영향을 미칩니다. 네트워크를 통해 외부 시스템과 통신하는 작업은 한번 실행되면 되돌릴 수 없습니다.



해결 과정: 트랜잭션 분리와 책임의 재설계

이 문제를 해결하기 위해, 단일 트랜잭션으로 묶여있던 로직을 역할과 책임에 따라 명확히 분리하고 호출 흐름을 재설계했습니다.

핵심은 "외부 시스템의 상태를 먼저 변경하고, 성공이 확인되었을 때만 우리 시스템의 상태를 변경한다" 입니다.


1. 책임 분리

역할이 섞여 있던 PaymentService를 세 개의 클래스로 분리했습니다.

  • PaymentClient: 외부 PortOne API와의 통신, 인증, 재시도(@Retryable) 등 모든 외부 연동 책임을 담당합니다.
  • PaymentTransactionService: @Transactional을 사용하여 데이터베이스 트랜잭션(저장, 수정)만을 전담합니다. (셀프 호출 방지)
  • PaymentService: 위 두 서비스를 조율 하여 전체 비즈니스 흐름을 관리합니다.

2. 새로운 처리 흐름: 선 API 호출, 후 DB 업데이트

PaymentService의 결제 취소 로직을 다음과 같이 변경했습니다.

// PaymentService.java - @Transactional 제거
public Payment cancelPayment(PaymentCancelRequestDto cancelDto, Long userId) throws IamportResponseException, IOException {
    // ... 사전 검증 로직 ...

    // 1. [트랜잭션 X] PaymentClient를 통해 외부 API(환불)를 먼저 실행
    IamportResponse<com.siot.IamportRestClient.response.Payment> iamportResponse = paymentClient.cancelPaymentByImpUid(cancelDto);

    // 2. [별도 트랜잭션] API 호출 성공 시, PaymentTransactionService를 호출하여 DB 상태 변경
    return paymentTransactionService.updatePaymentStatusToCancelled(iamportResponse.getResponse());
}

3. 최악의 경우 대비: "성공한 실패"를 기록하기

"API 호출은 성공했지만, DB 업데이트는 실패"하는 최악의 경우에 대비해야 합니다. 이 경우, 시스템이 조용히 넘어가서는 안 됩니다. 반드시 개발자가 인지하고 수동으로 조치할 수 있도록 명확한 흔적을 남겨야 합니다.

PaymentTransactionService의 DB 업데이트 로직에 try-catch 블록을 추가하여, 예외 발생 시 치명적인 수준의 에러 로그를 남기도록 구현했습니다.

// PaymentTransactionService.java
@Transactional
public Payment updatePaymentStatusToCancelled(com.siot.IamportRestClient.response.Payment paymentInfo) {
    Payment payment = paymentRepository.findByImpUid(paymentInfo.getImpUid())
            .orElseThrow(() -> new CustomException(ErrorCode.PAYMENT_NOT_FOUND));
    try {
        payment.changeStatus(paymentInfo.getStatus());
        return paymentRepository.save(payment);
    } catch (Exception e) {
        // 최악의 경우, 추적 가능한 로그를 남기고 예외를 던진다.
        log.error("[CRITICAL] PortOne 결제 취소는 성공했으나 DB 상태 업데이트 실패! 수동 확인 필요. imp_uid: {}", paymentInfo.getImpUid());
        throw new CustomException(ErrorCode.DATABASE_UPDATE_FAILED);
    }
}


개선 결과: 예측 가능한 실패는 더 이상 버그가 아니다

  • 데이터 정합성 보장: 이제 시스템이 조용히 데이터 불일치 상태를 만들지 않습니다. 만약 불일치 상황이 발생하면, 명확히 추적 가능한 로그를 남기는 예측된 실패로 관리됩니다.
  • 안정성 및 유지보수성 향상: 각 클래스의 역할이 명확해져 코드를 이해하고 테스트하기 쉬워졌습니다. 특히, "API는 성공, DB는 실패"라는 특정 상황을 명확히 로깅하므로 문제 원인 분석과 해결이 매우 빨라졌습니다.
  • 견고한 아키텍처: 외부 시스템 연동과 내부 로직을 분리하는 원칙을 적용하여, 향후 다른 외부 API를 연동하거나 비즈니스 로직이 변경되어도 유연하게 대처할 수 있는 구조가 되었습니다.

외부 시스템과 연동하는 로직을 작성할 때는 항상 "두 시스템의 상태를 어떻게 일치시킬 것인가?"를 먼저 고민하는 습관이 중요하다는 것을 다시 한번 깨닫게 된 경험이었습니다.

0개의 댓글