Sale Commerce 프로젝트에서 PortOne을 이용한 결제 승인 기능을 구현했다.
처음 결제 로직을 보면
"결제와 관련된 작업이니까 전부 하나의
@Transactional로 묶는 게 더 안전하지 않을까?"
라는 생각을 하기 쉽다.
하지만 외부 API 호출까지 DB 트랜잭션 안에 포함하면 오히려 DB Lock을 불필요하게 오래 유지할 수 있다는 문제가 있었다.
이번 글에서는 외부 결제 API와 DB 트랜잭션의 범위를 분리하고, 동시에 들어오는 결제 승인 요청까지 방어한 과정을 정리한다.
결제 승인에는 크게 두 가지 종류의 작업이 있었다.
1. PortOne에서 실제 결제 상태와 금액 확인
2. 우리 DB에서
주문 상태 확인
Payment 저장
주문 상태 변경
만약 이 전체 과정을 하나의 트랜잭션으로 묶고 주문에 Row Lock을 획득한 상태에서 PortOne API를 호출한다면 다음과 같은 구조가 된다.
Transaction 시작
↓
주문 Row Lock 획득
↓
PortOne API 호출
↓
외부 응답 대기...
↓
Payment 저장
↓
주문 상태 변경
↓
Commit
문제는 외부 API의 응답시간을 우리가 제어할 수 없다는 것이다.
PortOne이 빠르게 응답할 수도 있지만 네트워크나 외부 시스템 상황에 따라 응답이 늦어질 수도 있다.
그 시간 동안 DB의 주문 Row Lock도 계속 유지된다.
같은 주문에 접근하려는 다른 요청은 PortOne의 응답과 아무런 관계가 없음에도 Lock이 풀릴 때까지 기다리게 된다.
그래서 전체 결제 승인 과정을 두 단계로 분리했다.
[트랜잭션 밖]
주문 1차 검증
↓
PortOne 결제 상태 조회
↓
결제 금액 검증
[짧은 DB 트랜잭션]
주문 Row Lock 획득
↓
주문 상태 재검증
↓
Payment 저장
↓
주문 상태 변경
↓
Commit
핵심은 외부 네트워크 응답을 기다리는 동안 DB Lock을 유지하지 않는 것이었다.
PortOne을 호출하기 전에 주문이 결제 가능한 상태인지 이미 확인했다.
그런데 DB 트랜잭션 안에서 다시 한번 주문 상태를 확인했다.
왜 같은 검증을 두 번 했을까?
동시 요청 때문이다.
예를 들어 두 개의 요청이 거의 동시에 들어온다고 생각해보자.
Request A : 주문 확인 → 결제 가능
Request B : 주문 확인 → 결제 가능
Request A : PortOne 확인
Request B : PortOne 확인
Request A : Payment 저장
Request B : Payment 저장 ❌
PortOne을 호출하는 동안 다른 요청이 먼저 결제를 완료했을 수 있다.
따라서 실제 DB를 변경하는 시점에는 주문에 Row Lock을 획득한 뒤 현재 시점의 주문 상태를 다시 확인해야 했다.
즉 두 검증의 목적이 다르다.
외부 API 호출 전 검증
→ 잘못된 요청을 빠르게 차단하기 위한 검증
DB Lock 획득 후 검증
→ 동시 요청에서도 정합성을 보장하기 위한 최종 검증
애플리케이션 코드에서 중복 결제를 검사하더라도 예측하지 못한 동시 요청이 존재할 수 있다.
그래서 PortOne 결제 식별자에도 DB Unique Constraint를 적용했다.
최종적으로 중복 결제를 여러 단계에서 방어하도록 구성했다.
애플리케이션 검증
+
주문 Row Lock
+
DB Unique Constraint
실제 동시성을 확인하기 위해 테스트를 작성했다.
조건은 다음과 같다.
동일한 주문
동시 결제 승인 요청 : 2건
각 요청의 PortOne Payment ID는 서로 다름
결과는 다음과 같았다.
결제 성공 : 1건
이미 결제 완료된 요청 : 1건
DB에 저장된 Payment : 1건
최종 Order 상태 : PAID
동일 주문에 두 개의 결제 승인 요청이 동시에 발생해도 실제 Payment는 하나만 생성되는 것을 검증했다.
이전에는 @Transactional의 범위가 넓으면 넓을수록 데이터를 안전하게 보호할 수 있다고 생각하기 쉬웠다.
하지만 트랜잭션 내부에 외부 네트워크 호출이 들어가는 순간 문제가 달라진다.
트랜잭션은 무조건 크게 묶는 것이 아니라
정합성을 위해 반드시 함께 처리되어야 하는 DB 작업만 가능한 짧게 묶는 것
이 중요했다.
특히 외부 시스템이 포함된 기능에서는
외부 API 성공
≠
내부 DB 성공
이라는 점도 함께 고려해야 한다는 것을 배웠다.
이 경험은 이후 환불 기능을 설계하면서 외부 시스템과 내부 DB의 부분 실패를 고민하는 데에도 이어졌다.