· 15분 읽기

한 주문에 문이 세 개 – 결제 완료 API·PG 웹훅·취소를 주문번호 분산락 하나로 줄 세우기


이 글에 나오는 시각(0ms, 40ms 같은 값)은 경합을 설명하려고 붙인 예시다. 실제로 이 경합 때문에 사고가 났다는 기록은 없다. 결제 흐름을 설계하면서 막아 둔 경합에 대한 기록이다.

1. 한 주문에 문이 세 개 있다

토스페이먼츠 결제 흐름을 새로 만들면서 주문 하나에 들어오는 요청을 입구별로 적어 봤다. 셋이었다.

  • 결제 완료 API. 고객이 결제창에서 승인하면 FE 가 부른다. 0원 결제와 가상계좌 발급도 이 경로에서 처리한다.
  • PG 웹훅. 가상계좌 입금 콜백과 결제 상태 변경 웹훅이 온다.
  • 결제 취소 API

이 셋은 서로 순서를 맞춰 주지 않는다. 몇 가지 장면을 떠올리면 금방 겹친다.

  • 고객이 결제 버튼을 두 번 누른다(따닥). 같은 주문의 결제 완료 요청이 두 개 들어온다.
  • 결제 완료 처리가 아직 도는 중에 고객이 취소를 누른다.
  • 가상계좌 입금 콜백이 도착하는 순간 고객이 그 주문을 취소한다.
  • 결제 완료 처리 중에 같은 주문의 결제 상태 변경 웹훅이 온다.

모두 같은 주문의 상태를 읽고 바꾸려는 요청이다. 그래서 처음부터 같은 주문의 요청은 한 번에 하나만 돌도록 주문번호 분산락을 설계했다.

2. 락이 없다면: 둘 다 자기가 처음이라고 믿는다

결제 결과를 반영하는 함수 앞에는 상태 가드가 있다. 주문의 지금 상태를 읽어 PG 결과로 만든 새 상태와 비교하고 이미 같으면 아무것도 하지 않고 성공을 돌려준다. 결제 완료 API 와 입금 콜백이 이 함수를 함께 지난다.

handlePaymentResult 의 멱등 가드: 주문의 oldStatus 와 PG 결과로 만든 newStatus 가 같으면 결제 로그만 채우고 success = true 로 바로 돌아간다
스냅샷 1 · 상태 가드. 이미 같은 상태면 처리하지 않는다. 비교는 DB 가 아니라 각 요청이 읽은 값으로 한다.

이 비교는 DB 가 해 주는 것이 아니다. 각 요청이 자기가 읽은 값으로 판단한다. 순서대로 두 번 오면 두 번째 요청은 첫 번째가 저장한 결제완료 를 읽고 돌아간다. 하지만 두 요청이 같은 순간에 결제대기 를 읽으면 둘 다 "상태가 바뀌었다"고 판단하고 둘 다 가드를 통과한다.

락이 없다고 가정하고 따닥 장면을 예시 시각으로 펼치면 이렇다. 주문 A1 은 카드 결제다.

시각(예시)결제 완료 요청 ①결제 완료 요청 ②
0ms요청 진입
40ms요청 진입
120ms주문 조회. 상태 결제대기
150ms주문 조회. 상태 결제대기
300ms토스 승인 결과 DONE
320ms같은 멱등키로 승인. 결과 DONE
340ms가드: 결제대기 ≠ 결제완료 → 통과
360ms가드: 결제대기 ≠ 결제완료 → 통과
400mscompleteOrder 실행completeOrder 실행
500ms주문 저장. 상태 결제완료주문 저장. 상태 결제완료
락이 없으면: 같은 주문의 두 요청이 각각 결제대기 상태를 읽고 둘 다 상태 가드를 통과해 결제완료로 바꾼 뒤 후속 처리를 실행한다. 재고가 두 번 차감되고 후속 처리가 두 번 돈다
그림 1 · 락이 없으면. 둘 다 같은 이전 상태를 읽었으니 둘 다 자기가 처음이라고 믿는다.

