잔액이 10,000포인트인 지갑에서 7,000포인트 상품을 교환한다고 하자. 서버가 포인트를 차감한 뒤 외부 발급사를 호출했는데 응답을 받지 못한다. 이때 포인트를 돌려주면 될까? 발급사가 상품을 만들고 응답만 잃었다면 환급과 발급이 함께 남는다.
이 글의 질문은 선차감 이후 결과를 알 수 없는 교환을 어떻게 확정할 것인가다. Kotlin/JVM과 Spring, PostgreSQL 17의 단일 지갑 행, 외부 HTTP 발급사를 조건으로 설명한다. 잔액과 상품 가격은 설명용 값이며 가격은 서버가 양수로 결정한다.
차감 순서와 차감 커밋은 다르다
외부 발급을 먼저 실행하는 흐름부터 살펴본다.
val wallet = walletRepository.findByUserId(userId)
wallet.requireAtLeast(price)
val voucher = provider.issue(productId)
wallet.debit(price)
두 요청이 모두 10,000을 읽으면 잔액 검사를 함께 통과한다. 각 요청이 상품을 발급받은 뒤 3,000을 저장하면 상품 두 개에 차감 하나만 남을 수도 있다. 잔액이 음수가 아닌지만 확인해서는 이 문제를 찾기 어렵다.
잔액 확보는 읽기와 저장을 분리하지 않고 한 조건부 갱신으로 표현한다.
UPDATE point_wallet
SET balance = balance - :price
WHERE user_id = :user_id
AND balance >= :price
RETURNING balance;
반환된 행이 없으면 발급을 시작하지 않는다. PostgreSQL READ COMMITTED에서 같은 행을 갱신하는 두 요청이 겹치면 뒤 요청은 앞선 갱신을 기다리고 변경된 행에 조건을 다시 평가한다. 첫 요청이 잔액을 3,000으로 만들면 두 번째 요청은 차감 조건을 만족하지 않는다. READ COMMITTED의 갱신 동작이 이 예제의 근거다.
하지만 차감 SQL 다음에 HTTP 호출을 둔다고 차감이 먼저 확정되는 것은 아니다. 둘을 같은 로컬 트랜잭션에 넣으면 외부 응답을 기다리는 동안 지갑 락과 DB 연결을 유지할 수 있다. 외부 발급이 끝난 뒤 로컬 트랜잭션이 롤백돼도 상대 시스템의 상품까지 되돌아가지는 않는다. 따라서 이 설계에서는 짧은 예약 트랜잭션을 커밋한 뒤 발급을 호출한다.
타임아웃에는 환급보다 결과 확인이 먼저다
응답이 없다는 사실은 발급 여부를 알려주지 않는다. 요청이 도착하지 않았을 수도 있고, 발급 전에 거절됐을 수도 있으며, 발급 후 응답만 사라졌을 수도 있다. 이 세 경우를 같은 실패로 처리하면 이미 발급된 상품에 환급이 붙을 수 있다.
주문은 RESERVED에서 시작한다. 확정 성공이면 SUCCEEDED, 발급하지 않았다는 확정 거절이면 보상을 거쳐 REFUNDED로 진행한다. 응답을 잃으면 UNKNOWN으로 남기고 같은 주문 번호로 결과를 조회한다. HTTP 500을 확정 거절로 해석할 수 있는지도 발급사 계약에 달려 있다.
아래 그림은 예약을 커밋한 뒤 외부 응답을 잃은 경로만 따라간다. 응답 유실 지점에서 환급으로 바로 넘어가지 않는 이유를 본다.
그림 1 응답 유실 뒤의 결과 확인 — 선차감 이후 응답을 잃으면 같은 주문의 결과를 확인한 뒤 성공이나 보상으로 진행한다.
결과 조회와 재전송의 안전성은 발급사의 계약에 달려 있다. 조회를 지원하지 않으면 거래 대사와 수동 확인 경로가 필요하다. 보상을 선택한 경우에도 잔액·환급 원장·주문 상태를 함께 확정해야 한다.
상태 이름보다 지켜야 할 조건은 간단하다. 한 교환에 차감과 환급은 각각 한 번만 기록하고, 성공과 환급을 동시에 확정하지 않는다.
재시도는 같은 구매 의도에 연결한다
조건부 차감은 잔액 경쟁을 다루지만 같은 구매 버튼의 재전송까지 구별하지는 못한다. 클라이언트는 같은 구매 의도의 재시도에 요청 키를 유지하고, 서버는 (user_id, request_key)에 고유성을 부여한다. 상품과 가격 기준도 함께 기록해 같은 키로 다른 내용을 보내는 경우를 거절한다.
예약의 핵심 순서는 요청 생성, 조건부 차감, 차감 원장 기록이다. 다음 코드는 생략한 저장소 구현을 전제로 한 흐름 예제다.
fun reserve(command: Command): Reservation =
requireNotNull(transactionTemplate.execute {
// UNIQUE(user_id, request_key), INSERT ... ON CONFLICT DO NOTHING
val inserted = orders.insertIfAbsent(command)
if (!inserted.created) {
return@execute orders.loadAndValidateSameRequest(command)
}
if (wallets.debitIfEnough(command.userId, command.price) != 1) {
throw InsufficientPointException()
}
ledgers.recordDebit(inserted.orderId, command.price)
orders.markReserved(inserted.orderId)
})
Command·InsertResult의 값은 Kotlin 프로퍼티로 표현했다. return@execute는 예약 함수 전체가 아니라 트랜잭션 콜백에서 기존 주문을 반환한다. TransactionTemplate.execute의 nullable 반환은 예약 결과가 있어야 한다는 계약으로 검사한다.
요청 생성과 차감이 같은 트랜잭션에 있어야 차감 실패 시 요청도 롤백된다. 이미 존재하는 요청은 내용을 검증하고 기존 상태를 반환한다. UNIQUE 제약은 사전 조회가 아니라 저장 시점에 고유성을 지킨다. PostgreSQL 오류로 실패한 트랜잭션에서 중복 예외를 잡고 계속 조회하는 방식 대신 충돌을 정상 결과로 다루거나 트랜잭션 밖에서 다시 조회한다.
외부 발급에도 로컬 주문 ID에서 정한 안정적인 번호를 사용한다. 발급사가 그 번호의 멱등성을 보장할 때만 재전송을 같은 발급에 연결할 수 있다. 로컬 고유 키가 외부 시스템의 중복 발급까지 자동으로 막는 것은 아니다.
예약과 발급 사이의 중단을 복구한다
예약 직후 프로세스가 종료되면 차감은 남지만 발급 호출은 없을 수 있다. 오래된 RESERVED 주문을 찾아 재처리하거나, 예약과 outbox를 함께 저장해 작업자에게 넘기는 방식이 필요하다. outbox를 선택해도 작업자가 발급 후 완료 표시 전에 종료될 수 있으므로 외부 멱등성 키와 결과 조회는 여전히 필요하다.
환급도 별도 불변식을 갖는다. 보상 가능한 주문 상태를 확인하고 잔액 증가, 환급 원장, REFUNDED 전환을 함께 커밋한다. 주문별 환급 고유 키를 두면 복구 작업과 콜백이 겹쳐도 같은 환급을 중복 기록하지 않게 설계할 수 있다. 상태만 바꾸거나 잔액만 늘리는 중간 커밋을 만들면 또 다른 복구 지점이 생긴다.
발급사가 멱등성과 결과 조회를 지원하지 않으면 자동 재발급의 근거가 부족하다. 이 경우에는 거래 대사와 수동 확인 경로까지 선택해야 한다.
확인할 것은 응답 코드보다 원장과 상태다
발급사 대역은 단순 예외뿐 아니라 상품 생성 후 응답 유실을 표현해야 한다. 같은 키의 동시 요청, 다른 키의 잔액 경쟁, 예약 직후 종료, 발급 직후 종료를 따로 재현한다. 이 예제의 잔액에서는 서로 다른 7,000포인트 교환 두 개 중 하나만 예약돼야 한다.
복구와 콜백을 겹쳐 실행할 때도 잔액이 원장 합계와 일치하고 성공 주문에는 환급이 없어야 한다. 예약 트랜잭션 시간과 외부 호출 시간을 분리해 보면 외부 지연이 지갑 락까지 늘리는지도 확인할 수 있다.
선차감은 발급을 시작할 자격을 확보하는 단계다. 결과 확인과 보상이 같은 주문에 연결돼야 응답 유실 뒤에도 교환을 확정할 수 있다.
