프로젝트
약 16분 분량 학습 프로젝트/결제 시스템

정산과 취소: 가맹점에 돈이 안 나가고 있었다

Payment정산정합성취소
목차

개요

수수료는 계산됐고 정산 배치는 돌았고 취소도 성공했는데, 가맹점에 지급될 돈이 나가지 않고 있었습니다. 예외도 로그도 없었습니다. 결제에서 제일 무서운 실패는 터지는 실패가 아니라 조용히 숫자만 틀리는 실패입니다.

이 글은 정산과 취소 경로에서 그렇게 틀리고 있던 것들을 다섯 갈래로 묶었습니다. 수수료를 총액의 3%만 떼고 있었고, 확정된 정산이 가맹점으로 나가지 않았고, 구매확정 전에 정산이 돌았고, 취소가 월렛을 모르고 지나갔고, 부분취소는 같은 요청을 두 번 보내면 두 번 취소됐습니다.

상황다룬 것
상황 1정산이 총액의 3%만 떼고 있었다
상황 2집계 키가 승인일이라 가맹점에 돈이 안 나갔다
상황 3구매확정 전에 정산되고 있었다
상황 4취소가 월렛을 몰랐다
상황 5전액취소는 멱등인데 부분취소는 아니었다

개인 프로젝트로 만든 결제 시스템 pay의 개발 기록입니다. 실무 운영 경험이 아닙니다.

정산이 “총액의 3%“만 떼고 있었다

정산 수수료가 FEE_PERCENT = 3 상수 한 줄로 끝나 있었습니다. 실제로 떼야 할 셋 중 하나만 있었습니다.

0. 정산이 한 줄로 끝나 있었다

정산 수수료 계산이 상수 한 줄로 끝나 있었습니다. 에스크로에 정렬한 정산에서 “구매확정된 결제만 집계한다”까지는 맞췄는데, 정작 수수료 쪽을 열어보니 이랬습니다.

private static final long FEE_PERCENT = 3;
// fee = gross * 3 / 100

총액의 3%를 떼는 게 전부입니다. 실무 정산은 여기서 한 겹 더 들어갑니다.

PG/가맹점 정산은 수수료(정률) 에 그 수수료의 부가세 를 더 떼고, 지급이 언제 이뤄지는지(지급예정일)까지 계산합니다.

구체적인 값은 계약마다 다릅니다. 결제수단별 수수료율, VAT 별도·포함, 건별 계산인지 총액 계산인지, 원 단위 반올림·절사, 일·주·월 정산, 지급 주기 +N이 전부 계약 사항입니다. 이 프로젝트는 “과세 수수료 2.7%, VAT 별도 10%, 일정산 후 2영업일 지급”을 가정했습니다. 승인 100,000원이면 수수료 2,700원에 VAT 270원이 붙어 실지급 97,030원입니다.

“3%만 떼기”는 이 셋 중 하나만 있는 셈입니다. 빠진 둘을 채웠습니다.

1. 돈은 정수로, 수수료율을 bps로

수수료율은 basis point(bps) 정수로 뒀습니다. 270 bps가 2.7%입니다. 0.027(double)로 두고 gross * 0.027을 하는 쪽이 편해 보이지만, 이 프로젝트에는 KRW를 소수점 없는 정수(long)로만 다룬다는 원칙이 있습니다. 부동소수를 돈 계산에 끼우면 반올림 오차가 쌓입니다.

long fee = Math.multiplyExact(gross, feeBps) / 10000; // 정수 나눗셈(floor)
long feeVat = fee / 10; // 수수료의 10%
long net = gross - fee - feeVat;

검산하면 gross=100,000 → fee=2,700 → feeVat=270 → net=97,030. 부동소수 없이 떨어집니다. 이 검산값은 테스트로 못박아 뒀습니다(settleFeeModelExactValues). 수수료 로직은 한 번 틀어지면 돈이 샙니다.

정산 집계의 불변식도 기존 net = gross - fee에서 net = gross - fee - feeVat 로 넓혔습니다. 마이그레이션에서 레거시 정산은 feeVat = 0으로 백필하므로 옛 불변식이 그대로 성립하고, net을 다시 계산할 필요도 없었습니다.

2. 지급예정일 = 정산일 + 2영업일