토스 승인은 멱등키 덕분에 두 번 일어나지 않는다. 마지막 줄의 주문 상태도 결제완료 하나다. 상태만 보면 아무 일도 없었던 것처럼 보인다. 문제는 그 사이에 두 번 돈 completeOrder 다. 이 함수를 따라가 보면 두 번 돌 때 일어나는 일은 이렇다.

  • 재고 차감. 재고 갱신은 조건 없이 수량을 $inc 로 더하고 빼는 방식이라 두 번 부르면 두 번 빠진다. 재고 쪽에 Mutex 가 있지만 프로세스 안의 Mutex 라 다른 서버 인스턴스에서 온 요청은 막지 못한다.
  • 적립 예정 마일리지 처리와 사용 마일리지 차감
  • 쿠폰 사용 처리
  • 장바구니 비우기

결제 이력 적재처럼 부분 유니크 인덱스가 한 번만 들어가게 지켜 주는 곳도 있지만 일부다. 대부분은 "두 번 부르면 두 번 일어나는" 코드다. 상태 가드는 요청이 한 줄로 서 있을 때만 제 역할을 한다. 줄을 세우는 일이 락의 몫이다.

3. 해결: 세 입구가 키 하나를 쓴다

세 입구 모두 같은 키 PAYMENT_PROCESS_V2_KEY::{주문번호} 로 락을 잡는다.

  • 결제 완료. 0원 결제와 가상계좌 발급도 이 락 아래에서 처리된다.
  • 결제 취소
  • 웹훅. 입금 콜백과 결제 상태 변경 웹훅 둘 다 같은 래퍼로 락을 잡는다.
V2 결제 완료: PAYMENT_PROCESS_V2_KEY:: + orderId 로 락을 얻고 tryLock(30, 120, SECONDS, randomNumber) 로 기다린 뒤 획득하지 못하면 ERR_PAYMENT_PROCESS_LOCK_TIMEOUT 응답을 돌려준다
스냅샷 2 · 결제 완료의 락. 키는 접두어 + 주문번호, 대기 30초·임대 120초.
withWebhookLock: 결제 완료와 같은 PAYMENT_LOCK_PREFIX 로 키를 만들고 tryLock(30, 120) 으로 기다리며 획득하지 못하면 예외를 던진다
스냅샷 3 · 웹훅 공통 락 래퍼. 결제 완료와 같은 상수로 키를 만든다.

처음 만들 때는 결제 처리 쪽과 웹훅 컨트롤러 쪽에 같은 값의 접두어 상수가 하나씩 있었다. 값은 같았지만 누가 한쪽만 고치면 두 입구가 다른 락을 잡게 된다. Redis 에게 락은 키 문자열이라 문자열이 한 글자만 달라도 서로를 막지 못한다. 그래서 웹훅 락을 결제 처리 쪽의 withWebhookLock 으로 옮기고 접두어 상수 하나를 세 입구가 함께 쓰게 했다. 같은 값을 두 번 적지 않으면 어긋날 일도 없다.

웹훅 가운데 주문 상태를 실제로 바꾸는 것은 가상계좌 입금 콜백이다. 입금이 확인되면 입금대기 주문을 결제완료 로 처리한다. 결제 상태 변경 웹훅은 락 안에서 PG 조회와 기록만 한다. 그래도 락을 잡는 이유가 코드 주석에 적혀 있다. "동일 orderNo 의 completePayment/cancel 과 경합을 피하기 위해 분산락은 유지한다."

한 키 아래의 순서: 같은 주문의 두 번째 결제 완료 요청이 PAYMENT_PROCESS_V2_KEY::{주문번호} 를 잡으려 한다. ① 먼저 온 요청이 락을 잡고 처리한다 ② 두 번째 요청은 락을 기다렸다가 얻는다 ③ 같은 멱등키로 승인을 다시 묻고 상태 가드에서 이미 결제완료임을 보고 성공을 돌려준다
그림 2 · 한 키 아래에서 결제 완료가 두 번 오면. 두 번째 요청은 기다렸다가 이미 끝난 일을 확인하고 돌아간다.

2장의 따닥 장면이 락 아래에서는 이렇게 된다.

시각(예시)결제 완료 요청 ①결제 완료 요청 ②
0msPAYMENT_PROCESS_V2_KEY::A1 락 획득
40ms같은 키에 tryLock. 대기 시작
500ms승인·주문 완료 처리 끝. 락 해제
510ms락 획득
600ms같은 멱등키 confirm:{paymentKey} 로 승인을 다시 묻는다. 토스 쪽에서 승인이 다시 일어나지 않는다
620ms상태 가드: 이미 결제완료 → 성공 반환. 후속 처리 없음

