취소된 주문에 결제 완료가 도착했습니다
모놀리스를 서비스로 떼어낸 뒤, PeekCart의 주문은 세 서비스를 거쳐 끝납니다. Order가 주문을 만들고, Product가 재고를 예약하고, Payment가 Toss로 결제를 승인합니다. 세 서비스는 서로를 직접 부르지 않고 Kafka 토픽으로만 이벤트를 주고받습니다. 결제가 승인되면 Payment가 payment.completed를 발행하고, 주문이 취소되면 Order가 order.cancelled를 발행합니다. 중앙에서 순서를 지휘하는 쪽 없이 각 서비스가 받은 이벤트에 반응해 다음 이벤트를 내보내는 이 방식을 choreography saga라고 부릅니다.
이 흐름이 가장 곤란해지는 때는 두 이벤트가 엇갈릴 때입니다. 아래는 코드 경로를 근거로 구성한 설명용 예시이고, 운영에서 실제로 겪은 장애의 순서를 옮긴 것은 아닙니다.
- 사용자가 결제를 승인합니다. Payment는 Toss 승인 API를 호출합니다.
- 그 사이 Order의 타임아웃 스케줄러가 같은 주문을 만료로 판단해 취소하고
order.cancelled를 발행합니다. 사용자가 직접 취소해도 같습니다. - Toss 승인은 성공합니다. Payment는
payment.completed를 발행합니다.
돈은 빠져나갔는데 주문은 취소됐습니다. 되돌릴 방법은 환불뿐인데, 이 사건을 받은 서비스들은 저마다 다르게 처리하고 있었습니다.
Order는 이미 CANCELLED인 주문에 payment.completed를 받습니다. CANCELLED는 어디로도 나갈 수 없는 상태라 전이가 ORD-003 예외로 실패하고, 소비는 1초·5초·30초 간격으로 재시도한 뒤 payment.completed.dlq로 넘어갑니다. 그런데 DLQ를 읽는 운영 리스너는 하나도 없었습니다.
Product는 먼저 받은 order.cancelled로 재고 예약을 RELEASED로 되돌려 둔 상태입니다. 여기에 늦은 payment.completed가 오면 결제는 됐는데 재고는 확정되지 않은 PAID_BUT_UNRESERVED가 됩니다. Product는 예약 원장에 compensated_at 표시를 한 번 찍고 Slack 알림을 보냈습니다. 자동 환불은 없었고, 기존 계획은 운영자가 알림을 보고 직접 환불하는 것이었습니다.
기존 계획에도 이 경로의 끝은 있었습니다. 재시도 한계를 넘으면 환불 요청과 운영 알림으로 보상하고, 종료 상태를 미결로 남기지 않는다는 것이었습니다. 운영 알림은 만들어 두었지만 실제로는 아무에게도 가지 않았습니다. 환불 요청은 어떤 토픽으로, 누가 보내고, 누가 실행하는지조차 정해져 있지 않았습니다.
만들러 갔더니 saga는 이미 돌고 있었습니다
우선 해야 할 일은 saga 구현 단계에서 재고 예약·확정·복구 consumer, 예약 결과 토픽, 취소 이벤트 payload 보강, 예약 타임아웃 스케줄러를 만드는 것이었습니다. 앞의 둘은 모놀리스에서 서비스를 떼어낼 때 이미 만들어져 있었습니다.
그래서 할 일은 이미 도는 saga가 끝나지 않은 채 남는 경우를 없애는 것이 됐고, 완료 조건도 다시 정했습니다. 모든 saga 인스턴스는 정해진 시간 안에 종료 상태(CONFIRMED, RELEASED, FAILED, 환불 완료)에 도달해야 합니다. 도달하지 못하면 관측할 수 있는 신호와 처리할 수 있는 원장으로 드러나야 합니다. 로그나 알림만 남는 것은 종료 상태로 치지 않았습니다.
환불에 앞서 세 가지를 먼저 바꿨습니다.
- 동시 수정은 충돌로 드러나게 했습니다. Order에
@Version을 넣어, 취소와 결제 완료가 같은 주문을 동시에 고칠 때 나중 커밋이 앞 결과를 조용히 덮지 않게 했습니다. - 알림 대신 원장을 남기게 했습니다. 소비가 커밋되면
processed_events때문에 같은 이벤트를 다시 소비할 수 없으니, Slack 알림만 남기면 나중에 환불을 만들어도 입력이 사라진 뒤입니다. 그래서 소비와 같은 트랜잭션에order_compensations원장 행을OPEN으로 남기게 했습니다. 다만 이 원장 행을OPEN에서 다른 상태로 바꾸는 코드는 아직 없었습니다. order.cancelled의 뜻을 하나로 정했습니다. 취소 사유와 품목을 싣고 결제 실패로 인한 취소도 이 이벤트로 알리게 해, 모든 취소를 알리는 하나의 이벤트로 만들었습니다.
정리하면 상황은 이랬습니다. “이 주문은 환불이 필요하다”는 감지 결과가 Order의 원장과 Product의 표시에 따로 쌓입니다. 같은 사건을 두 서비스가 각자 감지하니 환불 하나에 요청이 둘 생길 수 있습니다. 그리고 환불을 실행할 수 있는 유일한 서비스, 결제 금액과 결제 키를 가진 Payment에는 아무 요청도 전달되지 않습니다.
환불은 Toss를 부르는 외부 호출입니다. 우리 DB 트랜잭션이 롤백돼도 Toss에서 일어난 취소는 되돌아오지 않습니다. 요청이 둘 생겨도 환불은 한 번만 일어나야 하고, 요청이 하나뿐이어도 환불이 빠지면 안 됩니다. 그렇다면 이 흐름에서 “한 번”은 어디서, 무엇을 세어 보장해야 할까요?
어떻게 접근할 수 있을까요?
질문을 셋으로 나눠 보겠습니다. 환불 요청을 Payment에 어떻게 전할지, 같은 주문의 요청이 여러 개 오면 무엇을 기준으로 하나로 합칠지, 합친 뒤 누가 Toss를 부를지입니다.
환불 요청은 이벤트로 전합니다
첫 질문의 답은 비교적 분명했습니다. 서비스끼리는 이미 토픽으로만 이야기하고 있었고, 세 서비스 모두 자기 DB에 outbox_events 테이블이 있었습니다. Outbox는 상태 변경과 “보낼 이벤트”를 같은 트랜잭션에 기록하고 별도의 poller가 그 행을 Kafka로 내보내는 방식입니다. 감지 기록이 커밋되면 이벤트도 언젠가는 나갑니다. 다만 한 번 이상 나갈 수 있습니다(at-least-once). 그래서 Order와 Product가 환불 요청 이벤트를 Outbox로 발행하고 Payment가 그것을 소비해 환불하는 구조가 자연스럽습니다.
같은 주문의 요청은 무엇으로 합칠까요?
소비자 쪽에는 이미 중복을 막는 장치가 있었습니다. 세 서비스 모두 processed_events에 (event_id, consumer_group) 유니크 키를 두고, 같은 메시지가 두 번 배달되면 두 번째를 건너뜁니다.
이 장치가 세는 단위는 메시지입니다. 같은 사건을 Order와 Product가 각각 감지해 요청을 발행하면 두 메시지의 eventId는 다릅니다. 시간이 지나 다시 발행된 메시지도 그렇습니다. 같은 eventId로 재발행할 수 있는 replay window는 7일(dlq-replay-window: 7d)이고, 이 기간을 넘긴 재발행은 새 eventId를 씁니다. processed_events는 어느 경우도 막지 못합니다.
그래서 세어야 할 단위는 주문입니다. PeekCart에서 주문 하나에는 결제 하나가 붙고(payments.order_id가 유니크입니다), 부분 환불은 없습니다. 환불은 주문당 전액 한 건입니다. orderId를 키로 같은 주문의 요청을 하나로 합치면 됩니다. 기존 계획도 처음부터 Payment가 소유하는 환불 요청 상태와 Outbox, orderId 또는 paymentKey 기반 멱등성, 성공·실패 종결 상태, 재시도 소진 시 운영 처리까지 정하는 방향이었습니다.
”있는지 보고 부른다”로는 하나로 합쳐지지 않습니다
orderId로 합치는 가장 쉬운 방법은 “이 주문의 환불 기록이 있는지 조회하고, 없으면 진행한다”입니다. 이 방법은 바로 앞 작업에서 한 번 틀렸습니다.
재고 예약에 만료 시간(이하 예약 lease)을 도입하면서, 결제 승인 직전에 예약이 아직 살아 있는지 검사해 만료된 예약의 승인을 막았습니다. 기존 계획은 이것으로 재고를 두 번 파는 문제(oversell)가 해결됐다고 봤습니다. 하지만 승인 코드는 검사를 통과한 뒤 같은 트랜잭션 안에서 Toss를 호출했고, 검사와 호출 사이에 예약이 만료돼 재고가 다른 주문에 넘어가는 순서가 그대로 일어날 수 있었습니다. 진입할 때 한 번 검사해도 그 뒤의 일은 막지 못합니다. 이 함정은 계획 점검표에 “계약 검사와 fence의 혼동”으로 올라갔습니다.
환불도 같습니다. 두 요청이 거의 동시에 도착해 같은 스냅샷을 읽으면 둘 다 “기록 없음”을 보고 진행합니다. 둘 중 하나만 통과시키는 것은 DB의 유니크 제약입니다. 같은 orderId로 행을 넣으려는 두 시도 중 하나만 성공합니다. 이렇게 경합에서 한쪽만 통과시키는 장치를 이 글에서는 fence라고 부르겠습니다.
행 삽입에 성공한 쪽이 Toss를 부르면 될까요?
여기까지 오면 설계가 거의 끝난 것처럼 보입니다. 환불 원장 테이블에 order_id 유니크를 걸고, 요청을 소비하는 쪽이 행을 넣어 봅니다. 넣기에 성공한 쪽만 Toss 취소 API를 부르고, 실패한 쪽은 이미 누가 처리 중이니 조용히 끝냅니다. 처음 세운 계약(서비스 사이의 보상·환불 약속을 정한 설계 문서)도 이 구조였습니다. payment_refunds.order_id 유니크를 fence로 삼고, 삽입에 성공한 쪽만 PG를 호출합니다. 중복 키로 실패한 삽입은 예외로 DLQ에 보내지 않고 정상 no-op으로 끝냅니다.
기존 계획은 이 작업의 약속을 “환불 API 호출 1회”로 잡았습니다. 두 결정을 함께 보면, 유니크 키 하나로 Toss 호출 횟수까지 한 번으로 묶을 수 있다고 기대한 것으로 보입니다.
그런데 이 구조를 코드와 맞춰 보자, 계획이 전제로 삼은 사실부터 어긋나 있었습니다.
해 보려니 이런 문제가 있었습니다
이 작업은 아래 순서로 진행했습니다.
- 계획: 기존 계획을 코드와 대조했습니다.
- 계약: 서비스 사이의 보상·환불 약속을 문서로 정했습니다.
- 구현 1: payment-service 안에서 끝나는 부분을 만들었습니다.
- 구현 2: 다른 서비스의 트리거와 회신을 붙였습니다.
계획의 전제가 코드와 달랐습니다
계획을 굳히기 전에 기존 계획이 전제로 삼은 사실을 코드와 다시 맞춰 봤더니, 세 가지가 달랐습니다.
| 계획의 전제 | 실제 코드 | 바뀐 것 |
|---|---|---|
| 감지 지점은 Product와 Order 두 곳이다 | Payment도 감지하고 있었습니다(아래에서 자세히) | 진입점이 셋이 됐고, Payment의 감지만으로 시작하는 안도 대안이 됐습니다 |
| Payment가 요청을 받아 환불을 실행한다 | TossPaymentClient에는 승인(confirm)만 있고 취소 API가 없었습니다. APPROVED는 어디로도 나갈 수 없는 종결 상태였습니다 | 취소 API와 “환불됨”을 표현할 상태가 범위에 들어왔습니다 |
| 기존 설계의 범위 안에서 다듬는다 | 기존 설계의 토픽 목록에 없는 새 토픽과, 여러 서비스에 걸친 새 상태머신이 필요했습니다 | 구현보다 보상·환불 계약을 먼저 정하기로 했습니다 |
앞 작업의 예약 lease는 기존 토픽에 필드를 더하는 일이라 기존 설계 안에서 다듬었지만, 이번에는 같은 방식으로 다룰 수 없다고 봤습니다. 코드를 대조한 결과가 범위와 선행 조건을 둘 다 바꿨습니다.
감지 지점은 셋이었습니다
세 번째 감지 지점은 Payment에 있었습니다. Payment는 order.cancelled를 소비하는데, 결제가 이미 APPROVED인 주문에 취소가 도착하면 이렇게 처리했습니다.
@KafkaListener(topics = "order.cancelled", groupId = GROUP_ORDER_CANCELLED)
@Transactional
public void handleOrderCancelled(String message) {
...
if (payment.cancelBeforePayment()) {
...
slackPort.send("[보상 필요] 과금 완료(APPROVED) 후 주문 취소 수신 — 환불 처리 필요, orderId=" + orderId);
}
}
세 지점이 남기는 기록도 서로 달랐습니다. Product의 compensated_at은 환불 전에 찍히는 감지 표시라 “요청됨”과 “해결됨”을 구분하지 못합니다. Order의 order_compensations는 DB에 남는 원장이지만 OPEN에서 바꾸는 코드가 없습니다. Payment는 Slack과 로그만 남기고, 소비가 커밋되면 processed_events 때문에 다시 소비할 수도 없습니다. Slack이 no-op이었으니 Payment가 감지한 사실은 debug 로그 한 줄 말고는 어디에도 남지 않았습니다. Order 쪽에 원장을 만들게 한 이유가 Payment 쪽에도 그대로 있었습니다.
”호출 1회”는 지킬 수 없는 약속이었습니다
가장 큰 문제는 약속의 문구였습니다. 유니크 키는 동시에 들어오는 두 요청 중 하나만 통과시킵니다. Toss 호출 전후에 프로세스가 죽는 경우는 막지 못합니다.
- fence 행을 커밋한 뒤 Toss를 부르기 전에 죽으면, 다음 요청은 fence에 막혀 “이미 누가 처리 중”이라며 끝납니다. 아무도 Toss를 부르지 않아 환불이 유실됩니다.
- Toss 취소가 성공한 뒤 그 결과를 로컬에 커밋하기 전에 죽으면, 로컬에는 아직 환불 안 됨으로 남습니다. 재시도하면 Toss를 다시 부릅니다.
어느 쪽이든 로컬 DB의 수단만으로는 Toss 호출 횟수를 1로 만들 수 없습니다. 달성할 수 없는 통과 조건입니다. 계획에 이런 조건이 있으면 구현이 그 조건을 충족했다고 착각하게 됩니다.
소비 트랜잭션 안에서 부르면 fence가 사라졌습니다
약속을 고친 뒤에도 “삽입에 성공한 쪽이 PG를 부른다”는 결정은 그대로였습니다. 이 결정에는 어느 트랜잭션 안에서 부르는지가 빠져 있었습니다.
요청을 소비하는 메서드는 @Transactional입니다. fence 삽입과 processed_events 기록이 한 트랜잭션에 묶여야 소비가 원자적이기 때문입니다. 그 안에서 Toss를 부르면 이런 순서가 생깁니다. 반례를 순서로 풀어 쓴 설명용 예시입니다.
- 요청 A를 소비하는 트랜잭션이
payment_refunds에 행을 넣습니다. 아직 커밋 전입니다. - 같은 트랜잭션 안에서 Toss 취소를 호출하고, 성공합니다. 고객 카드의 결제는 취소됐습니다.
- 결과를 저장하고 커밋하려는 순간 실패합니다. 커밋 실패, 커넥션 단절, 프로세스 종료 무엇이든 됩니다.
- 트랜잭션이 롤백됩니다. 1번에서 넣은 fence 행도 함께 사라집니다.
- 요청 A가 재시도되거나 요청 B가 도착합니다. fence 행이 없으니 삽입이 성공하고, Toss를 다시 부릅니다.
트랜잭션 안에서 Toss를 부르면
요청 A의 소비 트랜잭션이 fence 행을 넣고 Toss를 부른 뒤 커밋에 실패합니다. 행과 Toss 취소 횟수를 따라가 보세요.
본문의 설명용 예시를 옮긴 모형입니다. 실패 원인은 "커밋 실패" 하나로 단순화했고, Kafka 재시도 간격, PG 멱등키, 오래된 CLAIMED를 Toss 조회로 확정하는 reconciliation은 그리지 않았습니다. 주문 번호 7은 예시입니다.
PG 호출 위치를 바꾼 구조는 해결책을 다루는 절에서 같은 장면으로 다시 봅니다.
fence는 “이 주문의 환불은 이미 시작됐다”는 기록입니다. 그 기록이 외부 호출 결과와 같은 트랜잭션에 묶여 있으면, 외부 호출은 성공했는데 기록은 롤백되는 순간이 생기고, 그 순간 fence는 아무것도 막지 못합니다. 그래서 PG를 호출하는 주체가 따로 있어야 했습니다.
Payment DB의 유니크 키로는 다른 서비스의 재발행을 막을 수 없었습니다
배포 시점의 문제도 있었습니다. 계약을 배포하는 순간에도 이미 compensated_at이 찍힌 예약 행과 OPEN 원장 행이 있습니다. 새 코드는 새로 감지할 때만 요청을 발행하므로 기존 행은 영영 요청을 만들지 못합니다. 그래서 각 서비스의 마이그레이션에서 기존 행마다 요청 Outbox를 한 번씩 만드는 backfill을 두기로 했습니다.
처음 계약은 이 backfill을 다시 실행해도 안전한 근거로 payment_refunds.order_id 유니크를 들었습니다. 그런데 그 유니크 키는 Payment의 DB에 있습니다. 서비스마다 DB가 나뉜 구조에서 Payment DB의 제약은 Product나 Order가 자기 DB에 Outbox 행을 다시 만드는 것을 막지 못합니다. 환불이 두 번 일어나지는 않겠지만 요청은 두 번 발행됩니다. 서로 다른 DB에 있는 두 보장을 하나처럼 다룬 것입니다.
정리하면
처음 그린 설계, “행 삽입에 성공한 쪽이 Toss를 부른다”에는 네 가지가 빠져 있었습니다.
- 감지 지점은 셋이었고, Payment에는 환불을 실행할 수단이 없었습니다.
- 유니크 키는 동시 요청을 하나로 줄일 뿐, 호출 전후의 crash는 막지 못합니다. “호출 1회”는 지킬 수 없는 약속이었습니다.
- fence와 Toss 호출이 한 트랜잭션에 있으면, 롤백이 fence를 지우고 다음 요청이 다시 부릅니다.
- 한 DB의 유니크 키는 다른 DB의 중복 발행을 막지 못합니다.
어디서 무엇을 세야 이 네 가지를 함께 해결할 수 있을까요?
좀 더 적절한 해결책은 무엇일까요?
약속부터 다시 썼습니다
먼저 지킬 수 있는 약속으로 바꾸고, 보장하는 것과 보장하지 않는 것을 함께 정했습니다.
- 보장하는 것: 같은 주문에 대해 실제로 처리되는 환불은 최대 1건입니다.
- 보장하지 않는 것: PG API 호출 1회. 외부 호출 전후에 프로세스가 죽는 경우는 로컬 DB만으로 막을 수 없습니다.
세는 대상을 Toss 호출 횟수에서 고객에게 실제로 처리된 환불 건수로 옮겼습니다. 호출이 두 번 일어날 수 있다고 인정하면 다음 질문이 따라옵니다. 두 번째 호출이 두 번째 환불이 되지 않게 하려면 무엇이 필요할까요?
진입점과 실행자를 나눴습니다
fence가 사라진 원인은 “fence 행을 넣는 일”과 “Toss를 부르는 일”이 한 트랜잭션에 있었기 때문입니다. 그래서 둘을 다른 주체에게 맡겼습니다.
- 진입점은
payment_refunds에REQUESTED행을 넣고 커밋하는 것으로 끝납니다. Toss를 부르지 않습니다. 진입점은 셋입니다. Product가 보낸 요청의 소비자, Order가 보낸 요청의 소비자, 그리고 Payment 자신의 감지입니다. Payment는 실행자이기도 해서 자기가 감지한 경우에는 이벤트를 거치지 않고 같은 행을 직접 넣습니다. - **실행자(dispatcher)**는 payment-service의 스케줄러입니다.
REQUESTED행을CLAIMED로 바꾸는 조건부 UPDATE를 실행하고, 이 UPDATE가 1행을 바꾼 쪽만 Toss를 부릅니다. 바뀐 행 수로 승자를 가리는 이런 갱신을 CAS(compare-and-set)라고 부릅니다.
dispatcher는 claim할 때 claimed_at을 남깁니다. 그래서 claim은 시간이 지나면 만료되는 잠금처럼 동작합니다. 앞의 예약 lease와 구분해 이 글에서는 claim lease라고 부르겠습니다. 행 하나의 상태는 이렇게 흘러갑니다. 결과를 알 수 없는 경우는 아래에서 따로 다룹니다.
진입점 INSERT → REQUESTED → dispatcher CAS → CLAIMED → Toss 결과 → SUCCEEDED | FAILED
이렇게 나누면 fence 행은 Toss 호출과 다른 트랜잭션에 있습니다. 진입점이 커밋한 행은 Toss 호출이 어떻게 끝나든 사라지지 않습니다. 앞의 장면에서 PG 호출 위치를 바꿔 같은 다섯 단계를 다시 보면 차이가 드러납니다.
PG 호출 위치를 옮기면
같은 다섯 단계를 두 구조로 비교합니다. 커밋 실패 뒤에 payment_refunds 행이 남는지, Toss 취소 횟수가 몇이 되는지 보세요.
본문의 설명용 예시를 옮긴 모형입니다. 실패 원인은 "커밋 실패" 하나로 단순화했고, Kafka 재시도 간격, PG 멱등키, 오래된 CLAIMED를 Toss 조회로 확정하는 reconciliation은 그리지 않았습니다. 주문 번호 7은 예시입니다.
세 진입점이 같은 order_id로 행을 넣으려 해도 유니크 제약 때문에 1행만 남고, 나머지 시도는 예외 없이 정상적인 no-op으로 끝납니다. 요청이 어떤 순서로 몇 번 오든 실행 경로는 하나로 합쳐집니다. 서로 다른 토픽 사이의 도착 순서는 보장되지 않습니다. 그래서 순서에 기대는 로직은 두지 않았고, 같은 결과로 모으는 일은 상태머신과 fence에만 맡겼습니다.
남은 crash는 Toss 조회로 확정했습니다
나눈 뒤에도 프로세스는 어디서든 죽을 수 있습니다. 계약은 죽는 시점을 네 칸으로 나눠 복구 규칙을 정했습니다. 표는 일어나는 순서대로 적었습니다.
| # | 죽는 시점 | 남는 상태 | 위험 | 복구 |
|---|---|---|---|---|
| a | 진입점이 REQUESTED 커밋 직후 | REQUESTED | 없음 | dispatcher가 다음 주기에 claim |
| b | claim 커밋 후, Toss 호출 전 | CLAIMED(claimed_at 오래됨) | 환불 유실 | 오래된 claim을 회수해 Toss에 조회. 취소되지 않았으면 같은 멱등키로 재호출, 취소됐으면 SUCCEEDED 확정 |
| c | Toss 타임아웃·응답 유실 | UNRESOLVED | 결과 불명 | 상태로 남겨 두고 조회로 확정 |
| d | Toss 성공 후, 로컬 커밋 전 | CLAIMED(claimed_at 오래됨) | 중복 호출 | b와 같은 경로. 조회가 이미 취소됨을 알려 주면 호출 없이 SUCCEEDED |
a 칸에 위험이 없는 것이 분리의 이득입니다. 진입점은 Toss를 부르지 않으니 어디서 죽어도 외부에는 아무 일도 일어나지 않습니다.
나머지 세 칸은 같은 규칙으로 모입니다. 로컬 기록만 보고 판단하지 않고, Toss에 물어 실제 결과를 확인합니다. 그래서 결제 조회 API가 이 계약에 꼭 필요해졌습니다. 오래된 CLAIMED와 UNRESOLVED를 주기적으로 claim해 이 조회를 하는 잡을 reconciliation이라고 부릅니다. Toss 취소 호출에는 orderId에서 만든 안정적인 멱등키(Idempotency-Key 헤더)도 붙였습니다. 재호출이 일어나도 Toss가 같은 요청으로 알아보게 하려는 것으로, 로컬 fence와 따로 동작하는 PG 쪽 중복 방지 장치입니다.
대가도 있습니다. 소비하는 순간 바로 환불하지 않으므로 환불이 dispatcher 주기만큼 늦어질 수 있고, payment-service에 스케줄러가 둘(dispatcher, reconciliation) 늘었습니다. 환불이 조금 늦어지는 대신, 어디서 프로세스가 죽어도 fence 행은 사라지지 않게 됐습니다.
토픽은 발행자마다 하나씩 뒀습니다
요청을 실어 나를 토픽으로 네 가지를 검토했습니다.
| 선택지 | 내용 | 결론 |
|---|---|---|
| 발행자별 토픽 2개 + 회신 1개 | stock.compensation.requested(Product) · order.compensation.requested(Order) · payment.refunded(Payment) | 채택 |
| 공용 요청 토픽 1개 | refund.requested를 Product와 Order가 함께 발행 | 기각. 기존 일곱 토픽이 모두 지키던 “토픽 하나에 발행자 하나” 규약을 처음으로 깹니다. 아끼는 토픽은 하나인데, 토픽 생성과 payload 스키마의 소유자가 모호해집니다 |
| Payment 로컬 시작 | Order는 발행하지 않고, Payment가 order.cancelled를 소비하다 감지한 것만으로 시작 | 기각. Product 경로는 Outbox로 전달이 보장되는데 Order 경로만 Payment의 소비가 성공해야 시작됩니다. 그 소비가 DLQ로 빠지면 Order 원장은 OPEN인 채 다시 트리거할 수단이 없습니다 |
| 기존 토픽 재사용 | order.cancelled를 환불 트리거로 | 기각. PAID_BUT_UNRESERVED는 취소와 무관하게도 생기고, 막 “모든 취소를 알리는 이벤트”로 정한 order.cancelled의 뜻이 다시 흐려집니다 |
세 번째 안은 Payment의 감지 지점을 찾아낸 덕분에 가능해진 안입니다. 하지만 이 작업의 목적이 OPEN으로 쌓이는 Order 원장 행을 종결 상태로 옮기는 것이었으니, 그 경로만 보장이 없어지는 안은 받아들일 수 없었습니다.
요청 payload에는 orderId·reason(PAID_BUT_UNRESERVED 또는 PAID_BUT_CANCELLED)·detectedAt만 싣고 금액은 싣지 않았습니다. 금액을 정하는 주체는 결제 금액을 가진 Payment입니다. 발행자가 실어 보낸 금액을 믿으면 금액의 출처가 둘로 갈라집니다.
환불의 진행 상태는 별도 원장에 뒀습니다
APPROVED가 종결 상태인 결제 상태에 환불을 넣는 방법으로 두 안을 비교했습니다. 결제 상태 enum에 진행 상태까지 늘리는 안과 별도 원장 payment_refunds를 두는 안입니다.
결정을 가른 것은 crash 복구였습니다. 위 표의 복구는 과거에 몇 번 시도했고 무슨 오류가 났는지에 기대는데, enum을 늘리는 안에는 그 기록을 둘 자리가 없습니다. fence로 쓸 유니크 키도 생기지 않습니다. @Version 같은 낙관 락은 경합이 있었다는 것을 알려 줄 뿐, 두 번째 시도를 막는 fence 역할은 하지 못합니다. 그래서 payments.status에는 종결 상태 REFUNDED 하나만 더하고 진행 상태는 원장이 맡았습니다.
payment_refunds: order_id UNIQUE · payment_key · amount · user_id
status REQUESTED → CLAIMED → SUCCEEDED | FAILED | UNRESOLVED
UNRESOLVED → SUCCEEDED | FAILED (reconciliation)
UNRESOLVED → FAILED (수동 종결, 감사 필드 필수)
원장 상태는 회신 이벤트로 바꿉니다
환불이 Payment에서 끝나도 Order의 원장과 Product의 표시는 그 사실을 모릅니다. 원장의 주인과 환불의 실행자가 다르기 때문입니다. 결과를 돌려보내는 방법으로는 회신 이벤트 말고도 두 안이 있었습니다. 원장은 감사 기록으로만 두고 종결은 Payment 상태로 판단하는 안은 Order 원장을 영원히 OPEN으로 남깁니다. 운영자가 직접 상태를 바꾸는 안은 “미결로 남기지 않는다”를 사람의 성실성에 맡기는데, Slack이 no-op인 배포에서는 그 사람에게 통지조차 가지 않습니다.
회신 payment.refunded를 받은 쪽은 결과에 따라 상태를 다르게 바꿉니다.
result | Order order_compensations | Product |
|---|---|---|
SUCCEEDED | OPEN → RESOLVED | 종결 시각 기록 |
FAILED | OPEN → REFUND_FAILED(+failure_code) | 실패로 기록 |
UNRESOLVED는 회신으로 발행하지 않습니다. 확정되지 않은 결과로 원장 상태를 바꾸면 원장이 실제와 달라지기 때문입니다. reconciliation이 SUCCEEDED나 FAILED로 확정한 뒤에야 회신이 나갑니다.
처음 초안은 결과와 상관없이 RESOLVED로 바꿨습니다. RESOLVED는 환불 완료라는 뜻인데, 실패한 환불까지 RESOLVED로 바꾸면 원장 상태가 실제와 달라집니다. 그래서 처리는 끝났지만 환불은 되지 않았음을 뜻하는 REFUND_FAILED를 따로 뒀습니다. Product의 compensated_at은 감지 표시라서 종결은 별도 컬럼에 적기로 했습니다.
사용자에게는 성공만 알립니다. Notification은 payment.refunded 중 SUCCEEDED만 소비해 환불 완료를 알립니다. 실패와 미확정은 사용자에게 알리지 않고 내부에서 처리하기로 했습니다.
여기까지 정한 것을 한 장면에 모으면 환불 한 건은 아래처럼 왕복합니다. 결과를 바꿔 회신에 따라 원장 상태가 어떻게 달라지는지 비교해 보세요.
환불 한 건의 왕복
같은 주문 7의 환불이 감지부터 회신까지 어디를 지나는지 봅니다. 결과를 바꿔 회신에 따라 원장 상태가 어떻게 달라지는지 비교해 보세요.
crash가 없는 경로만 그린 모형입니다. Outbox poller와 Kafka 전달 지연, 재시도, reconciliation의 Toss 조회, 종결된 fence에 온 요청의 회신 재발행은 그리지 않았습니다. 요청 세 개가 모두 도착하는 순서와 주문 번호 7은 설명용 예시입니다.
끝나지 않는 상태에도 끝을 정했습니다
UNRESOLVED는 종결이 아닙니다. 초안은 reconciliation이 이 상태를 해소한다고 해 놓고, 상태머신에는 UNRESOLVED에서 나가는 전이를 두지 않았습니다. 이 모순을 고친 최종 계약에서는 payment-service의 reconciliation 잡이 5분마다 오래된 CLAIMED와 UNRESOLVED를 Toss 조회로 확정합니다. 조회 상한은 24시간이고, 그 안에 확정되지 않으면 운영 알림과 함께 수동 종결로 넘깁니다. 수동 종결에는 누가 무엇을 근거로 종결했는지 기록을 남깁니다.
24시간 안에 확정되지 않는 건은 자동으로 처리하지 못합니다. 대신 이런 건이 쌓이면 바로 보이도록 미해결 원장 건수와 가장 오래된 건의 나이를 게이지로 노출합니다. 이 게이지가 없으면 “미결로 남기지 않는다”는 약속을 확인할 방법이 없습니다.
Toss가 ALREADY_CANCELED(이미 취소됨)를 돌려주는 경우도 실패로 보지 않았습니다. 앞선 호출이 성공하고 응답만 잃어버렸을 수 있기 때문입니다. 바로 FAILED로 확정하면 실제로는 환불된 결제가 APPROVED로 남습니다. 이 경우에도 조회로 취소 금액을 확인해 전액 취소면 SUCCEEDED, 금액이 다르면 FAILED, 조회가 안 되면 UNRESOLVED로 나눴습니다.
두 보장이 서로 다른 DB에 있다는 것을 계약에 넣었습니다
backfill의 근거는 각 발행자의 DB 안으로 옮겼습니다. 마이그레이션은 INSERT ... SELECT ... WHERE NOT EXISTS (해당 주문의 요청 Outbox 행)으로 요청을 만들고, 다시 실행해도 추가 발행이 0인 것은 이 조건이 보장합니다. 그래도 Product와 Order가 같은 주문을 각자 감지해 중복 발행이 생기면, 환불이 한 건인 것은 Payment의 fence가 따로 보장합니다. 두 보장은 서로 다른 DB에서 동작하므로 한쪽이 다른 쪽을 대신할 수 없습니다.
같은 원칙으로 감지 기록과 요청 Outbox 행은 같은 트랜잭션에서 만들게 했습니다. 감지는 커밋됐는데 요청이 없는 부분 커밋이 생기면 그 건은 영원히 처리되지 않은 채 남기 때문입니다.
네 문제와 해결 방법
앞에서 찾은 네 문제는 이렇게 해결했습니다.
| 문제 | 해결 방법 |
|---|---|
| 감지 지점은 셋인데 Payment에는 실행 수단이 없었다 | 발행자별 요청 토픽 둘과 Payment 로컬 감지가 같은 payment_refunds 행으로 모이고, Toss 취소·조회 API와 REFUNDED 상태를 더했습니다 |
| ”호출 1회”는 지킬 수 없는 약속이었다 | 약속을 “같은 주문의 환불 최대 1건”으로 바꾸고, 결과를 알 수 없는 건은 Toss 조회로 확정합니다 |
| 롤백이 fence를 지웠다 | 진입점은 행을 커밋하고 끝나며, Toss는 트랜잭션 밖의 dispatcher가 부릅니다 |
| 한 DB의 유니크 키로 다른 DB의 재발행을 막으려 했다 | backfill은 발행자 DB의 NOT EXISTS로, 환불 1건은 Payment의 fence로 따로 보장합니다 |
최종적으로 이렇게 됐습니다
계약은 두 번에 나눠 구현했습니다. 먼저 payment-service 안에서 끝나는 부분(환불 원장, dispatcher, reconciliation)을 만들고, 그다음 다른 서비스들의 트리거와 회신을 붙였습니다.
환불 설정
payment-service의 환불 정책은 app.refund 아래에 모였습니다.
refund:
claim-lease: 5m
pg-timeout: 10s
max-attempts: 3
retry-backoff: 2s
batch-size: 5
max-batches-per-run: 2 # 앞 배치가 계속 미확정이어도 뒤 배치가 굶지 않게 한다
unresolved-limit: 24h # 초과 시 자동 확정 포기 → 운영 알림 + 수동 종결(감사 필드 필수)
dispatch-interval-ms: 30000
reconcile-interval-ms: 300000
lock-at-most-for: 20m # 1회 실행 최악(건당 66s × 5 × 2 = 11m) 보다 길어야 함(부팅 시 fail-fast)
dispatcher는 30초마다 REQUESTED 행을 claim하고, reconciliation은 5분마다 5분이 지난 CLAIMED와 UNRESOLVED를 claim합니다. Toss 호출 하나는 최대 10초씩 세 번 시도합니다. 이 설정으로 계산하면 환불 시작은 최대 30초 남짓 늦어질 수 있습니다. 측정한 값은 아니고, Outbox poller와 Kafka 전달 지연은 넣지 않은 계산입니다. Toss 취소에는 refund-{orderId}를 멱등키로 붙여서, 최초 호출과 재시도와 reconciliation의 재호출이 모두 같은 값을 보냅니다.
fence를 만드는 SQL 한 줄이 틀려 있었습니다
fence 삽입은 처음에 INSERT ... ON DUPLICATE KEY UPDATE id = id로 짰습니다. 중복이면 아무것도 바꾸지 않는 UPDATE로 넘어가니, 영향 받은 행 수가 1이면 새로 넣은 것이고 0이면 중복이라고 판단할 생각이었습니다. 실제로 돌려 보니 중복에서도 1이 돌아왔습니다. 두 진입점이 모두 자기가 행을 넣었다고 판단했습니다.
MySQL 문서에 따르면 이 구문의 영향 행 수는 새로 넣으면 1, 기존 행을 바꾸면 2, 기존 행을 같은 값으로 두면 0입니다. 다만 연결할 때 CLIENT_FOUND_ROWS 플래그를 주면 같은 값으로 둔 경우에도 1을 돌려줍니다(MySQL 8.0 Reference Manual, INSERT … ON DUPLICATE KEY UPDATE).
프로젝트가 쓰는 MySQL Connector/J 9.6.0은 useAffectedRows의 기본값이 false이고, 이 옵션의 설명은 “CLIENT_FOUND_ROWS 플래그를 설정하지 않는다”입니다. 기본값이 false이니 드라이버는 기본으로 이 플래그를 켠 채 연결합니다. 문서와 기본값, 아래 실험을 합쳐 보면 드라이버는 바뀐 행 대신 찾은 행을 세고 있었던 것으로 설명됩니다.
이 동작을 직접 확인했습니다. 일회용 mysql:8.0 컨테이너(서버 8.0.46)에 프로젝트와 같은 Connector/J 9.6.0으로 JDBC를 직접 호출해, 같은 order_id로 두 번 삽입해 봤습니다. 비교를 위해 useAffectedRows=true를 준 경우에는 ON DUPLICATE KEY UPDATE id = id의 두 번째 삽입이 0을 돌려줬고, 옵션을 주지 않은 기본 설정에서는 같은 문장이 1을 돌려줬습니다. INSERT IGNORE는 두 설정 모두 두 번째 삽입에서 0이었습니다. 애플리케이션 코드를 거치지 않은 최소 구성이라, 이 실험으로 말할 수 있는 것은 이 서버·드라이버 조합에서 두 구문이 돌려주는 값까지입니다.
그래서 INSERT IGNORE로 바꿨습니다. 중복으로 건너뛴 행은 0으로 보고되므로 승자가 정확히 한 명이 됩니다.
/**
* <p>{@code ON DUPLICATE KEY UPDATE id = id} 를 쓰지 않는 이유: MySQL Connector/J 는 기본이
* <b>found-rows</b> 시맨틱이라 값이 바뀌지 않은 중복도 <b>1</b> 로 보고한다 — 그러면 두 진입점이
* 모두 "내가 fence 를 잡았다"고 판단한다(실측으로 확인). {@code INSERT IGNORE} 는 건너뛴 행을
* 0 으로 보고해 승자를 정확히 1명으로 만든다.
*/
@Query(value = """
INSERT IGNORE INTO payment_refunds
(order_id, payment_key, user_id, amount, status, attempts, requested_at)
VALUES (:orderId, :paymentKey, :userId, :amount, 'REQUESTED', 0, :now)
""", nativeQuery = true)
int insertRequestedIfAbsent(...);
JPA로 save하고 유니크 위반 예외를 잡아 no-op으로 처리하는 방법도 쓰지 않았습니다. JPA에서 유니크 위반은 save를 부른 줄에서 터지지 않고 flush 시점에 터지며, 그때는 이미 트랜잭션이 rollback-only로 표시돼 있습니다. 예외를 잡아도 소비 트랜잭션 전체가 롤백됩니다.
첫 구현에서 나온 결함들
첫 구현에는 이 글의 질문과 직접 관련된 결함이 둘 있었고, 그 수정이 새 결함 둘을 만들었습니다.
첫째, dispatcher가 오래된 CLAIMED 행을 조회 없이 다시 claim해 Toss를 다시 부르고 있었습니다. 계약은 crash 표의 b·d 칸에서 재호출 전에 조회를 요구했는데, 그 경로가 코드에 아예 없었습니다.
더 곤란한 것은 테스트였습니다. 테스트는 “오래된 claim이 다시 claim된다”를 성공 기준으로 삼아, 계약과 정반대인 구현 동작을 그대로 기대값으로 고정하고 있었습니다.
그래서 claim을 둘로 나눴습니다. dispatcher는 REQUESTED만 claim하고(claimRequested), 오래된 CLAIMED와 UNRESOLVED는 reconciliation만 claim해 조회부터 합니다(claimForReconcile). 테스트도 오래된 CLAIMED는 dispatcher의 후보가 아님을 단언하도록 다시 썼습니다.
둘째, claim lease가 만료된 옛 실행자가 뒤늦게 결과를 확정하는 경합입니다. 5분짜리 claim이 만료돼 다른 실행자가 같은 행을 claim했는데 옛 실행자의 Toss 호출이 그제야 끝나 결과를 쓰면 두 실행자가 모두 확정합니다. claim할 때마다 generation을 1씩 올리고, 확정할 때 내가 claim했을 때의 generation과 지금 값이 같은지 확인하게 했습니다. 이런 번호를 fencing token이라고 부릅니다.
수정한 코드에서 다시 8건이 나왔고, 그중 2건은 앞선 수정에서 생긴 것이었습니다.
- generation을 잠그지 않고 읽었습니다. 검사와 커밋 사이에 다른 claim이 끼어들 수 있어서, 확정 경로에서 행을
SELECT ... FOR UPDATE로 잠그고 읽게 했습니다. UNRESOLVED를 claim해도 상태가 그대로였습니다. 그러면 reconciliation이 조회하고 있는 행을 운영자가 수동 종결할 수 있고, 그 직후 Toss 취소가 성공해도 원장은 이미FAILED입니다. 환불은 됐는데 원장에는 실패로 남는 상태입니다. claim하면UNRESOLVED도CLAIMED로 바꾸게 했습니다.
수정 뒤에 다시 확인하지 않았다면 두 결함 모두 남았을 것입니다.
이 단계가 끝났을 때는 회신을 받는 쪽이 아직 없어서 Order 원장은 여전히 OPEN으로 쌓였습니다.
세 진입점이 하나의 fence로 모였습니다
다음 작업에서 Product와 Order의 트리거, 그리고 세 서비스의 회신 소비를 붙였습니다. 진입점 세 곳은 모두 payment_refunds.order_id에 삽입만 시도하고, 중복은 정상 no-op으로 끝납니다. 그래서 코드에는 토픽 사이의 도착 순서에 기대는 로직이 없습니다. 요청 두 종류와 Payment 로컬 감지가 어떤 순서로, 몇 번 겹쳐 오더라도 결과가 같은지를 선후·중복 조합을 모두 나열한 통합테스트로 고정했습니다. 아래에서 세 진입점을 원하는 순서로, 여러 번 눌러 보세요.
세 진입점, 행 하나
같은 주문 7에 대해 세 진입점이 환불을 요청합니다. 원하는 순서로, 같은 요청을 여러 번 눌러 보고 dispatcher를 돌려 보세요.
crash가 없는 경로만 그린 모형입니다. crash 때 Toss를 다시 부를 수 있는 경우는 본문의 crash 표에서 다룹니다. Kafka 재시도, DLQ, 결과 회신 이벤트(payment.refunded)와 종결된 fence에 온 요청의 회신 재발행은 그리지 않았습니다. 버튼 순서와 중복 조합은 설명용이고, 실제 코드에서 이 조합은 통합테스트로 고정했습니다.
감지와 요청은 한 트랜잭션에서 만듭니다. Product는 감지 표시와 요청 Outbox를, Order는 보상 원장과 요청 Outbox를 함께 커밋합니다. Outbox 저장을 일부러 실패시키면 감지 기록까지 함께 롤백되는지를, mock 대신 실제 Spring 프록시와 DB 위에서 확인했습니다. 새로 생긴 리스너 다섯 개(요청 2·회신 3)는 실제 Kafka로 메시지를 한 번 주고받아 설정이 실제로 동작하는지 확인했습니다. 리스너 메서드를 직접 호출하는 테스트는 consumer group이나 factory 설정이 틀려도 통과하기 때문입니다.
fence가 요청을 막은 뒤 회신이 없는 순서가 있었습니다
이 작업에서 나온 가장 큰 결함은 작업의 목적을 되돌리는 것이었습니다. 문제의 경로는 payment.refunded가 payment.completed보다 Order에 먼저 도착하는 경우입니다. 이해하기 쉽게 Payment 로컬 감지에서 시작하는 순서로 풀어 보면 이렇습니다.
- Payment가 로컬 감지로 환불을 시작해 성공하고
payment.refunded를 발행합니다. - Order에
payment.refunded가 먼저 도착합니다. Order에는 아직 원장이 없으므로 회신을 no-op으로 처리하고,processed_events에 처리됨으로 기록합니다. - 늦게
payment.completed가 Order에 도착합니다. 취소된 주문이니OPEN원장을 만들고 환불 요청을 발행합니다. - 요청은 Payment의 fence에 막혀 no-op이 됩니다. 이미 끝난 환불이라 회신이 다시 나가지 않습니다.
Order 원장은 영원히 OPEN입니다. 이 작업이 없애려던 바로 그 상태입니다. 세 진입점이 하나의 fence로 모인다고 정해 두고, fence가 요청을 막은 뒤에 회신이 없는 경우를 보지 않았습니다. 이미 종결된 fence에 요청이 오면 결과 회신을 다시 발행하게 고쳤습니다.
이 수정도 처음에는 실패했고, 테스트가 그것을 잡았습니다. 환불이 성공하면 payments.status는 REFUNDED로 바뀌는데, 기존 코드는 맨 앞에서 APPROVED가 아니면 돌아가고 있었습니다. 재발행 경로까지 가기도 전에 반환된 것입니다. 종결 검사를 상태 검사보다 앞에 둬야 했습니다.
@Transactional
public boolean requestRefund(Payment payment, String reason) {
// 종결 검사가 APPROVED 검사보다 먼저다 — 환불이 성공하면 payments 는 REFUNDED 로 옮겨가므로
// 상태 가드를 먼저 두면 "이미 환불된 결제"가 조기 반환돼 회신 재발행 경로에 닿지 못한다.
if (republishIfAlreadyResolved(payment.getOrderId())) {
return false;
}
if (payment.getStatus() != PaymentStatus.APPROVED) {
return false;
}
...
int inserted = refundRepository.insertRequestedIfAbsent(
payment.getOrderId(), payment.getPaymentKey(), payment.getUserId(), payment.getAmount());
if (inserted == 0) {
// 진행 중인 환불에 도착한 중복 트리거 — 결과가 아직 없으므로 회신할 것도 없다.
return false;
}
backfill SQL에도 비슷한 일이 있었습니다. 요청 이벤트의 eventId를 Outbox 행과 payload에 같은 값으로 넣으려고, 먼저 빈 payload로 행을 넣고 다음 문장에서 payload를 채우는 두 단계로 나눴습니다. 다시 실행해도 안전한지는 따졌지만, 롤링 배포 중에 옛 버전의 Outbox poller가 두 문장 사이에서 빈 payload를 그대로 발행하고 PUBLISHED로 표시할 수 있다는 것은 놓쳤습니다. 1단계는 poller가 보지 않는 BACKFILL 상태로 넣고, 2단계가 payload 완성과 PENDING 전환을 한 UPDATE에서 하게 바꿨습니다. 이 작업으로 Order 원장이 OPEN으로 쌓이기만 하던 문제가 해결됐습니다.
결제 승인에도 같은 구조를 적용했습니다
같은 문제가 결제 승인 쪽에도 있었습니다. 승인 코드는 DB 트랜잭션 안에서 Toss 승인을 호출했습니다. Toss 승인이 성공한 뒤 커밋이 실패하면 고객의 돈은 빠져나갔는데 로컬은 전부 롤백됩니다. 기존 계획은 이것을 외부 과금이 남는 문제로 봤습니다.
착수 전에 코드를 대조하다가 문제가 더 크다는 것을 알았습니다. 같은 트랜잭션에서 Toss가 발급한 실제 결제 키도 저장하고 있어서, 롤백되면 payments.payment_key는 결제를 만들 때 넣어 둔 UUID 임시값으로 돌아갑니다. 그 UUID로는 Toss에 조회할 수 없습니다. 과금이 남는 것보다 나쁜 일은 과금이 남았는지 확인할 수단 자체가 사라진다는 것이었습니다.
이 발견으로 가장 간단한 대안이 빠졌습니다. Toss를 먼저 부르고 짧은 트랜잭션으로 저장하는 안에서는 결제 키를 커밋할 수 있는 시점이 Toss 호출 뒤뿐이라, 호출 직후에 죽으면 여전히 조회할 키가 없습니다. 그래서 결제 키를 Toss 호출 전에 커밋하는 것이 설계의 중심이 됐습니다.
환불에 쓴 구조를 그대로 가져왔습니다. payment_approvals 원장(order_id UNIQUE)을 fence로 두고 승인을 세 구간으로 나눴습니다.
- 첫 트랜잭션이 실제 결제 키와 fence 행을 커밋합니다. 커밋돼야 사후 조회가 가능합니다.
- 트랜잭션 밖에서
approve-{orderId}멱등키를 붙여 Toss 승인을 한 번 부릅니다. - 두 번째 트랜잭션이 claim 당시의
generation으로 결과를 확정합니다. 결과가 불명이면 원장을UNRESOLVED로 두고 reconciliation이 조회로 확정합니다.
다만 승인은 사용자가 기다리는 동기 요청이고, 다시 부르면 없던 과금이 새로 생깁니다. 그래서 환불과 달리 REQUESTED 상태와 요청 안 재시도를 두지 않았고, reconciliation도 승인을 다시 부르지 않고 조회로 확정만 합니다.
reconciliation이 Toss에서는 승인됐는데 로컬 결제는 이미 CANCELLED나 FAILED인 건을 찾으면, APPROVED 검사만 건너뛰는 진입점(requestRefundForOrphanedCharge)으로 환불 원장에 보냅니다. fence·중복 no-op·회신은 기존 경로를 그대로 씁니다. 사용자에게는 결과 불명을 실패 대신 “확인 중”(409)으로 돌려주게 바꿨습니다. 과금됐을 수도 있는 건에 결제되지 않았다고 단언하지 않기 위해서입니다.
그렇다면 이렇게 정한 계약이 최선이었을까요?
더 좋은 방법은 없었을까요?
여기부터는 작업을 돌아본 사후 분석입니다. 작업 중에 검토한 내용과 돌아보며 덧붙이는 해석을 나눠 썼습니다.
결제 승인에는 아직 fence가 없습니다
이 글의 환불 계약은 진입할 때 한 번 검사하는 것만으로는 경합을 막을 수 없다는 판단에서 출발했습니다. 이 판단이 처음 나온 곳은 결제 승인이었습니다. 승인을 시작하기 전에 예약이 아직 살아 있는지 검사하는데, 검사와 Toss 승인 사이에 예약이 만료되면 재고가 다른 주문에 넘어간 뒤 과금될 수 있습니다.
예약 lease 작업에서는 남은 예약 lease가 승인 마진(lease-approval-margin: 2m)보다 짧으면 승인을 시작하지 않게 해서 race window를 줄였습니다. 예약을 “승인 중” 상태로 CAS 전이해 회수 스케줄러가 건드리지 못하게 하는 진짜 fence는 saga 프로토콜을 바꾸는 일이라 별도 결정 문서가 필요하다고 보고 미뤘습니다. 마진은 race window를 줄일 뿐이라, PG 호출이 마진보다 길어지면 경합은 다시 일어날 수 있습니다.
승인 흐름을 다시 짠 작업도 이 마진 검사를 첫 트랜잭션 안에 그대로 옮겨 두었고, 이 경합은 따로 다루지 않았습니다. 작업 목록에서도 이 항목은 추적되지 않고 있었습니다.
여기서부터는 해석입니다. 환불 계약이 생긴 뒤에는 이 경합의 끝이 달라졌을 것으로 보입니다. 예약이 만료돼 풀린 뒤 결제 완료가 도착하면 Product는 PAID_BUT_UNRESERVED를, 취소된 주문을 가진 Order는 PAID_BUT_CANCELLED를 감지하고, 둘 다 환불 요청으로 이어집니다. 그러면 이 경로는 재고를 두 번 파는 대신 과금된 뒤 자동으로 환불되는 것으로 끝납니다. 코드 경로를 따라간 추론이고, 승인과 예약 lease 만료를 실제로 겹치게 한 테스트는 없습니다. 고객 입장에서는 결제 직후 환불을 받는 일이니, 이 경로를 아예 막는 fence는 여전히 필요합니다. 적어도 이 항목이 추적 목록에서 빠지지 않게 했어야 했습니다. 이 항목은 기술 부채로 다시 등록했습니다.
PG 멱등키가 실제로 약속하는 것
계약은 Toss 호출에 붙인 Idempotency-Key를 PG 쪽에서 중복 호출을 걸러 주는 장치로 봤습니다. 실제 Toss로는 검증하지 못했으므로, Toss의 멱등키 설명과 코드를 대조했습니다(토스페이먼츠 개발자센터, 멱등키). Toss가 설명하는 동작은 이렇습니다.
- 모든 POST API에 멱등키 헤더를 쓸 수 있습니다.
- 멱등키는 처음 요청한 날부터 15일간 유효합니다.
- 같은 요청이 두 번 와도 실제로 다시 처리하지 않고, 첫 번째 요청의 응답과 같은 응답을 돌려줍니다.
- 첫 번째 요청이 아직 처리 중일 때 같은 요청을 다시 보내면
409 IDEMPOTENT_REQUEST_PROCESSING을 돌려줍니다. - 같은 요청인지는 멱등키·API 키·API 주소·HTTP 메서드의 조합으로 판단합니다.
대부분은 계약의 전제와 맞습니다. 미확정 상한이 24시간이니 15일 유효기간 안에서 끝납니다. Toss 성공 후 로컬 커밋 전에 죽은 뒤의 재호출은 첫 응답과 같은 성공 응답을 받을 것입니다. 환불(refund-{orderId})과 승인(approve-{orderId})은 API 주소가 달라 키가 겹치지 않습니다.
맞지 않는 곳이 하나 있습니다. 409 IDEMPOTENT_REQUEST_PROCESSING입니다. 취소 응답 분류 코드는 ALREADY_CANCELED_PAYMENT가 아닌 4xx를 모두 영구 실패로 봅니다.
private TossOutcome toOutcome(HttpStatusCode status, String body) {
if (status.is2xxSuccessful()) {
return TossOutcome.succeeded(body);
}
String code = extractCode(body);
if (ALREADY_CANCELED.equals(code)) {
return TossOutcome.alreadyCanceled(body);
}
if (status.is5xxServerError() || status.value() == 429) {
return TossOutcome.transient_(code, body);
}
// 4xx — 기간 초과·금액 불일치·인증 실패 등. 재시도해도 상태가 바뀌지 않는다.
...
return TossOutcome.permanentFailure(code != null ? code : "HTTP_" + status.value(), body);
}
그리고 dispatcher는 한 번의 실행 안에서 같은 멱등키로 최대 세 번 부릅니다. 그러면 이런 순서가 코드상 가능합니다. Toss 설명과 코드를 대조해 만든 예시입니다.
- 첫 번째 취소 호출이 우리 쪽 타임아웃(10초)에 걸립니다. 결과를 알 수 없으니 재시도 대상입니다.
- 2초 뒤 같은 멱등키로 두 번째 호출을 보냅니다.
- Toss가 첫 번째 요청을 아직 처리하고 있으면 409
IDEMPOTENT_REQUEST_PROCESSING을 돌려줍니다. - 분류는 이것을 영구 실패로 보고, 원장은 재시도 없이
FAILED로 확정되며, Order 원장은REFUND_FAILED가 됩니다. - 그 사이 첫 번째 취소는 성공할 수 있습니다.
환불은 됐는데 원장에는 실패로 남는 상태입니다. 계약이 ALREADY_CANCELED를 실패로 보지 않기로 하면서 피하려던 바로 그 상황입니다.
이 분류를 테스트로 확인했습니다. 실제 Toss 대신 응답을 흉내 내는 MockRestServiceServer를 붙이고, 실제 분류 코드와 실행기를 그대로 썼습니다. 첫 호출은 타임아웃, 두 번째 호출은 409 IDEMPOTENT_REQUEST_PROCESSING을 주자 실행기는 두 번째 시도에서 멈추고 FAILED를 돌려줬습니다. 조회는 하지 않았습니다. 비교를 위해 두 번째 응답만 409 ALREADY_CANCELED_PAYMENT로 바꾸자 같은 실행기가 조회를 거쳐 SUCCEEDED를 돌려줬습니다. 같은 409라도 body의 코드 하나에 따라 원장의 결론이 갈립니다.
다만 Toss가 실제로 10초 넘게 처리 중인 경우가 얼마나 있는지, 그 경우 409 응답 body의 code가 실제로 어떤 형태인지는 실 PG로 확인하지 못했습니다. 확인된 장애는 아닙니다. Toss의 설명과 코드가 맞지 않는 부분이고, 기술 부채로 등록해 두었습니다. 승인 경로에는 이 문제가 없습니다. 승인은 한 번만 부르고, 같은 주문의 두 번째 요청은 로컬 fence에서 먼저 막힙니다. 돌아보면 이 응답은 ALREADY_CANCELED처럼 조회로 결과를 확인할 응답이나 UNRESOLVED로 다뤘어야 했습니다. Toss 멱등키에 기대기로 했다면, 그 기능이 돌려주는 응답까지 우리 분류와 맞춰 봤어야 했습니다.
같은 구조를 두 번 만든 것은 괜찮았을까요?
환불과 승인은 구조가 같습니다. 둘 다 order_id 유니크 원장을 fence로 두고, claim과 generation으로 소유권을 가리고, 결과가 불명이면 조회로 확정하고, 상한을 넘으면 사람에게 넘깁니다. 원장 엔티티, 리포지토리, 실행기, 서비스, 스케줄러가 각각 따로 있고, payment-service의 스케줄러는 다섯 개가 됐습니다.
공통 “외부 호출 원장” 하나로 묶을 수 있었을까요? 작업 중에는 따로 검토하지 않았던 질문입니다.
묶지 않을 이유는 승인 쪽 결정에 있습니다. 승인 쪽이 환불과 다르게 한 세 가지(REQUESTED 없음, 요청 안 재시도 없음, 재호출 없음)는 모두 승인이 사용자가 기다리는 동기 요청이고 다시 부르면 과금이 새로 생긴다는 하나의 차이에서 나옵니다. 묶었다면 이 차이를 옵션으로 열어야 했을 것이고, 옵션 하나를 잘못 고르면 승인을 다시 부르는 코드가 생길 수 있습니다.
반대로 따로 둔 비용도 하나 보입니다. Toss 호출의 타임아웃은 두 경로가 함께 쓰는데, 그 값은 app.refund.pg-timeout 하나에서 옵니다. 승인 쪽은 claim lease가 PG 호출보다 길어야 한다는 불변식을 부팅 시 강제하지 않기로 했습니다. 강제하려면 app.refund.pg-timeout을 복제해야 하고, 그 복제가 어긋나면 검증 자체가 거짓이 된다는 이유였습니다. 공유하는 값이 한쪽 이름 아래 있어서 다른 쪽은 그 값에 기대는 검증을 포기했습니다. 원장까지 묶을 필요는 없었더라도, 두 경로가 함께 쓰는 Toss 호출 정책만큼은 중립적인 이름의 설정으로 분리할 수 있었습니다.
같은 실수가 두 가지 형태로 반복됐습니다
이 작업들을 지나며 같은 종류의 실수가 여러 번 나왔습니다.
하나는 테스트가 구현 동작을 그대로 따라 쓴 것입니다. 단위 테스트의 mock은 트랜잭션 경계를 증명하지 못했고, “오래된 claim이 다시 claim된다”를 성공 기준으로 삼았습니다. 필수 필드가 없는 메시지를 “관용”하는 동작을 고정했고, 마이그레이션 SQL을 그대로 실행하는 대신 복사본을 검증했습니다.
다른 하나는 보장의 적용 범위를 실제보다 넓게 잡은 것입니다. 진입 검사로 oversell이 해결됐다고 봤고, Payment DB의 유니크 키가 다른 서비스의 재발행까지 막는다고 봤습니다. 세 진입점이 하나의 fence로 모인다고 정하고는 fence가 요청을 막은 뒤의 회신을 보지 않았습니다.
이 실수들은 모두 바로잡았습니다. 하지만 바로잡은 시점은 늘 구현이나 계약을 다 정한 뒤였습니다.
첫 구현 계획을 다시 보면서 원인으로 꼽은 것은, 계약에서 정한 내용을 구현 계획으로 옮기며 요약하다가 일부 결정이 빠졌다는 점이었습니다. 다음에는 결정 항목과 작업 항목의 대응표를 계획서에 두는 것이 낫다고 봤지만, 이 대응표는 그 메모에만 남았고 계획 양식이나 점검표로 옮겨지지 않았습니다. 계획 점검표에 올라간 것은 “진입 검사는 fence가 아니다” 하나입니다.
돌아보면 두 패턴도 같은 방식으로, 예를 들어 “이 보장은 어느 DB, 어느 트랜잭션의 제약인가”, “이 테스트의 기대값은 계약의 어느 문장에서 왔는가”를 묻는 점검 항목으로 남길 수 있었습니다.
정리하며
롤백되지 않는 외부 호출을 낀 보상 흐름에서 “한 번”은 어디서, 무엇을 세어 보장할 수 있을까요? 이번 작업에서는 같은 주문의 환불을 1건으로 세기로 했습니다. 메시지 중복 제거와 Toss 호출 횟수로는 이 1건을 지킬 수 없었습니다. 이 1건은 진입점이 커밋한 order_id 유니크 행으로 지키고, 호출 전후에 프로세스가 죽어 결과를 알 수 없게 된 건은 Toss 조회로 확정합니다. 24시간 안에 확정되지 않는 건은 운영 알림과 게이지로 드러나게 했습니다.
이 답에 이르기까지 나온 결함은 대부분 보장의 범위를 실제보다 넓게 잡은 데서 나왔습니다. 이 답은 테스트로 재현한 장애와 서비스별 통합테스트 위에서 확인한 것이고, 실제 PG 위에서는 확인하지 못했습니다.
남은 과제는 이렇습니다.
- 실제 Toss로는 검증하지 못했습니다. 취소 API는 승인된 실거래가 있어야 부를 수 있습니다. 환불과 승인에서 crash가 나는 시점은 모두
MockRestServiceServer와 트랜잭션 분리로 재현해 확인했습니다. - 환불 재시도가 409
IDEMPOTENT_REQUEST_PROCESSING을 영구 실패로 처리하는 문제가 있습니다. 분류와 실행기 동작은 테스트로 확인했지만, 실제로 이 응답이 얼마나 나오는지는 모릅니다. 부채로 등록해 두었습니다. - 결제 승인과 예약 lease 회수 사이에는 아직 fence가 없습니다. 마진으로 race window만 줄인 상태이고, 기술 부채로 다시 등록했습니다.
- 두 작업은 같은 릴리스 주기에 배포해야 합니다. 첫 구현은 회신을 발행하지만 받는 쪽은 다음 작업에 있습니다. 둘 사이가 벌어지면 쌓인 회신이 Kafka 보존 기간을 넘길 수 있습니다.
- 여러 서비스에 걸친 E2E 테스트와 DLQ 원장은 뒤로 미뤘습니다. 여기까지의 검증은 서비스별 통합테스트입니다. 환불 체인 전체를 실제 서비스들 위에서 돌리는 검증과 DLQ로 빠진 메시지를 원장으로 남기는 일은 다음 작업의 요구사항으로 올렸습니다.
- 승인 흐름 작업에도 남은 일이 있습니다. 수동 종결은 아직 서비스 메서드로만 있고 운영자가 쓸 수 있는 경로는 없습니다. 승인 응답에 커밋이 한 번 늘었지만 그 지연은 측정하지 않았습니다.