외부 API와 데이터베이스가 엮여있는 결제 취소 기능에서 어떻게 데이터가 어긋날 수 있는지, 그리고 이 문제를 어떻게 해결하는지 보도록 하겠습니다.
이 두 가지 작업을 하나의 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 호출)은 롤백할 수 없습니다!
이후 결과는
고객은 돈을 돌려받았지만 우리 시스템에는 여전히 유효한 결제처럼 남아있는, 심각한 데이터 불일치가 발생한 것입니다. 이 데이터는 나중에 정산, 재고 관리, 사용자 알림 등 모든 곳에서 문제를 일으키게 됩니다.
문제의 원인은 트랜잭션의 제어 범위를 벗어나는 작업(외부 API 호출)과 DB 작업을 하나의 트랜잭션으로 묶었기 때문입니다.
@Transactional은 데이터베이스의 ACID 원칙을 지키기 위한 도구이지만, 오직 해당 트랜잭션 내의 DB 작업에만 영향을 미칩니다. 네트워크를 통해 외부 시스템과 통신하는 작업은 한번 실행되면 되돌릴 수 없습니다.
이 문제를 해결하기 위해, 단일 트랜잭션으로 묶여있던 로직을 역할과 책임에 따라 명확히 분리하고 호출 흐름을 재설계했습니다.
핵심은 "외부 시스템의 상태를 먼저 변경하고, 성공이 확인되었을 때만 우리 시스템의 상태를 변경한다" 입니다.
역할이 섞여 있던 PaymentService를 세 개의 클래스로 분리했습니다.
PaymentClient: 외부 PortOne API와의 통신, 인증, 재시도(@Retryable) 등 모든 외부 연동 책임을 담당합니다.PaymentTransactionService: @Transactional을 사용하여 데이터베이스 트랜잭션(저장, 수정)만을 전담합니다. (셀프 호출 방지)PaymentService: 위 두 서비스를 조율 하여 전체 비즈니스 흐름을 관리합니다.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());
}
"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);
}
}
외부 시스템과 연동하는 로직을 작성할 때는 항상 "두 시스템의 상태를 어떻게 일치시킬 것인가?"를 먼저 고민하는 습관이 중요하다는 것을 다시 한번 깨닫게 된 경험이었습니다.