두 번째 요청은 첫 번째가 끝날 때까지 기다린다. 락을 얻은 뒤 같은 멱등키로 승인을 묻고 상태 가드에서 성공으로 돌아간다. 입금 콜백과 취소가 겹칠 때도 마찬가지로 한쪽은 같은 키에서 기다렸다가 앞 요청이 저장한 상태를 보고 판단한다.

4. 왜 주문번호여야 했나

락 키를 고르는 기준은 단순했다. 같이 돌면 안 되는 요청들은 모두 같은 키를 만들어야 하고 같이 돌아도 되는 요청들은 서로 다른 키를 만들어야 한다. 그리고 그 키는 락을 잡기 전에 손에 있어야 한다. 상태를 읽기 전에 락을 잡아야 하므로 키를 얻으려고 먼저 DB 를 읽어야 한다면 순서가 뒤집힌다.

후보세 입구가 락 전에 갖고 있나범위판단
전역 키 하나그렇다모든 주문뺐다. 한 주문 결제가 다른 주문 결제를 기다리게 만든다
paymentKey결제 완료만 갖고 있다결제 한 건뺐다. 취소 입력과 입금 콜백 바디에 없다
ownerId(고객)입금 콜백에는 없다고객 한 명뺐다. 같은 고객의 다른 주문까지 묶고 막으려는 불변식이 다르다
주문번호세 입구 모두 갖고 있다주문 한 건골랐다

전역 키는 가장 쉬운 안이다. 하지만 같은 주문끼리만 줄을 세우면 되는 일에 모든 결제를 한 줄로 세운다. 한 주문의 처리가 길어지면 상관없는 다른 주문의 결제까지 그만큼 기다린다.

paymentKey 는 결제 한 건을 가리키니 의미로는 그럴듯하다. 하지만 입력을 보면 쓸 수 없다. 결제 완료 입력에는 paymentKey, orderId, amount 가 있다. 취소 입력에는 paymentKey 가 없고 결제건은 주문번호로 찾는다. 가상계좌 입금 콜백 바디에는 orderId, status, secret 이 오고 paymentKey 는 우리가 저장해 둔 결제건에서 꺼낸다. paymentKey 를 키로 쓰려면 락을 잡기 전에 결제건을 먼저 읽어야 한다.

토스의 orderId 는 우리가 넘긴 주문번호다. 그래서 세 입구 모두 처음부터 이 값을 들고 온다. 여기에 상수 하나로 둔 접두어를 붙이면 세 입구가 똑같은 문자열을 만든다. 키는 값이 아니라 문자열로 맞아야 한다.

ownerId 락은 따로 있다. ID 당 구매 제한이 걸린 상품을 결제할 때다. 한 고객의 결제 두 건이 판정과 승인 사이를 동시에 지나가면 둘 다 같은 기구매 수량을 보고 통과해 상한이 뚫린다. 이건 주문 하나가 아니라 고객 한 명의 불변식이라 키가 달라야 한다.

구매 제한 락: 제한 상품이 있는 주문만 PURCHASE_LIMIT_KEY:: + ownerId 로 락을 잡고 주문 완료 처리까지 덮은 뒤 finally 에서 푼다. 주석에 락 순서와 데드락이 없는 이유가 적혀 있다
스냅샷 4 · 고객 단위 락은 주문번호 락 안쪽에서만 잡는다. 제한 상품이 없는 주문은 이 락을 잡지 않는다.

두 락이 겹치면 순서가 문제다. 여기서는 순서를 하나로 정했다. 바깥이 주문번호 락이고 안쪽이 ownerId 락이다. 웹훅과 취소는 안쪽 락을 잡지 않는다. 모든 경로가 이 순서로만 잡으니 서로 상대 락을 쥔 채 기다리는 상황이 생기지 않는다.

5. 락의 세부: 얼마나 기다리고 누가 푸나

5-1. 대기 30초, 임대 120초

세 입구 모두 tryLock(30, 120, TimeUnit.SECONDS, randomNumber) 를 쓴다. 30초 동안 락을 기다리고 잡은 락은 120초가 지나면 저절로 풀린다. 첫 구현은 임대 30초였고 0원 결제와 재고 부족 취소 처리를 손보던 변경에서 120초로 늘었다.