지급예정일은 “정산일 + 2일”로 계산하면 틀립니다. 주말엔 정산 지급이 안 되기 때문에 “+2영업일”로 세야 합니다.

// 하루씩 전진하며 토·일을 건너뛰어 N영업일을 셉니다
LocalDate payoutDate = BusinessDays.plusBusinessDays(settlementDate, 2);

금요일 정산이면 +2영업일은 토·일을 건너뛴 다음 화요일입니다. 여기서 선을 하나 그었습니다.

거래일은 Asia/Seoul 기준으로 자릅니다. UTC로 자르면 한국시간 00:30 결제가 전날 정산으로 들어갑니다. 이 편의 다른 버그와 정확히 같은 실패 모드(조용히 하루가 밀린다)라 도메인 상수로 못 박았습니다.

이 계산은 주말만 건너뛰고 법정공휴일은 반영하지 않습니다. 실제 정산이라면 공휴일 캘린더(설·추석·대체공휴일…)를 붙여야 맞습니다. 다만 그건 별도 데이터 소스가 필요한 일이라, 여기선 “주말 skip”까지만 하고 그 한계를 javadoc과 README에 명시했습니다. “공휴일도 처리한다”고 적고 안 하는 것보다 “여기까지만 했다”를 적는 쪽이 정직하다.

3. 또 하나의 죽은 배치

여기까지 만들고 “지급 확정”을 붙이려다 이상한 걸 발견했습니다. 정산을 실제로 만드는 settle()을 부르는 코드가 없었습니다.

$ grep -rn ".settle(" src/
src/test/.../SettlementServiceTest.java: service.settle(DATE) ← 테스트뿐

스케줄러 없던 배치들과 같은 패턴입니다. 정산 로직은 완성돼 있는데, 운영에서 그걸 주기적으로 부르는 스케줄러도 수동으로 돌릴 어드민도 없었습니다. 정산이 영원히 안 만들어지니 “지급 확정”할 대상도 없습니다.

그래서 배선했습니다. 기존 스케줄러 게이트 패턴(기본 off) 그대로 일 단위 스케줄러를 달고, 어드민에 조회·수동실행·지급확정을 뒀습니다. 정산 상태에는 PAID_OUT을 추가했습니다.

public void markPaidOut() {
if (this.status == SettlementStatus.CREATED) { // 멱등: 이미 PAID_OUT이면 무시
this.status = SettlementStatus.PAID_OUT;
this.paidOutAt = Instant.now();
}
}

데모 콘솔에도 정산 패널을 붙여, 승인→구매확정→정산 집계→지급 확정까지 눌러볼 수 있게 했습니다.

정산 데모: 총액 30,000 → 수수료 810 + VAT 81 → 지급액 29,109, 지급예정일 2영업일 뒤

총액 30,000이 수수료 810 + VAT 81을 떼고 29,109로, 지급예정일은 2영업일 뒤로 찍힙니다. CREATED를 “지급 확정”하면 PAID_OUT이 됩니다. 실 MySQL로 이 흐름 전체를 검증했습니다(3건 구매확정 → 집계 → 지급확정 → 항목 SETTLED).



정산이 조용히 가맹점에 돈을 안 주고 있었다

에러도 안 나고 테스트도 초록불인데 가맹점에 돈이 안 나가고 있었습니다. 집계 키가 구매확정일이 아니라 승인일이라, 에스크로에 며칠 묶인 항목은 어느 배치도 집어가지 못했습니다.

0. “더 있나?” 하고 다시 봤더니

여기까지 하고 다 됐다 싶었습니다. 그 “다 됐다”를 의심하며 코드베이스를 결제 도메인 리뷰어의 눈으로 한 번 더 훑었는데, 정산에서 조용한 버그가 나왔습니다. 에러도 안 나고 테스트도 초록불인데 가맹점에 돈이 안 나가는 버그입니다. 정산을 에스크로에 정렬하고 수수료·부가세·지급예정일까지 고도화한 바로 그 정산에서.

1. 집계 키가 잘못된 날짜였다

정산 배치의 핵심은 이 한 줄입니다.

itemRepository.findByStatusAndConfirmedDate(CONFIRMED, date);
// "그 date에 CONFIRMED된 정산 항목"을 집계

status == CONFIRMED 그리고 confirmedDate == date 둘 다 맞는 항목을 모읍니다. 문제는 이 confirmedDate의 정체였습니다.

