토스페이먼츠 결제 취소와 실패 복구: 결제는 됐는데 DB가 터지면?
지난 글에서 토스페이먼츠 API로 결제를 붙이는 과정을 다뤘다. 결제 승인까지는 잘 됐는데, 운영하자마자 두 가지 문제가 터졌다.
"관리자가 환불 버튼을 눌렀는데, 토스에는 아직 결제가 살아있다."
"사용자가 결제했는데 서버 에러가 났다. 카드값은 빠져나갔는데 수강권은 안 생겼다."
결제 연동의 진짜 난이도는 "결제하기"가 아니라 "취소하기"와 "실패 복구"에 있다는 걸 깨달았다.
문제 1: 환불이 DB에서만 일어나고 있었다
초기 환불 코드는 이랬다.
// 결제 금액 차감 payment.cancelAmount(cancelAmount); // 전액 환불이면 접근 권한 회수 if (payment.getBalanceAmount() == 0) { purchases.forEach(UserPurchase::refund); } // 환불 기록 저장 paymentCancelRepository.save(cancel);
DB의 balance_amount를 깎고 status를 CANCELED로 바꾼다. 잘 돌아가는 것 같지만 한 가지가 빠졌다. 토스페이먼츠에는 아무 말도 안 했다.
사용자 카드에서는 돈이 안 돌아오고, 토스 대시보드에서는 "결제 완료" 상태 그대로. 관리자는 환불 처리했다고 생각하지만 실제로는 아무 일도 안 일어난 거다.
토스 취소 API 연결
토스페이먼츠의 결제 취소 API는 단순하다.
POST /v1/payments/{paymentKey}/cancel Body: cancelReason: "고객 환불 요청" cancelAmount: 20000 ← 부분 취소 시. 전액이면 생략
전액 취소면 cancelAmount를 안 보내면 된다. 부분 취소면 금액을 넣으면 되고.
수정한 흐름은 이렇다.
관리자: "환불" 클릭 │ ▼ ┌────────────────────────────────────────┐ │ 1. 환불 금액 검증 (잔액 초과?) │ │ 2. 토스 API 호출 ← 여기가 핵심! │ │ POST /v1/payments/{paymentKey}/cancel │ │ 3. (성공) 토스 응답 저장 │ │ 4. DB 잔액 차감 + 상태 변경 │ │ 5. 전액 환불이면 접근 권한 회수 │ └────────────────────────────────────────┘ 토스 API 실패 시: │ → cancel_status = FAILED 저장 │ → BusinessException 던짐 │ → DB 잔액은 변경 안 됨 (안전)
순서가 중요하다. 토스 API를 먼저 호출하고, 성공한 경우에만 DB를 변경한다. 반대로 하면 DB는 환불됐는데 토스 호출이 실패하는 상황이 생긴다.
// 토스 API 먼저 호출 TossPaymentResponse response = tossPaymentService.cancelPayment( payment.getPaymentKey(), cancelReason, tossAmount); cancel.setTossCancelResponse( objectMapper.writeValueAsString(response)); // 성공한 경우에만 DB 반영 payment.cancelAmount(cancelAmount);
실패하면? cancel_status를 FAILED로 저장하고 예외를 던진다. DB의 balance_amount는 그대로 유지되니, 토스와 우리 DB의 상태가 어긋나지 않는다.
문제 2: 결제는 됐는데 DB가 터졌다
이게 진짜 무서운 케이스다.
결제 승인 흐름을 보면:
사용자: 결제 완료 │ ▼ ┌─────────────────────────────────────┐ │ 1. 주문 검증 (소유자, 상태, 만료) │ │ 2. 금액 검증 │ │ 3. 토스 API 결제 승인 ← 여기서 카드 결제 │ │ 4. DB 처리: │ │ - 주문 상태 → PAID │ │ - Payment 레코드 생성 │ │ - UserPurchase 생성 ← 여기서 터짐! │ │ - 장바구니 정리 │ └─────────────────────────────────────┘
3번에서 토스가 카드를 긁었다. 돈은 빠져나갔다. 그런데 4번의 UserPurchase 저장에서 Duplicate Key 에러가 터진다. 트랜잭션이 롤백되면서 Payment 레코드도, 주문 상태 변경도 전부 날아간다.
결과: 토스에는 결제 완료, 우리 DB에는 흔적 없음.
사용자는 카드값이 빠졌는데 수강권이 안 생겼고, 관리자 화면에서도 결제 기록이 안 보인다.
왜 Duplicate Key가 났나
원인은 "환불 후 재구매"였다. user_purchase 테이블에 (user_id, product_id) 유니크 제약이 걸려 있는데, 환불할 때 레코드를 삭제하지 않고 status = REFUNDED로만 바꿨다. 같은 상품을 다시 구매하면 새 INSERT가 유니크 제약에 걸린다.
┌─────────────────────────────────────────────────┐ │ user_purchase 테이블 │ │ UNIQUE(user_id, product_id) │ ├─────────┬────────────┬──────────┬────────────────┤ │ user_id │ product_id │ status │ payment_id │ ├─────────┼────────────┼──────────┼────────────────┤ │ 2 │ 1 │ REFUNDED │ 1 (이전 결제) │ └─────────┴────────────┴──────────┴────────────────┘ 재구매 시 INSERT 시도: user_id=2, product_id=1 → Duplicate entry '2-1'!
두 가지를 동시에 고쳐야 한다.
첫째, 재구매 지원. INSERT 대신 기존 REFUNDED 레코드를 업데이트한다.
userPurchaseRepository .findByUserIdAndProductId(userId, productId) .ifPresentOrElse( existing -> existing.repurchase(paymentId, price), () -> userPurchaseRepository.save( UserPurchase.createPaid(userId, product, paymentId, price)) );
기존 레코드가 있으면 repurchase()로 ACTIVE 상태로 되돌리고 새 결제 정보를 세팅한다. 없으면 새로 만든다.
둘째, 실패해도 복구. 재구매 문제는 고쳤지만, 앞으로 다른 이유로 DB 처리가 실패할 수도 있다. 어떤 이유로든 토스 승인 후 DB 처리가 실패하면 자동으로 토스 결제를 취소하고 기록을 남겨야 한다.
자동 취소 + 실패 로그
수정한 흐름:
토스 결제 승인 (성공) │ ▼ DB 처리 시도 ──── 성공 → 정상 완료 │ 실패! │ ▼ ┌──────────────────────────────────────┐ │ 1. 토스 결제 자동 취소 시도 │ │ POST /payments/{key}/cancel │ │ │ │ 2. 실패 로그 저장 (별도 트랜잭션!) │ │ - 에러 메시지 │ │ - 토스 취소 성공 여부 │ │ - 주문 상태 → FAILED │ │ │ │ 3. 사용자에게 에러 응답 │ │ "결제 처리 중 오류. 자동 취소됨" │ └──────────────────────────────────────┘
핵심은 2번의 **"별도 트랜잭션"**이다. 메인 트랜잭션은 이미 롤백될 운명인데, 여기서 실패 로그를 저장하면 같이 날아간다. 그래서 REQUIRES_NEW로 독립 트랜잭션을 열어 실패 로그를 저장한다.
@Transactional(propagation = Propagation.REQUIRES_NEW) public void saveFailureLog(String orderId, String paymentKey, Long userId, int amount, String errorMessage, ...) { // 실패 로그 저장 (메인 tx 롤백과 무관) failureLogRepository.save(PaymentFailureLog.create(...)); // 주문 상태도 FAILED로 변경 paymentOrderRepository.findByOrderId(orderId) .ifPresent(order -> order.markAsFailed()); }
┌──────────────────────────────────────────────┐ │ 트랜잭션 구조 │ │ │ │ [메인 TX] ─── 결제 처리 ─── 실패 → 롤백! │ │ │ │ │ └── [별도 TX] ─── 실패 로그 저장 → 커밋 │ │ └── 주문 FAILED 변경 → 커밋 │ │ │ │ 메인 TX가 롤백되어도 별도 TX는 살아남음 │ └──────────────────────────────────────────────┘
이렇게 하면 메인 트랜잭션이 롤백되어도 실패 로그는 남고, 주문 상태는 FAILED로 바뀐다. 관리자가 "왜 결제가 실패했지?" 하고 찾아볼 수 있다.
관리자가 실패 원인을 볼 수 있어야 한다
실패 로그를 쌓기만 하면 소용없다. 관리자 화면에서 바로 확인할 수 있어야 한다.
┌──────────────────────────────────────────────┐ │ 결제 관리 │ │ │ │ [결제 내역] [결제 실패 로그] │ │ ^^^^^^^^^^^^^^^^ 새로 추가 │ ├──────────────────────────────────────────────┤ │ 주문 ID │ 금액 │ 에러 내용 │ 자동 취소 │ ├──────────────────┼───────┼─────────────┼─────────┤ │ order_abc123... │ 1,000 │ Duplicate │ 취소 완료 │ │ │ │ entry '2-1' │ │ ├──────────────────┼───────┼─────────────┼─────────┤ │ order_def456... │ 5,000 │ Connection │ 취소 실패 │ │ │ │ timeout │ │ └──────────────────┴───────┴─────────────┴─────────┘
**"취소 완료"**면 사용자에게 돈이 돌아간 거니 안심. **"취소 실패"**면 관리자가 토스 대시보드에서 수동으로 확인해서 처리해야 한다.
실패 로그 테이블은 이렇게 만들었다.
CREATE TABLE payment_failure_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id VARCHAR(64) NOT NULL, payment_key VARCHAR(200), user_id BIGINT NOT NULL, amount INT NOT NULL, error_message VARCHAR(1000) NOT NULL, error_type VARCHAR(50) NOT NULL, toss_cancel_success BOOLEAN NOT NULL DEFAULT FALSE, toss_cancel_response JSON, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );
toss_cancel_success가 핵심이다. 자동 취소까지 성공했는지, 취소 시도 자체도 실패했는지를 구분한다. toss_cancel_response에는 토스 API 응답을 그대로 저장해서, 나중에 디버깅할 때 쓸 수 있게 했다.
토스 결제 취소 시 주의할 점
토스페이먼츠 취소 API를 붙이면서 알게 된 것들이다.
┌──────────────────────────────────────────────────┐ │ 결제 수단별 취소 특성 │ ├───────────────┬──────────────┬───────────────────┤ │ 결제 수단 │ 취소 기한 │ 환불 소요 시간 │ ├───────────────┼──────────────┼───────────────────┤ │ 카드 │ 사실상 무제한 │ 매입 전: 즉시 │ │ │ (1년 권장) │ 매입 후: 3~4영업일 │ ├───────────────┼──────────────┼───────────────────┤ │ 계좌이체 │ 180일 │ 실시간 │ ├───────────────┼──────────────┼───────────────────┤ │ 가상계좌 │ 365일 │ 취소일+2영업일 │ │ │ │ (환불 계좌 필요!) │ ├───────────────┼──────────────┼───────────────────┤ │ 휴대폰 │ 결제 당월만 │ 당일 │ └───────────────┴──────────────┴───────────────────┘
카드 부분 취소는 매입 여부와 관계없이 영업일 3~4일이 걸린다. 사용자에게 "바로 환불됩니다"라고 안내하면 민원 온다.
가상계좌 취소는 환불 계좌 정보가 필요하다. 결제 승인 직후 30분 안에 refundReceiveAccount 필드를 저장해두지 않으면, 나중에 환불할 때 사용자에게 다시 물어봐야 한다.
휴대폰 결제는 당월만 취소 가능하다. 2월에 결제한 건 3월에는 취소할 수 없다.
정리
이번에 삽질하면서 정리한 원칙이다.
1. 외부 API를 먼저, DB를 나중에 → 토스 취소가 성공한 경우에만 DB 잔액 차감 2. 외부 API 성공 후 DB가 실패하면 외부 API를 되돌려라 → 토스 승인 후 DB 에러 → 토스 자동 취소 3. 되돌리기에 실패해도 기록은 남겨라 → REQUIRES_NEW 트랜잭션으로 실패 로그 저장 4. 관리자가 문제를 인지할 수 있게 하라 → 실패 로그 관리 화면, 취소 성공/실패 구분
결제 시스템은 "잘 되는 경우"보다 "안 되는 경우"를 얼마나 잘 처리하느냐가 핵심이다. 사용자 입장에서 "결제했는데 아무 반응 없음"이 최악이고, 운영하는 입장에서 "뭐가 잘못됐는지 모르겠음"이 최악이다.
토스 결제가 됐는데 DB가 터지는 건 확률이 낮다. 근데 딱 한 번 터지면 사용자한테 전화가 온다. 그 한 번을 위해 자동 취소 로직과 실패 로그를 미리 깔아두는 거다.

댓글
댓글을 작성하려면 이 필요합니다.