5-2. 못 잡았을 때: 입구마다 다르게 끝낸다

  • 결제 완료: 에러 로그를 남기고 ERR_PAYMENT_PROCESS_LOCK_TIMEOUT("결제 잠금 획득 시간이 초과되었습니다.") 응답을 돌려준다.
  • 취소: 같은 코드로 실패 응답을 돌려준다.
  • 웹훅: 예외를 던진다. 컨트롤러가 예외를 잡아 HTTP 500 으로 응답한다.

웹훅만 다른 이유는 상대가 사람이 아니라 PG 이기 때문이다. 200 으로 실패를 알려 주면 PG 는 전달에 성공했다고 보고 다시 보내지 않는다. 그래서 500 을 돌려주면 PG 가 다시 보내는 것을 전제로 했다. 웹훅이 30초 안에 락을 못 잡았다면 다른 요청이 같은 주문을 처리하는 중이다. 다음에 다시 왔을 때는 그 처리가 끝나 있을 테고 상태 가드에서 그대로 성공으로 돌아간다.

5-3. 락 주인은 스레드가 아니라 난수다

Redisson 의 락은 기본적으로 스레드 id 로 주인을 가린다. 잡은 스레드와 푸는 스레드가 같아야 한다. 이 서버는 리액티브 클라이언트와 코루틴 위에서 돈다. 코루틴은 멈췄다가 다른 스레드에서 이어질 수 있어서 락을 푸는 순간의 스레드가 잡을 때와 다를 수 있다. 스레드 기반 소유 판정이 이 실행 모델과 맞지 않는다.

그래서 스레드 id 자리에 SecureRandom 으로 만든 난수를 직접 넘긴다. 잡을 때 tryLock(..., randomNumber) 로 넘기고 풀 때 unlock(randomNumber) 로 같은 값을 넘긴다.

completePayment 의 마무리: 성공과 실패 두 갈래 모두 finally 에서 lock.unlock(randomNumber) 로 잡을 때 쓴 난수를 넘겨 락을 풀고 MDC 를 비운다
스냅샷 5 · 성공이든 실패든 잡을 때 쓴 난수로 푼다.

같은 이유로 로그 컨텍스트도 챙긴다. 락을 기다리기 전에 MDC 를 복사해 두고 락을 잡은 뒤 다시 넣는다. 스레드가 바뀌면 MDC 가 비기 때문이다. 락을 푼 뒤에는 MDC 를 지운다.

6. 락만 믿지 않는다

락은 "같은 주문의 처리가 동시에 돌지 않는다"만 보장한다. 순서대로 두 번 오는 요청, PG 쪽의 중복 승인, 위조된 콜백은 락이 막는 문제가 아니다. 그래서 막는 대상이 다른 장치를 겹쳐 뒀다.

겹겹이 막는 장치: 주문번호 분산락은 동시 실행, 상태 멱등 가드는 순차 재진입, Idempotency-Key 는 PG 중복 승인, 웹훅 secret 검증과 PG 재조회는 위조·변조 콜백, 취소 조건부 갱신은 취소 경합을 막는다
그림 3 · 층마다 막는 것이 다르다. 한 층이 놓친 것을 다음 층이 받는다.

6-1. 상태 멱등 가드: 순서대로 다시 와도 한 번만

2장에서 본 가드다. 혼자서는 동시에 들어온 두 요청을 막지 못한다. 락이 요청을 한 줄로 세우면 뒤에 선 요청은 앞 요청이 저장한 상태를 읽고 그때부터 가드가 제 역할을 한다. 락과 가드는 한 쌍이다.

6-2. Idempotency-Key: PG 에 승인을 두 번 요청해도

토스 승인 API 를 부를 때 confirm:{paymentKey} 를 멱등키 헤더로 싣는다. 같은 키로 다시 승인을 요청해도 토스 쪽에서 중복 승인이 일어나지 않는다. 취소도 각각의 멱등키를 쓴다.

결제 승인: paymentClient.confirmPayment 에 ConfirmRequest 와 함께 "confirm:${param.paymentKey}" 를 멱등키로 넘긴다
스냅샷 6 · 승인 요청에 결제건 단위 멱등키를 싣는다.