// 적재 시점(결제 승인 이벤트)
LocalDate confirmedDate = LocalDate.ofInstant(event.approvedAt(), UTC); // ← 승인일

이름은 confirmedDate(“구매확정일”)인데 실제로 담긴 건 승인일이었습니다. 항목이 CONFIRMED(정산 가능)로 바뀌는 건 에스크로 릴리스(구매확정) 시점인데, 그 전이가 confirmedDate재스탬프하지 않았습니다.

이게 치명적인 이유는 에스크로가 며칠 홀드되기 때문입니다(기본 7일).

결제가 D일에 승인 → 항목 적재(confirmedDate = D, PENDING). D+1일에 settle(D) 배치가 도는데, 이 항목은 아직 PENDING이라 제외됩니다. D+7일에 에스크로가 릴리스되어 CONFIRMED가 되지만 confirmedDate여전히 D. 그런데 settle(D)는 D+1에 이미 실행됐고, 재실행은 멱등하게 skip됩니다(그 날짜 정산이 이미 있으니까). 스케줄러는 매일 settle(어제)만 돌지 과거를 다시 돌지 않습니다.

결과적으로 이 항목은 CONFIRMED인 채로 영원히 집계되지 않습니다. “그 날짜에 CONFIRMED된 항목”이라는 조건을 어떤 배치도 만족시키지 못합니다. settle(D)가 돌 땐 PENDING이었고, settle(D+7)confirmedDate가 안 맞습니다. 가맹점은 돈을 못 받습니다.

무서운 대목은 따로 있습니다. 에스크로 홀드가 본질적으로 며칠짜리라, 이건 거의 모든 항목이 타는 기본 경로입니다. “구매확정 시점 정산”을 하겠다던 바로 그 리워크가 정작 지급을 막고 있었습니다.

2. 내 테스트는 왜 못 잡았나

더 뼈아픈 쪽은 테스트입니다. 정산 테스트의 헬퍼가 이랬습니다.

private static SettlementItem confirmedItem(...) {
SettlementItem item = SettlementItem.of(..., DATE); // 승인일 = DATE
item.confirm(); // 곧바로 확정
return item; // confirmedDate == DATE
}

승인하자마자 같은 날 확정하니 confirmedDatesettle 대상 날짜가 늘 일치했습니다. 그래서 모든 테스트가 통과했습니다. 하지만 “승인하자마자 같은 날 구매확정”은 실제로는 거의 안 일어납니다. 에스크로가 며칠 홀드하기 때문입니다.

테스트가 “승인일 == 확정일”이라는, 현실에선 드문 조건에서만 돌아 버그를 통째로 가렸습니다. 실기동 검증 때도 나는 결제 후 바로 구매확정을 눌렀으니 그때도 우연히 통과했습니다. 승인과 확정 사이의 시간 간격이라는 정산의 본질을, 테스트도 나도 좁혀서 보고 있었습니다.

3. 고침: 확정일로 재스탬프

집계 키를 승인일에서 구매확정(릴리스)일로 바꿨습니다. 마침 EscrowReleasedEvent가 릴리스 시각을 담고 있었습니다.

public void confirm(LocalDate settlementReadyDate) {
if (this.status == PENDING_CONFIRMATION) {
this.status = CONFIRMED;
this.confirmedDate = settlementReadyDate; // ← 릴리스일로 재스탬프
}
}
void onEscrowReleased(EscrowReleasedEvent event) {
LocalDate releaseDate = LocalDate.ofInstant(event.releasedAt(), UTC);
settlementService.confirmSettlement(event.orderNo(), releaseDate);
}

이제 릴리스일 R로 재스탬프되니 R+1의 settle(R)이 이 항목을 집계합니다.

그런데 이 수정도 반쪽이었습니다. 외부 리뷰가 짚었습니다.

D+8 새벽 settle(D+7) 실행 → 정산 생성
D+8 오전 D+7에 발생한 구매확정 이벤트가 지연 도착 → confirmedDate = D+7

그 날짜 정산은 이미 있어 멱등하게 skip되고, 다음 날 배치는 다른 날짜만 봅니다.
늦게 확정된 항목이 영영 집계되지 않습니다. 승인일로 집계하던 원래 버그와 같은 실패 모드입니다.

날짜를 정확히 맞추는 대신 그 날짜 이하의 미정산 재고를 전부 보게 고쳤습니다.

// 전: findByStatusAndConfirmedDate(CONFIRMED, date)
findByStatusAndConfirmedDateLessThanEqual(CONFIRMED, date)

늦게 확정된 항목은 다음 실행이 쓸어 담습니다. “그 날짜에 확정된 것”이 아니라 “아직 안 나간 것”을
묻는 게 맞았습니다.
필드 이름도 이 수정으로 바로잡혔습니다. confirmedDate가 이름대로 “구매확정일”이 됐습니다(전엔 이름과 달리 승인일이었습니다).

회귀 테스트도 심었습니다. 승인 D일 적재 → D+7 릴리스 → 릴리스일 배치가 잡는지. 누가 다시 승인일로 되돌리면 빨간불이 뜹니다.

실 MySQL로 확인했습니다.

승인일 backdate(2026-06-01) → PENDING (confirmed_date=2026-06-01)
구매확정(에스크로 릴리스) → CONFIRMED, confirmed_date=2026-07-07 (재스탬프!)
settle(2026-07-07) → 집계 → SETTLED, 정산 생성(net 9,703)

승인일이 6월 1일이던 항목이 릴리스 시 오늘로 재스탬프되어 오늘 배치에 잡힙니다.



구매확정 전에 정산되고 있었다: 죽은 이벤트가 가리킨 도메인 모순

구독자 없이 죽어 있던 건 Kafka 토픽만이 아니었습니다. 코드베이스를 전수 감사하다 구독자 0인 EscrowReleasedEvent를 발견했고, 그 끝에는 두 모듈이 “돈이 언제 판매자 것이 되는가”를 정반대로 알고 있던 도메인 모순이 있었습니다.

0. 감사가 이상한 걸 짚었다

기능이 거의 다 완성된 뒤 코드베이스를 전수 감사했습니다. “시스템이 암묵적으로 가정하는 것”을 다 찾아내자는 취지였습니다. 17건이 나왔는데 그중 하나가 유독 걸렸습니다.

[3] 정산이 에스크로와 분리: 정산 적재가 PaymentConfirmedEvent(승인 즉시)에서 일어나고, EscrowReleasedEvent구독자 0(죽은 이벤트). “구매확정 전 보류”가 정산에 미반영.

읽고 나서 “아…” 했습니다. 에스크로 편에서 분명히 이렇게 만들었습니다. 결제금을 구매확정 전까지 HELD로 보류하고, 구매자가 확정하면 RELEASED로 풀어 정산 가능하게. 자금이 판매자 것이 되는 건 구매확정 시점이라고.

그런데 정산 모듈은 그걸 몰랐습니다. PaymentSettlementListener가 결제 승인 이벤트를 받아, 승인되자마자 정산 항목을 쌓고 있었습니다.

에스크로는 “구매확정 전엔 못 준다”고 하는데, 정산은 “승인됐으니 지급 목록에 올린다”고 하고 있었습니다. 같은 시스템 안에서 두 모듈이 “돈이 언제 판매자 것이 되는가”를 정반대로 알고 있었습니다.

그 증거가 죽은 이벤트였습니다. 구매확정 시 EscrowReleasedEvent를 발행하는데, grep해보니 구독자가 아무도 없었습니다. “향후 정산 파이프라인이 구독한다”는 주석만 남긴 채, 아무도 듣지 않는 이벤트를 계속 던지고 있었습니다.

1. “돈은 언제 판매자 것이 되는가”

결제 도메인에서 가장 중요한 질문 중 하나입니다. 마켓플레이스라면 특히 그렇습니다.

  • 너무 일찍 (승인 시점) 정산하면 → 미배송·분쟁 때 이미 나간 돈을 회수 못 합니다.
  • 그래서 에스크로가 있는 거고, 정산은 구매확정(에스크로 릴리스)에 맞춰야 합니다.

정답은 명확했습니다. 정산의 트리거를 승인에서 구매확정으로 옮기는 것. 죽어 있던 EscrowReleasedEvent를 정산이 구독하게 하면 됩니다.

2. 정산 항목에 생명주기를 주다

기존 정산 항목의 상태는 PENDING → SETTLED 둘뿐이었습니다. 여기에 “구매확정” 관문을 끼워 넣었습니다.