이 키가 막는 것은 토스 쪽의 중복 승인이다. 우리 DB 의 재고·마일리지가 두 번 움직이는 것은 막지 못한다. 게다가 웹훅 경로는 승인 API 를 부르지 않으니 이 키가 아예 관여하지 않는다. 웹훅과 결제 완료 사이의 중복은 락과 상태 가드의 몫이다.

6-3. 웹훅은 바디를 믿지 않는다

가상계좌 입금 콜백은 먼저 secret 을 대조한다. 저장해 둔 paymentKey 로 토스에 결제건을 조회해 그 secret 과 콜백의 secret 을 비교하고 다르면 거부한다. 통과하면 락 안에서 토스에 한 번 더 조회하고 그 조회 결과의 금액과 상태로 처리한다. 콜백 바디의 값으로 주문 상태를 바꾸지 않는다.

handleDepositCallback: secret 이 맞지 않으면 예외를 던진다. 락 안에서 paymentClient.getPayment 로 다시 조회한 totalAmount 로 처리한다
스냅샷 7 · secret 을 대조한 뒤 락 안에서 PG 에 다시 물어본 결과로 처리한다.

6-4. 취소는 조건부 갱신을 한 겹 더

취소는 주문번호 락을 잡은 위에 상태 전이를 조건부 갱신으로 한다. 주문번호와 "상태가 입금대기 또는 결제완료"를 조건으로 findAndModify 해 취소완료로 바꾼다. 다른 요청이 먼저 상태를 바꿔 놨으면 이 갱신은 아무것도 바꾸지 못한다.

7. 다른 길은 없었나

분산락이 유일한 답은 아니다. 다른 안을 이 코드베이스에 대 보면 이렇다.

안이 코드베이스의 상황바깥 문으로 쓰지 않는 이유
Mongo 조건부 갱신(상태를 조건으로 findAndModify)취소·배송 전이에는 쓴다문서 하나의 상태 전이는 원자적으로 바꾸지만 그 앞의 PG 호출과 뒤의 재고·마일리지·쿠폰 같은 부수효과를 한 단위로 묶지 못한다
낙관적 락(@Version)주문·결제 컬렉션에 없다충돌은 저장할 때 드러난다. 그때는 PG 호출과 일부 부수효과가 이미 지나간 뒤라 되돌릴 경로를 따로 짜야 한다
유니크 인덱스결제 이력(PAYMENT_DONE)에 부분 유니크 인덱스를 쓴다인덱스가 걸린 그 한 줄의 중복만 막는다. 조건 없는 재고 $inc 나 마일리지 처리는 막지 못한다
DB 트랜잭션PG 승인·조회는 외부 HTTP 호출이라 트랜잭션에 넣을 수 없다
Idempotency-Key 만으로승인·취소에 쓴다토스 쪽 중복 승인만 막는다. 웹훅은 승인 API 를 부르지 않는다
Redis SETNX 로 직접 구현쓰는 곳이 없다소유자 식별, 내 락인지 확인하고 푸는 해제, 대기와 재시도를 전부 직접 짜야 한다. Redisson 은 이 서버가 이미 쓰는 클라이언트다
웹훅을 무시하고 FE 호출만 믿기가상계좌는 고객이 결제창을 닫은 뒤에 입금한다. 그 시점에는 FE 호출이 없다. 입금을 결제완료로 바꾸는 신호는 입금 콜백뿐이다

조건부 갱신과 유니크 인덱스는 버릴 안이 아니다. 취소처럼 락 위에 한 겹 더 얹거나 이력 한 줄을 지키는 식으로 각자 막을 수 있는 범위에서 이미 쓰고 있다. 다만 "같은 주문의 결제 처리 전체가 한 번에 하나만 돈다"를 혼자 보장하지는 못한다.

8. 결과

  • 결제 완료(0원 결제·가상계좌 발급 포함), 취소, 웹훅 세 입구가 PAYMENT_PROCESS_V2_KEY::{주문번호} 하나로 직렬화된다. 접두어는 상수 하나라 입구끼리 어긋날 자리가 없다.
  • 실제 Redis 컨테이너를 띄운 통합 테스트로 확인했다. 같은 웹훅 10건을 동시에 보내고 응답과 최종 주문 상태가 결제완료인지 본다. 결제 완료를 승인 단계에서 붙잡아 둔 채 상태 변경 웹훅과 취소를 동시에 던지는 테스트에서는 임계 구역에 동시에 들어간 요청 수가 1 인지 단언한다.
  • 운영 반영 후 첫 30일 동안 웹훅 결제 처리의 락 획득 실패·락 타임아웃·서명 불일치(secret 불일치) 0건(운영 로그 기준).