PENDING_CONFIRMATION 승인됨 · 구매확정 대기 (아직 지급 대상 아님)
↓ EscrowReleasedEvent
CONFIRMED 구매확정됨 · 정산 가능
↓ 일 단위 배치
SETTLED 집계·지급됨
(전액취소 시) → CANCELED

핵심은 세 곳입니다.

(1) 승인 → PENDING_CONFIRMATION. 승인 이벤트는 여전히 정산 항목을 만들지만, 이젠 “대기” 상태로 만듭니다. 이 시점엔 아직 지급 대상이 아닙니다.

(2) 구매확정 → CONFIRMED. 정산이 EscrowReleasedEvent를 구독해서 그 주문의 항목을 CONFIRMED로 전이시킵니다. 죽었던 이벤트가 드디어 제 일을 합니다.

@ApplicationModuleListener
void onEscrowReleased(EscrowReleasedEvent event) {
settlementService.confirmSettlement(event.orderNo()); // PENDING_CONFIRMATION → CONFIRMED
}

(3) 배치는 CONFIRMED만 집계. 일 단위 정산 배치가 PENDING 대신 CONFIRMED만 합산합니다. 구매확정 안 된 PENDING_CONFIRMATION 항목은 지급에서 자동으로 빠집니다. “구매확정 전 보류”가 실제 동작으로 정산에 반영되는 지점입니다.

실기동으로 확인했습니다.

승인 후 → settlement_items: PENDING_CONFIRMATION (보류)
구매확정 후 → settlement_items: CONFIRMED (정산 가능!)
전액취소 → settlement_items: CANCELED (정산 제외)
부분취소 3000 → settlement_items: amount 7000 (역반영)

3. 취소는 정산에 어떻게 반영하나: 정직한 한계

정산은 취소 이벤트도 구독하게 했습니다(PaymentCanceledEvent). 그런데 여기 미묘한 경우들이 있습니다.

  • 확정 전 전액취소 → 항목을 CANCELED로. 애초에 지급 안 됨.
  • 확정 전 부분취소 → 항목 amount를 줄임.
  • 이미 SETTLED(집계·지급됨) 뒤 취소 → …?

마지막 경우가 어렵습니다. 이미 판매자에게 지급 목록으로 나간 걸 정산 항목에서 되돌리면 회계가 어긋납니다. 그래서 되돌리지 않기로 했습니다.

이미 SETTLED된 항목에 취소가 오면 금액을 건드리지 않고 settlement.postsettle.cancel 카운터만 올렸습니다. “정산은 재구성 가능한 집계이고 취소의 진짜 이력은 원장 역분개가 보유한다”는 판단을 주석에 적어뒀습니다.

그런데 카운터로는 부족했다

외부 리뷰가 짚었습니다. 원장은 취소 이력을 갖고 있지만, 그건 근거지 실행할 일이 아닙니다.

카운터만 있으면 “몇 건 있었다”는 알지만 어떤 주문을 얼마 조정해야 하는지는 복구할 수 없습니다.
프로세스가 재시작되면 운영이 처리할 목록조차 안 남습니다.

그래서 회수 대기 항목을 만들었습니다.

settlement_adjustments
order_no, payment_id
original_settlement_id ← 어느 지급에서 잘못 나갔나
cancel_seq ← (order_no, cancel_seq) 유니크로 멱등
adjustment_amount ← 음수로 저장. 차기 총액에 그대로 더합니다
status PENDING / APPLIED / REVIEW_REQUIRED

과거 정산은 고치지 않습니다. 이미 지급 대상으로 나갔고, 수정하면 그때 무엇을 근거로 얼마를
줬는지 추적할 수 없게 됩니다. 회계에서 지워진 기록은 기록이 아닙니다. 대신 차기 정산이 음수로
반영
합니다. 원장(모든 자금 이동을 기록하는 복식부기 장부)이 취소를 역분개(원래 기록을 지우지 않고 반대 방향 기록을 새로 쌓아 상쇄하는 방식)로 쌓는 것과 같은 형태입니다.

여기서 선을 하나 더 그었습니다. 회수액이 차기 정산 총액보다 크면 자동으로 처리하지 않습니다.
그건 음수 지급, 즉 “돈을 돌려받는” 일이라 지급 파이프라인이 아니라 별도 청구 절차입니다.
조용히 0으로 깎으면 회수액이 증발합니다. REVIEW_REQUIRED로 남겨 사람이 봅니다.

그리고 정산 항목에 settlement_id를 뒀습니다. 이게 없으면 “어느 지급에서 잘못 나갔는지”를
못 적습니다. 회수를 만들려면 원 정산을 가리킬 수 있어야 합니다.

측정을 부풀리지 않았듯(데드락 편 각주처럼), 처리 못 하는 케이스도 “못 한다”고 표시하는 쪽을 택했습니다. 조용히 틀리게 처리하는 것보다 낫습니다.

4. 경계는 여기서도 단방향

정산이 에스크로 이벤트를 구독하니 settlementescrow에 의존합니다. 방향을 확인했습니다. settlement → escrow 단방향입니다. 에스크로는 정산을 모릅니다(자기 이벤트만 던질 뿐). 모듈 경계를 CI가 강제하니 순환이 생겼으면 빌드가 깨졌을 텐데, 통과했습니다.



취소가 월렛을 몰랐다: 신규 코드 정밀 감사에서 잡은 자금 버그들

회원·분쟁·월렛까지 만든 뒤 그 신규 코드를 정면으로 감사해 치명 2건 포함 11건이 나왔습니다. 카드와 월렛으로 나눠 낸 결제가 전액취소되지 않았습니다.

0. 기능을 늘렸으면, 그 코드를 의심해야 한다

결과부터. 회원·분쟁/차지백·월렛 배선까지 확정 기능을 다 만든 뒤 바로 그 신규 코드를 정면으로 감사했고, 치명 2건 포함 11건이 나왔습니다. 인증 쪽 셋은 인증 비용 편으로 보내고, 여기서는 돈이 걸린 것만 봅니다. 지난 감사들이 매번 실 자금 버그를 잡아왔으니, 돈과 인증을 새로 건드린 코드가 무사할 리 없다고 봤는데 역시나였습니다. 하나씩 보면 전부 “기능은 각자 옳은데, 만나는 지점이 틀린” 종류였습니다.

1. [치명] 취소가 월렛을 몰랐다: 환불 증발

라이브로 재현부터 했습니다. 주문 20,000원을 카드 14,000 + 월렛 6,000으로 결제하고 취소하면:

전액취소 20,000 → CANCEL_AMOUNT_EXCEEDED "잔여 14,000" (전액취소 자체가 불가)
카드몫만 14,000 취소 → fullyCanceled:true, 주문 CANCELED
월렛 잔액 → 그대로. REFUND 이력 없음. 6,000원 증발.

원인은 단순합니다. CancelService가 환불 재원을 포인트와 카드만 조회했습니다. 월렛은 의존성에도 없었습니다. 월렛을 결제수단으로 배선할 때 사가(승인 실패 보상)는 챙겼는데, 완료된 결제의 취소 경로는 빠뜨린 것입니다. 전액 월렛 결제 주문은 아예 취소가 불가능했습니다.

수정은 배분기를 3-way로 확장하는 것.

// 포인트 → 월렛 → 카드 순. 내부 재원(무상 지급 가능성이 있는 것)부터 환불해야
// 카드 환불로 포인트·선불충전분을 현금화하는 어뷰징을 막습니다.
RefundAllocation alloc = RefundAllocator.allocate(cancelAmount, paidByPoint, paidByWallet, paidByCard);

전액취소 판정(fully)도 세 재원 합 기준으로 고쳤습니다. 재현 시나리오를 다시 돌리면 이제 refundedWallet:6000, 월렛 잔액 원복, USE/REFUND 상쇄까지 확인됩니다.

결제수단을 추가하면 그 수단의 전체 수명주기(예약→확정→취소→복구)를 따라가야 합니다. 성공 경로와 사가 보상만 배선하고 끝내면, 취소가 그 수단의 존재를 모릅니다.

2. [치명] 멱등 장치가 공짜 결제를 만들었다

두 번째는 더 미묘합니다. 체크아웃 사가는 카드 거절 시 선점한 포인트·월렛을 되돌리고 주문을 PENDING_PAYMENT로 복귀시킵니다. 재시도하라는 뜻입니다. 그런데:

  1. 1차 시도: 월렛 6,000 차감(USE) → 카드 거절 → 월렛 6,000 환불 → 주문 PENDING 복귀
  2. 2차 시도(같은 주문): 월렛 차감이 existsByOrderNoAndType(orderNo, USE)를 보고 “이미 차감했네” → skip
  3. 카드 승인 → 주문 PAID. 월렛 몫 6,000은 아무도 안 낸 돈.