동시성 통합 테스트: 같은 웹훅 요청을 async 로 10건 동시에 보내고 성공 수와 주문 상태 결제완료를 단언한다
스냅샷 8 · 같은 웹훅 10건을 동시에 보내는 테스트. 실제 Redis 와 Redisson 락을 쓴다.

9. 남은 것

  • 동시성 테스트가 부수효과 횟수를 직접 세지 않는다. 단언은 응답 성공 수와 최종 상태뿐이다. 2장에서 봤듯이 최종 상태는 중복 처리가 일어나도 결제완료 다. 테스트가 세는 타임아웃 코드도 지금 코드에는 없는 이전 값이다. 재고 차감 횟수나 PG 조회 횟수를 세는 단언을 붙여야 "1회만 처리"를 테스트가 직접 증명한다.
  • 웹훅 처리 실패가 200 으로 나가는 경로가 있다. 락 타임아웃이나 secret 불일치처럼 예외로 던지는 경우는 500 이 된다. 하지만 입금 콜백 처리 안에서 실패를 응답 객체로 바꿔 돌려주면 락 안의 처리 블록이 그 결과를 버리고 성공을 돌려준다. 이 경우 PG 는 다시 보내지 않는다.

요약

  • 주문 하나에 결제 완료 API, PG 웹훅(입금 콜백·상태 변경), 취소라는 세 입구가 있고 서로 순서를 맞춰 주지 않는다. 토스페이먼츠 결제 흐름을 새로 만들면서 처음부터 주문번호 분산락으로 줄을 세웠다.
  • 락이 없으면 두 요청이 같은 결제대기 를 읽고 둘 다 상태 가드를 통과한다. 최종 상태는 결제완료 하나라 정상처럼 보이지만 재고 차감과 마일리지·쿠폰·장바구니 처리가 두 번 돈다.
  • 세 입구는 상수 하나로 만든 PAYMENT_PROCESS_V2_KEY::{주문번호} 를 함께 쓴다. 키 문자열이 어긋나면 락이 서로를 막지 못하므로 접두어를 두 곳에 적지 않는다.
  • 키가 주문번호인 이유는 세 입구 모두 락을 잡기 전에 갖고 있는 식별자이고 범위가 주문 한 건이기 때문이다. 전역 키는 다른 주문까지 줄 세우고 paymentKey 는 취소와 입금 콜백 입력에 없다.
  • 고객 단위 불변식(구매 제한)은 ownerId 락으로 따로 두고 항상 주문번호 락 안쪽에서 잡아 데드락을 피한다.
  • 대기 30초·임대 120초로 잡고 못 잡으면 결제·취소는 실패 응답, 웹훅은 500 을 돌려 PG 가 다시 보내게 한다. 코루틴은 스레드를 옮겨 다니므로 락 주인은 난수로 넘긴다.
  • 락 뒤에 상태 멱등 가드, 토스 멱등키, 웹훅 secret 대조와 PG 재조회, 취소 조건부 갱신을 겹쳐 뒀다.
  • 조건부 갱신·유니크 인덱스·트랜잭션·멱등키는 막는 범위가 좁거나 외부 호출을 묶지 못한다. SETNX 직접 구현은 직접 짜야 할 것이 많고 FE 호출만 믿으면 가상계좌 입금을 놓친다. 그래서 바깥 문은 주문번호 분산락으로 뒀다.
  • 실제 Redis 컨테이너 통합 테스트로 직렬화를 확인했고 운영 반영 후 첫 30일 동안 웹훅 결제 처리의 락 획득 실패·락 타임아웃·서명 불일치(secret 불일치) 0건(운영 로그 기준)이다. 테스트가 부수효과 횟수를 직접 세지 않는 것과 웹훅 처리 실패가 200 으로 나가는 경로는 보강할 일이다.

댓글

댓글을 불러오는 중…