사가 재진입(크래시 복구)의 이중차감을 막으려고 넣은 멱등이, 거절 후 재시도 시나리오에서는 환불로 죽은 예약을 살아있다고 착각한 것입니다. 한도초과 카드로 1차 거절을 유도하면 누구나 재현할 수 있는 구멍이었습니다.

수정의 핵심은 멱등의 질문을 바꾸는 것.

// "이미 차감했는가?"(exists)가 아니라 "활성 예약이 남아 있는가?"(순액)
if (refundableAmount(orderNo) > 0) { // USE − RESTORE − REFUND
return balance(userId); // 예약 살아있음 → skip
}
// 예약이 해제됐으면(거절 보상) 재시도 시 다시 차감됩니다

그리고 월렛에 RESTORE(사가 보상, 멱등)와 REFUND(취소 환불, 부분취소 다회라 비멱등)를 분리했습니다. 포인트가 원래 쓰던 검증된 계약과 대칭이 되도록. 동시 요청의 이중차감은? 같은 주문의 confirm은 Order.startPayment()@Version이 이미 직렬화하니, check-then-act가 원자적이지 않아도 안전합니다.

멱등 키의 수명은 예약의 수명과 같아야 합니다. “한 번 기록되면 영원히 skip”은 보상 트랜잭션이 있는 세계에서는 틀린 모델입니다.

3. [높음] 취소로 포인트 파밍: 적립 회수 부재

포인트 적립을 만들 때 취소를 잊었습니다. 100,000원 결제 → 1,000P 적립 → 전액취소(카드 100% 환불) → 적립은 그대로. 반복하면 무비용 포인트 파밍입니다.

취소가 실결제 회수분만큼 적립을 회수하게 했습니다(EARN_REVERSAL). 디테일이 둘 있습니다.

  • 적립분 상한 캡: 부분취소가 여러 번 와도 적립보다 많이 회수하지 않게 min(요청, EARN−EARN_REVERSAL).
  • 음수 잔액 허용: 이미 적립분을 써버렸어도 회수를 관철해야 파밍이 막힙니다. 음수분(적립 채무)은 이후 적립으로 상계됩니다.

4. 분쟁 쪽: 입력을 믿은 죄

분쟁/차지백 모듈은 상태기계와 원장 멱등은 견고했는데, 입력 검증이 구멍이었습니다.

  • amount 무검증: 웹훅 amount 누락/문자열이면 Jackson asLong()이 조용히 0을 줍니다. amount=0 분쟁이 생기고, 패소 확정 시 원장 분개가 “금액은 양수” 검증에 걸려 아웃박스 이벤트가 영구 실패합니다(포이즌). 개시 시점에 양수 가드를 넣었고, 라이브로 amount=0 웹훅이 분쟁 0건인 걸 확인했습니다.
  • 원 결제 미대조: 존재하지 않는 orderNo, 원 결제보다 큰 금액의 차지백도 그대로 분쟁이 됐습니다. 어드민이 (PG를 믿고) 패소 확정하는 순간 실존하지 않는 매출에 역분개가 찍힙니다. 개시 시 승인 완료 결제 실존 + 금액 상한을 대조하게 했습니다.
  • 위험한 기본값: resolve가 "WON".equalsIgnoreCase(outcome)라, 오타든 null이든 “WON”이 아니면 전부 LOST(비가역 역분개)였습니다. WON/LOST 외에는 400으로 거부하게 했습니다. 라이브로 "WIN" 오타가 400, 상태 OPEN 유지 확인.
  • 동시 승패 확정: @Version이 없어 두 어드민이 동시에 WON/LOST를 확정하면 최종 상태는 WON인데 LOST 이벤트가 이미 발행될 수 있었습니다(승소에 역분개). 낙관적 락을 추가했습니다.

전액취소는 멱등인데, 부분취소는 아니었다

취소를 정산에 반영하는 코드가 이렇게 갈려 있었습니다.

if (event.fullyCanceled()) {
item.cancel(); // 전액: status==CANCELED 가드로 재배달에도 멱등
} else {
item.reduce(event.cancelAmount()); // 부분: 델타를 뺀다 → 재배달 시 또 뺀다 (버그)
}

전액취소는 “이미 CANCELED면 무시” 가드가 있어서 at-least-once 재배달에 안전합니다. 부분취소는 다릅니다. reduce로 델타(취소분)를 빼기만 합니다.

이벤트는 아웃박스로 최소 한 번 이상 배달됩니다. 리스너가 성공했는데 완료 표시 전에 크래시가 나면 재기동 때 같은 취소 이벤트가 다시 옵니다. 그럼 reduce(3000)가 두 번 호출되어 6000이 깎입니다. 실제론 3000만 취소했는데도. 가맹점이 취소분의 두 배를 덜 받습니다. 멱등 가드가 부분취소 경로에만 빠져 있었고, 그 비대칭이 문제였습니다.

해법은 관점을 바꾸는 것이었습니다. 빼지 말고, 되어야 할 값으로 세팅하자. 결제 엔티티는 이미 balanceAmount(취소 후 잔액)를 들고 있었습니다. 그걸 이벤트에 실었습니다.

// 이벤트가 델타(cancelAmount)만이 아니라 "취소 후 잔액(절대값)"을 함께 나릅니다
new PaymentCanceledEvent(orderNo, paymentId, cancelAmount, settleableBalance, fullyCanceled);
// 정산은 델타를 빼는 대신, 잔액으로 세팅한다 → 몇 번 와도 같은 값
public void applySettleableBalance(long settleableBalance) {
this.amount = Math.max(0L, settleableBalance);
}

이제 같은 취소가 세 번 와도 amount는 잔액 그대로입니다. 절대값 세팅은 중복 배달에 멱등입니다.

다만 순서 역전에는 안전하지 않습니다. 이것도 외부 리뷰가 짚었습니다.

1차 취소 후 잔액 7,000
2차 취소 후 잔액 4,000
2차가 먼저 소비 → 4,000
늦게 온 1차 적용 → 7,000 ← 되돌아갑니다

멱등한 것과 순서 독립적인 것은 다릅니다. 그래서 취소 순번을 함께 봅니다.

public void applySettleableBalance(int cancelSeq, long settleableBalance) {
if (cancelSeq <= lastCancelSeq) {
return; // 이미 반영했거나, 더 오래된 취소가 늦게 도착했습니다
}
this.lastCancelSeq = cancelSeq;
this.amount = Math.max(0L, settleableBalance);
}

순번은 결제 도메인이 이미 부여하고 있던 값입니다(PaymentCanceledEvent.cancelSeq).
정산이 자기 번호를 새로 매기면 같은 취소인지 판단할 근거가 사라집니다. 델타(cancelAmount)는 원장 역분개와 에스크로 환불이 여전히 쓰기 때문에 같이 실어 보내고, 정산만 절대 잔액을 봅니다.

정정 이력: 나중에 대사 쪽에서 같은 이름의 메서드를 발견하고 일괄 수정할 뻔했습니다.
대사는 “무슨 일이 있었나”라는 사건 이력이라 덮어쓰면 이미 나간 판정이 무효가 됩니다.
대사에서는 취소를 별도 음수 행으로 쌓도록 바꿨습니다(대사 이력을 다룬 10편). 정산은 “지금 얼마를 지급할 것인가”라는
현재 상태
라 덮어쓰는 게 맞고, 두 모듈의 처방이 다른 게 정상입니다.

멱등성을 “중복을 감지해서 막는다”로 풀 수도 있습니다(취소 ID를 저장해 두고 비교). 더 단순한 건 연산 자체를 멱등하게 만드는 것. “빼기”를 “세팅”으로 바꾸니 감지 로직 없이 멱등이 됐습니다. 라이브로 부분취소 10,000을 걸어보니 정산액이 잔액 20,000으로 맞았습니다.


남는 생각

다섯 갈래 다 예외를 던지지 않았습니다. 로그도 안 남았습니다. 수수료는 계산됐고, 정산은 실행됐고, 취소는 성공했습니다. 숫자만 틀렸습니다.

결제에서 제일 무서운 실패가 이거입니다. 터지면 알림이 오지만, 조용히 틀리면 아무도 모른 채 장부에 남습니다. 그래서 대사가 최종 방어선이고, 복식부기가 수학으로 증명하는 층이 필요했습니다.

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.