모든 글
약 21분 분량 학습 프로젝트/pay

기능 확장과 정밀 감사

목차

결제 시스템 시리즈: 기능 확장과 정밀 감사. 원 연재 여러 편을 한 챕터로 묶었고, 각 절이 원래 한 편이다.

빌링키도 dunning도 다 만들어놓고: 구독을 부를 방법이 없었다

0. 또 “만들고 안 쓴 것”, 이번엔 통째로

지금까지는 파일 몇 개 수준이었는데, 이번에 찾은 건 모듈 하나였다. 감사재감사로 “만들어놓고 배선 안 한 것들”을 계속 잡아왔는데, 전체 기능 관점에서 훑어보니 subscription(구독)이 통째로 숨어 있었다.

안을 열어보니 이미 꽤 정성껏 지어져 있었다.

  • 빌링키: envelope 암호화 + 블라인드 인덱스로 저장(카드 토큰이라 민감).
  • 정기청구 배치: runBillingCycle이 청구일 도래한 구독을 청구.
  • dunning: soft decline은 재시도(유예 기간), hard decline은 정지, 재시도 소진 판정까지.

그런데 문제가 있었다.

이걸 부를 API가 하나도 없었다. 컨트롤러 0개. 구독을 개시할 수도, 조회할 수도, 해지할 수도 없다. 정기청구 배치는 있는데 청구할 구독을 만들 방법이 없으니, 사실상 아무도 못 쓰는 완성품이었다. 금고를 만들고 안 채운 것처럼, 이번엔 방을 다 지어놓고 문을 안 단 셈이다.

구독은 SaaS·멤버십 어디에나 있는 핵심 결제 상품이라, 이 표면부터 완성했다.

1. 사용자 표면: 개시·조회·해지·즉시청구

REST 표면을 얹었다.

POST /api/v1/subscriptions 구독 개시(빌링키 + 월 금액)
GET /api/v1/subscriptions 내 구독 목록
GET /api/v1/subscriptions/{id} 상세 + 청구 이력
POST /api/v1/subscriptions/{id}/cancel 해지
POST /api/v1/subscriptions/{id}/bill-now 즉시 청구(데모/운영)

기존 서비스가 이미 subscribe·changePlan·runBillingCycle을 갖고 있어서, 부족한 조각만 채우면 됐다. cancel, 조회(subscriptionsOf/detail), 데모용 즉시청구(billNow)가 그것이다.

여기서 신경 쓴 게 소유권이다. 구독은 회원 개인 자산이라, 남의 구독을 조회·해지하면 안 된다.

private Subscription requireOwned(Long subscriptionId, long userId) {
Subscription s = subscriptionRepository.findById(subscriptionId)
.orElseThrow(() -> SubscriptionException.notFound(subscriptionId));
if (s.getUserId() != userId) {
throw new SubscriptionException("SUBSCRIPTION_FORBIDDEN", "본인의 구독만 접근할 수 있습니다.");
}
return s;
}

주문에서 IDOR를 막던 것과 같은 원칙이다. userId는 인증 principal에서 얻고(클라이언트가 준 값은 믿지 않는다), 소유권 검증을 다른 무엇보다 앞에 둔다. 실기동으로 user2가 user1의 구독을 조회하면 403이 나는 것까지 확인했다.

2. 배치와 즉시청구가 한 로직을 공유하게

데모에서 “즉시 청구”를 눌러 dunning을 관찰하고 싶은데, 청구 로직은 배치(runBillingCycle) 루프 안에 있었다. 복붙하면 두 벌이 갈라진다. 그래서 한 구독 청구bill()로 추출했다.

private void bill(Subscription subscription, LocalDate today) {
BillingResult result = billingGateway.charge(subscription.getBillingKey(), subscription.getPlanAmount());
switch (result) {
case SUCCESS -> handleSuccess(subscription); // renew + 다음 청구일
case SOFT_DECLINE -> handleSoftDecline(subscription, today); // 재시도 예약/유예
case HARD_DECLINE -> handleHardDecline(subscription); // 정지
}
subscriptionRepository.saveAndFlush(subscription);
}

이제 배치는 청구일 도래분을 돌며 bill()을 부르고, 즉시청구(billNow)도 같은 bill()을 한 번 부른다. dunning·상태전이·이력 기록이 한 곳에서만 일어나니, 배치로 청구하든 버튼으로 청구하든 동작이 같다.

3. 눌러볼 수 있는 구독

데모 콘솔에 구독 패널을 붙였다.

구독 정기결제 데모: 개시·상태·청구주기·해지

빌링키로 구독을 개시하면 ACTIVE로 뜨고 다음 청구일이 한 달 뒤로 잡힌다. “즉시 청구”를 누르면 청구가 일어나 다음 청구일이 갱신되고, 상세에는 청구 이력(성공/decline)이 쌓인다. 해지하면 CANCELED. 서비스 계층에만 있던 구독이 이제 눌러볼 수 있는 기능이 됐다.

실기동으로 전 흐름을 확인했다.

구독 개시 → id=1 ACTIVE, 다음 청구 2026-08-07
즉시 청구 → 성공, 다음 청구 2026-09-07로 갱신, 이력[SUCCESS]
해지 → CANCELED
user2가 user1 구독 조회 → 403 (IDOR 차단)

마치며

이번 건 새 로직이라기보다 완성이다. 빌링키 암호화도, 정기청구도, dunning도 이미 있었다. 다만 부를 수 없으면 없는 것과 같다. 이 시리즈가 반복해서 보여준 “만들었다 ≠ 쓸 수 있다”의, 이번엔 모듈 통째 버전이었다. 표면을 얹을 때도 소유권 검증 같은 신뢰 경계는 반사적으로 따라와야 했고, 잘 지어둔 방에 문을 달고 자물쇠를 채우고 나서야 구독이 쓸 수 있는 상품이 됐다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 구독 개시·즉시청구·이력·해지·IDOR 차단을 실 MySQL로 검증했다.


결제수단마다 롤백 계약이 다르다: 월렛을 체크아웃에 배선하며 배운 것

0. 이번 고아는 “표면도 없고 배선도 없는” 완전체였다

구독이 “서비스만 있고 부를 API가 없던” 케이스였다면, 훑다 보니 그보다 더한 게 있었다. wallet(선불 월렛)이다. grep으로 WalletService를 찾으면 월렛 패키지 밖에서는 단 한 번도 안 나온다. 컨트롤러도 없고, 체크아웃도 안 부른다. 충전·차감·환불·잔액 로직에 낙관적 락 재시도까지 정성껏 갖춰놓고, 아무도 안 쓰는 완전한 고아였다.

월렛은 토스·카카오페이 어디에나 있는 핵심 결제수단이라, 이번엔 두 가지를 했다.

  1. 사용자 표면: 충전·잔액·이력 REST (/api/v1/wallet).
  2. 체크아웃 배선: 카드·포인트와 함께 쓰는 복합결제 수단.

미리 말해두면, 2번을 하다가 생각보다 깊은 걸 마주쳤다. 월렛을 포인트처럼 취급하면 자금 손실 버그가 된다.

1. 표면은 쉬웠다

먼저 표면. 이미 있는 charge/balance에 이력 조회만 얹으면 됐다.

POST /api/v1/wallet/charge 충전(전금법 기명 한도 검증)
GET /api/v1/wallet 잔액 + 최근 거래 이력 20건

이력은 findTop20ByUserIdOrderByIdDesc다. Top20으로 DB에서 LIMIT을 걸어 무한 적재를 막고, id(단조증가) 정렬로 같은 시각 거래의 타이순서 흔들림도 없앴다. userId는 늘 그렇듯 인증 principal에서 얻어 본인 월렛만 본다. 실 MySQL로 5만→3만 누적 8만, 이력 2건, 한도초과 409까지 확인했다. 여기까진 평이했다.

2. 체크아웃에 넣으려다 마주친 것: 롤백 계약이 다르다

기존 체크아웃은 이미 카드 + 포인트 복합결제를 하고 있었다(사가 편). “월렛도 세 번째 몫으로 끼우면 되겠네” 하고 포인트를 흉내 내려다, 둘이 근본적으로 다르다는 걸 알았다.

포인트 서비스는 클래스에 @Transactional이 걸려 있다.

@Transactional
public class PointService {
public void use(long userId, long amount, String orderNo) { ... }
}

pointService.use()는 예약 트랜잭션(reserve)에 합류한다. 예약이 뒤에서 롤백되면 포인트 차감도 자동으로 같이 롤백된다. 그래서 사가는 예약 실패 시 포인트를 명시적으로 되돌리지 않는다. 트랜잭션이 알아서 해주기 때문이다.

그런데 월렛은 일부러 @Transactional 걸었다.

// 재시도 루프를 단일 @Transactional로 감싸지 않는다 — 첫 낙관적 락 충돌에서
// 트랜잭션이 rollback-only가 되어 재시도가 무의미해지기 때문.
private long mutateWithRetry(...) {
while (true) {
try {
...
accountRepository.saveAndFlush(account); // 각 시도가 자체 짧은 tx로 커밋
...
} catch (OptimisticLockException e) { /* 다음 시도 */ }
}
}

낙관적 락 재시도를 하려면 각 시도가 독립적으로 커밋돼야 한다(한 tx로 감싸면 첫 충돌에 rollback-only가 박혀 재시도가 죽는다). 그 결과는 이렇다.

월렛 차감은 “커밋되는 부수효과”다. 예약 트랜잭션이 나중에 롤백돼도 월렛 차감은 되돌아오지 않는다. 포인트처럼 취급하면, 카드가 거절됐을 때 차감된 월렛 잔액이 그대로 증발한다(자금 손실).

그래서 월렛은 포인트보다 카드 레그를 닮았다. 카드 승인도 커밋되는 외부 효과라, 사가는 실패 시 망취소를 아웃박스에 적재명시적으로 보상한다. 월렛도 똑같이 명시적 환불이 필요했다.

세 결제수단의 롤백 계약이 이렇게 갈린다.

결제수단트랜잭션실패 시 되돌리기
포인트예약 tx에 합류자동 롤백 (아무것도 안 함)
카드외부 승인, 커밋됨명시적 망취소(아웃박스)
월렛자체 tx, 커밋됨명시적 환불(동기, 멱등)

3. 명시적 보상을, 사가 재진입에 안전하게

보상이 들어갈 자리는 settle의 두 실패 분기, 카드 거절승인 후 재고 부족이다.

} else {
// 명시적 거절: 선점 포인트·월렛 복원, 주문 PENDING_PAYMENT로 복귀.
if (pointAmount > 0) pointService.restore(userId, pointAmount, orderNo);
if (walletAmount > 0) walletService.refund(userId, walletAmount, orderNo); // 멱등
order.revertToPending();
}

여기서 중요한 게 멱등이다. 사가는 크래시하면 복구 배치가 settle을 다시 돌린다. 환불이 멱등하지 않으면 복구가 이중 환불을 해버린다. 그래서 월렛 차감/환불을 orderNo로 멱등화했다. 포인트가 이미 쓰던 계약 그대로다.

// (1) 이력에 같은 (orderNo, type)이 있으면 재적용 안 함
if (orderNo != null && transactionRepository.existsByOrderNoAndType(orderNo, type)) {
return balance(userId);
}
// ... 차감/환불 ...
// (2) 그 사이 다른 스레드가 먼저 넣어 (order_no, type) 유니크를 위반하면 → 멱등 skip
catch (DataIntegrityViolationException e) { return balance(userId); }

존재검사(빠른 경로)와 (order_no, type) 유니크 인덱스(최후 방어선) 이중으로 막는다. 사가 경로에선 같은 주문의 동시 차감이 주문 상태전이(startPayment)로 상위에서 이미 직렬화되니, 잔액과 이력이 원자적이지 않아도 이중반영이 안 난다.

그리고 reserve에서 월렛 차감을 맨 마지막(주문 저장 후)에 뒀다. 커밋되는 부수효과라, 뒤에 실패할 in-tx 작업이 남아 있으면 그만큼 고아가 될 창이 생긴다. 맨 뒤에 두면 그 창이 사라진다.

4. 복구는 “분할”을 어디서 아는가: 원장에서 역산

미묘한 문제가 하나 더 있었다. 크래시 복구가 settle을 재실행하려면 카드·포인트·월렛 각 몫을 알아야 하는데, 원 요청의 분할은 주문에 저장돼 있지 않다. 기존 복구는 포인트 = 총액 - 카드로 역산했는데, 월렛이 끼면 총액 - 카드 = 포인트 + 월렛이라 둘을 못 나눈다.

답은 append-only 월렛 원장이었다. 차감할 때 order_no를 남겼으니, 그게 진실의 원천이다.

long walletAmount = walletService.reservedAmountForOrder(orderNo); // USE 이력에서 역산
long pointAmount = order.getTotalAmount() - cardAmount - walletAmount;

복식부기 원장을 “잔액의 파생 근거”로만 보다가, 여기서는 복구가 상태를 재구성하는 근거로 썼다. 부수효과를 기록으로 남겨두면, 나중에 그 기록이 시스템을 고쳐 세우는 재료가 된다.

5. 눌러볼 수 있는 월렛

데모 콘솔에 월렛 패널을 붙이고, 체크아웃에 월렛 몫을 넣을 수 있게 했다.

선불 월렛 데모: 충전·잔액·이력, 체크아웃 복합결제 수단

실 MySQL로 전 흐름을 확인했다.

충전 → 월렛 80,000
주문 20,000 → 카드 14,000 + 월렛 6,000 결제
결과 → 주문 PAID, 카드결제 DONE(14,000)
월렛 → 80,000 → 74,000, USE 이력(order_no 스탬프) 1건
금액불일치 → 월렛 차감 전 403 차단(잔액·이력 불변)

카드+월렛이 합쳐져 결제되고, 월렛이 6,000 줄고, 그 차감엔 order_no가 찍혀 있다. 분할이 안 맞으면 월렛을 건드리기 전에 막힌다.

마치며

표면 얹는 건 반나절이었는데, 배선은 그렇지 않았다. “포인트처럼 하면 되겠지”가 자금 손실 버그였다. 결제수단마다 트랜잭션과 맺는 관계가 다르고, 그 관계가 곧 실패 시 되돌리는 방법을 결정한다. 예약 트랜잭션에 합류하는 포인트는 자동 롤백이면 충분하다. 커밋되는 부수효과는 트랜잭션이 지켜주지 않으니 명시적으로 보상해야 하고, 그 보상은 사가 재진입을 견디게 멱등이어야 하며, 상태를 재구성할 근거는 append-only 기록에 미리 남겨둬야 한다. 이 계약을 착각하면 돈이 샌다. 고아 하나 살리는 일이, 결국 사가의 실패 의미론을 한 겹 더 정확하게 만드는 일이 됐다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 카드+월렛 복합결제·orderNo 멱등 환불·원장 기반 복구를 실 MySQL로 검증했다.


쌓이기만 하고 안 보이던 것들: 주문 목록·원장·포인트 적립을 표면으로

0. 또 “만들어지는데 안 보이는 것”

구독월렛이 “로직은 있는데 부를 수 없던” 문제였다면, 이번 셋은 결이 조금 다르다. 데이터는 계속 만들어지는데, 그걸 볼 표면이 없어 죽어 있던 것들이다.

  • 주문: 단건 조회(GET /orders/{orderNo})만 있고, “내 주문 목록”이 없다.
  • 원장: 결제할 때마다 복식부기 분개가 쌓이는데, 들여다볼 방법이 없다.
  • 포인트: 차감(USE)·복원·환불만 있고 적립(EARN)이 없다. 쓸 줄만 알고 줄 줄은 몰랐던 셈이다.

셋 다 작지만 같은 병의 다른 얼굴이라, 한 번에 정리했다.

1. 내 주문 목록: 소유권을 쿼리로 격리

목록 API를 얹었다.

GET /api/v1/orders 내 주문 목록(최신 50건 요약)

여기서 IDOR(남의 주문 조회)를 막는 방식이 단건 조회 때와 다르다. 단건은 주문을 불러온 뒤 verifyOwner로 막았다. 목록은 쿼리 자체가 본인 것만 가져온다.

public List<OrderSummaryView> myOrders(long authenticatedUserId) {
return orderRepository.findTop50ByUserIdOrderByIdDesc(authenticatedUserId).stream()
.map(OrderSummaryView::from)
.toList();
}

userId는 인증 principal에서 오고, WHERE 절이 그걸로 걸리니 남의 주문은 애초에 조회 대상이 아니다. 불러온 뒤 검증으로 걸러낼 것도 없이 아예 안 가져오니, IDOR 방어 중에서도 가장 단순한 형태다. 실기동으로 user1은 자기 주문이 최신순으로 뜨고, user2는 0건인 걸 확인했다. Top50으로 상한을 둬 무한 적재도 막았다.

2. 원장 감사 뷰: 차변·대변 균형까지

복식부기 원장은 결제 승인마다 PG미수금(차변) ↔ 매출(대변) 분개를 append-only로 쌓는다. 그런데 운영자가 그걸 볼 수가 없었다. 감사용 어드민 뷰를 얹었다.

GET /api/v1/admin/ledger 최근 원장 트랜잭션 50건(ADMIN 전용)

뷰에 균형 여부를 함께 실었다. 각 트랜잭션의 차변 합과 대변 합이 같은지(imbalance()==0)를 바로 보여준다.

static LedgerView from(LedgerTransaction tx) {
return new LedgerView(..., tx.imbalance() == 0, ..., // balanced
tx.getEntries().stream().map(EntryView::from).toList());
}

감사자가 정합 위반을 한눈에 보라는 뜻이다. 실기동으로 보니 재밌는 게 있었다. 카드 14,000 + 월렛 6,000으로 결제한 주문의 원장 분개가 14,000이었다. 총액 20,000이 아니었다.

월렛 6,000은 이미 받아둔 선불(prepaid)이라, 새로 생기는 PG 미수금이 아니다. 그래서 원장엔 카드분 14,000만 PG미수금 ↔ 매출로 잡힌다. 사가에서 본 결제수단의 경제적 의미 차이가 이번엔 장부에 그대로 드러난 것이고, 회계가 맞으려면 이게 맞아야 한다.

비관리자가 이 엔드포인트를 부르면 403이 나는 것도 확인했다.

3. 포인트 적립: 실결제액 기준, 이중적립 없이

포인트는 쓸 줄만 알았다(USE). 결제가 끝나면 적립(EARN)이 돼야 하는데 그게 없었다. 사가의 성공 분기(markPaid)에 적립을 붙였다.

if (allDeducted) {
order.markPaid();
// 실결제액(카드+월렛, 포인트 사용분 제외) 기준 적립 — 포인트로 포인트를 버는 이중적립 방지.
long paidByMoney = cardAmount.amount() + walletAmount;
pointService.earn(order.getUserId(), paidByMoney * EARN_RATE_PERCENT / 100, orderNo);
}

두 가지를 신경 썼다.

  • 적립 기준: 카드 + 월렛, 즉 실제 돈으로 낸 몫만. 포인트로 낸 몫을 넣으면 포인트로 포인트를 버는 이중적립이 된다. 전액 포인트 결제는 적립이 0이다.
  • 멱등: earnorderNo로 멱등이다(EARN 이력 존재검사). 사가 복구가 성공 분기를 재실행해도 이중적립되지 않는다. 차감·환불에서 쓰던 계약을 적립에도 그대로 적용했다.

조회 표면(GET /api/v1/points)도 함께 얹어, 사용자가 잔액·적립 이력을 보게 했다.

포인트·월렛 데모: 적립·잔액·이력

실기동으로 확인했다.

포인트 0 → 30,000 카드결제 → 잔액 300(실결제액의 1%)
이력[EARN 300, order_no 스탬프], MySQL 확인

적립률은 지금 정책 상수(1%)로 뒀다. 쿠폰·등급별 차등 같은 건 이후 정책으로 확장할 자리를 남겨둔 것이다(“폴리시는 좀 더 고민”이라는 스스로에게 남긴 메모이기도 하다).

마치며

셋 다 거창한 로직은 아니다. 공통점은 뚜렷했다. 주문은 쌓이는데 목록이 없고, 분개는 쌓이는데 장부를 못 보고, 결제는 되는데 적립이 안 됐다. 백엔드의 “완성”은 로직이 도는 데서 끝나지 않고, 그 결과가 관측·소비 가능해질 때까지다. 표면을 얹을 때도 소유권 격리·정합 표시(원장 균형)·이중적립 방지 같은 결제 도메인의 반사신경은 그대로 따라왔다. 이걸로 구독·월렛에 이어, “만들고 안 쓰던” 조각들을 한 차례 크게 정리했다.


전체 코드는 Spring Modulith 기반 결제 시스템에 있고, 내 주문 목록(소유격리)·원장 감사 뷰(균형)·포인트 적립(멱등)을 실 MySQL로 검증했다.


데모 계정밖에 없던 인증에 진짜 회원을 붙이다: 숫자 userId 계약을 지키면서

0. 로그인은 되는데, “회원”이 없었다

기능을 이만큼 표면으로 끌어올리고 나니, 정작 제일 밑바닥이 비어 있었다. 실제로 가입한 회원이 없었다. 이번 작업의 관건을 먼저 말하면, 회원을 붙이면서 시스템 전체가 전제하는 숫자 userId 계약을 한 줄도 안 깨는 것이었다.

지금까지 로그인은 SecurityConfig에 이렇게 박아둔 게 전부였다.

UserDetails user1 = User.withUsername("1").password(...).roles("USER").build();
UserDetails user2 = User.withUsername("2").password(...).roles("USER").build();
return new InMemoryUserDetailsManager(admin, admin2, user1, user2);

데모 유저 “1”, “2”와 어드민 둘. 시연에는 충분했지만 “이메일로 가입해서 로그인하는” 진짜 회원은 만들 방법이 없었다.

1. 겁나게 조심해야 했던 계약 하나

회원을 붙이는 것 자체는 흔한 일이다. Member 엔티티, 저장소, BCrypt 해시, POST /signup. 문제는 이 시스템이 처음부터 깔고 있던 계약 하나였다.

시스템 전체가 소유권을 이렇게 검증한다.

long userId = Long.parseLong(principal.getName());

주문도, 결제도, 월렛도, 포인트도, 구독도 전부 이 한 줄로 “이 요청의 주인이 누구냐”를 얻는다. 그러니 principal.getName()은 반드시 숫자로 파싱돼야 한다. 데모 유저의 username이 왜 하필 "1", "2"였는지도 여기서 드러난다. username이 곧 userId였던 것이다.

그런데 회원은 이메일로 로그인하고 싶어 한다. principal.getName()alice@example.com이 되어버리면 Long.parseLong이 그 자리에서 터지고, 전 모듈의 소유권 검증이 한꺼번에 무너진다. 이게 이번 작업의 핵심 제약이었다.

2. 복합 UserDetailsService: 이메일로 찾되 숫자로 돌려준다

열쇠는 Spring Security의 동작 하나다. DaoAuthenticationProvider는 인증에 성공하면 로드된 UserDetails.getUsername()을 principal 이름으로 삼는다. 내가 뭘 입력해 로그인했는지는 principal에 남지 않는다. 여기에 답이 있었다. 로그인 식별자로 회원을 찾을 때, username을 회원의 숫자 id로 바꿔서 돌려주면 된다.

return username -> {
// (a) 데모 계정 우선 — admin/admin2/"1"/"2"는 인메모리 그대로
try {
return inMemory.loadUserByUsername(username);
} catch (UsernameNotFoundException notDemo) {
// (b) 실 회원 — 이메일로 조회하되, username은 숫자 id로 만든다
Member member = memberRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));
return User.withUsername(String.valueOf(member.getId()))
.password(member.getPasswordHash())
.roles(member.getRole())
.build();
}
};

alice@example.com으로 로그인해도, 인증이 끝나면 principal은 "1000"이다. auth.getName()이 숫자를 돌려주니 JWT subject도 숫자로 실리고, 이후 Long.parseLong(principal.getName())이 그대로 산다. 소유권 계약을 한 글자도 안 건드린 것이다.

데모 계정을 먼저 보고 없으면 회원을 보는 순서에도 의미가 있다. admin·"1"·"2"는 인메모리에서 즉시 잡히고(무중단), 데모 계정이 아닐 때만 DB를 한 번 때린다.

3. 데모 유저 “1”과 회원 id가 부딪히면?

여기서 미묘한 함정이 하나 있었다. 회원 id는 IDENTITY로 1부터 증가하는데, 인메모리 데모 유저의 principal도 "1", "2"다. 신규 회원이 id 1을 받으면 데모 유저 “1”과 principal이 겹친다. 소유권이 뒤섞이는 것이다.

그래서 회원 테이블의 시작 번호를 아예 떼어놨다.

-- 신규 회원 id는 1000부터 — 인메모리 데모 유저 principal("1"/"2")과 충돌하지 않게
alter table members auto_increment = 1000;

첫 회원이 1000번을 받으니 데모 유저 1·2와 영영 안 부딪힌다. 작지만, 안 넣었으면 시연 중에 조용히 데이터가 섞였을 자리다.

스키마는 Flyway로만 만든다(ddl-auto: validate). 그래서 V14__members.sql에 테이블·이메일 유니크 인덱스·AUTO_INCREMENT를 한 번에 담았다.

4. 나머지는 관례대로

member 모듈은 다른 모듈과 같은 뼈대다. @ApplicationModule(allowedDependencies = { "shared" })로 경계를 긋고, 엔티티는 정적 팩토리(Member.of)로만 만들고, 노출은 뷰 레코드(MemberView, passwordHash는 절대 안 싣는다)로 한다. 가입 서비스는 이메일 중복이면 EMAIL_ALREADY_EXISTS, 비밀번호는 BCrypt로 해시해서만 저장한다.

예외는 도메인 코드를 HTTP로 매핑하는 GlobalExceptionHandler에 두 줄 얹었다. EMAIL_ALREADY_EXISTS → 409, MEMBER_NOT_FOUND → 404.

회원 가입·로그인 데모

5. 남은 것

이제 흐름이 이렇다. POST /api/v1/members/signup으로 이메일 가입 → POST /api/v1/auth/login에 그 이메일을 그대로 태움 → 숫자 subject를 가진 JWT 발급 → 주문·결제·월렛이 그 숫자로 소유권을 검증. 데모 계정도 그대로 살아 있다.

붙이고 나서 제일 마음에 든 건 기존 계약을 한 줄도 안 고쳤다는 점이다. 소유권 검증 코드는 전 모듈에 흩어져 있는데, 그걸 다 놔두고 인증 입구에서 username만 숫자로 번역했다. 계약을 지키는 가장 싼 방법은, 계약이 보는 값을 계약이 기대하는 모양으로 만들어 넘겨주는 것이었다.


결제는 됐는데 그다음이 없었다: 차지백/분쟁을 상태기계·멱등 웹훅·패소 역분개로

회원까지 붙이고 나니 가입 → 로그인 → 결제 → 정산이 한 줄로 이어진다. 그런데 그 줄의 끝, 결제 “이후”가 비어 있었다.

분쟁/차지백 데모

결제는 되는데, 지면 어쩔 건데

데모를 돌리다 문득 이상했다. 결제는 승인되고, 취소도 되고, 정산도 붙는데 차지백이 오면 아무 일도 일어나지 않았다. 카드 결제에서 차지백은 일상으로 온다. 고객이 “이거 내가 안 샀다”고 카드사에 이의를 걸면, 카드사는 일단 돈을 고객에게 돌려주고 가맹점에 통보한다. 가맹점은 정해진 기한 안에 “정상 거래였다”는 증빙을 제출해 다퉈야 하고, 지면 그 매출은 사라진다.

승인만 있고 이 사후 흐름이 없으면, 장부는 계속 “매출이 있다”고 우기는데 실제 돈은 빠져나간 상태가 된다. 결제 시스템의 정합성이 조용히 깨지는 지점이다. 그래서 dispute 모듈을 새로 만들었다.

분쟁은 상태기계다

차지백 대응은 단계가 정해져 있다. 그래서 자유롭게 필드를 바꾸는 대신, 전이만 허용하는 상태기계로 못 박았다.

OPEN ──submitEvidence──▶ EVIDENCE_SUBMITTED
│ │
└──────resolve(win)───────────┴──▶ WON / LOST

상태 전이는 엔티티 메서드가 강제한다. 서비스는 그걸 부를 뿐이다. submitEvidence()OPEN에서만, resolve()OPEN/EVIDENCE_SUBMITTED에서만 동작하고, 이미 확정(WON/LOST)된 분쟁을 다시 건드리면 예외를 던진다.

public void submitEvidence(String memo) {
if (status != DisputeStatus.OPEN) {
throw DisputeException.invalidTransition(status, DisputeStatus.EVIDENCE_SUBMITTED);
}
this.evidenceMemo = memo;
this.status = DisputeStatus.EVIDENCE_SUBMITTED;
}
public void resolve(boolean win) {
if (status != DisputeStatus.OPEN && status != DisputeStatus.EVIDENCE_SUBMITTED) {
throw DisputeException.invalidTransition(status, win ? DisputeStatus.WON : DisputeStatus.LOST);
}
this.status = win ? DisputeStatus.WON : DisputeStatus.LOST;
this.resolvedAt = Instant.now();
}

전이 규칙이 엔티티 안에 있으니, 서비스든 배치든 컨트롤러든 어디서 호출해도 잘못된 전이는 물리적으로 불가능하다. 상태를 문자열로 훑어보며 “지금 이거 해도 되나” 방어 코드를 흩뿌릴 필요가 없다.

웹훅은 두 번 온다: 멱등키는 차지백 식별자

PG 웹훅의 제1원칙은 같은 이벤트가 두 번 온다는 것. 네트워크가 흔들리거나 우리가 200을 늦게 주면 PG는 재전송한다. 차지백 웹훅을 순진하게 받으면 같은 차지백에 대해 분쟁이 두 건, 세 건 생긴다.

그래서 외부 차지백 식별자(chargebackId)를 멱등키로 삼았다. 개시는 “이미 있으면 기존 걸 반환”하고, DB에는 chargeback_id UNIQUE 제약을 걸어 동시 수신까지 막는다. 체크아웃 사가에서 세운 “재시도 가능한 경계는 멱등해야 한다”는 원칙 그대로다.

public DisputeView openFromChargeback(String chargebackId, String orderNo, Long paymentId,
long amount, String reason) {
var existing = repository.findByChargebackId(chargebackId);
if (existing.isPresent()) {
return DisputeView.from(existing.get()); // 멱등: 중복 웹훅 흡수
}
try {
Dispute saved = repository.save(Dispute.open(chargebackId, orderNo, paymentId, amount,
reason, Instant.now().plus(RESPOND_WINDOW)));
return DisputeView.from(saved);
} catch (DataIntegrityViolationException e) {
// 동시 수신으로 UNIQUE를 스치면 재조회로 흡수
return repository.findByChargebackId(chargebackId).map(DisputeView::from).orElseThrow(() -> e);
}
}

인증은 기존 PG 웹훅과 같은 HMAC 서명 검증기를 그대로 재사용했다. Spring Modulith에서 payment 모듈의 웹훅 서명 검증기를 webhook 명명 인터페이스로 노출하고, dispute가 그것만 참조하게 배선했다. 검증기 하나 쓰자고 payment 내부를 통째로 열지는 않는다. 서명 위조만 401로 돌려주고, 나머지 처리 예외는 재전송 폭주를 막으려 200으로 흡수하는 것도 기존 웹훅 컨트롤러와 같다.

지면 되돌린다: 패소 역분개

핵심은 여기다. 패소(LOST)하면 원매출을 원장에서 되돌려야 한다. 이 시스템의 원장은 복식부기라 원거래를 지우지 않는다. 대신 반대 방향 분개를 하나 더 쌓아 이력을 보존한다. 결제 승인이 PG미수금(차변) ↔ 매출(대변)이었으니, 패소 역분개는 그 반대인 매출(차변) ↔ PG미수금(대변), 결제 취소와 같은 방향이다.

모듈 간 결합을 피하려고 dispute는 ledger를 모른다. 패소가 확정되면 DisputeLostEvent만 발행하고, ledger가 그 이벤트를 구독해 역분개한다. dispute → ledger 단방향이라 순환이 없다.

@ApplicationModuleListener
void onDisputeLost(DisputeLostEvent event) {
ledgerService.recordDisputeLost(event);
}
@Transactional
public void recordDisputeLost(DisputeLostEvent event) {
if (repository.existsByTxTypeAndSourceTypeAndSourceId("DISPUTE_LOST", "DISPUTE", event.disputeId())) {
return; // 멱등: 이미 역분개함
}
long amount = event.amount();
LedgerTransaction tx = LedgerTransaction.of("DISPUTE_LOST", "DISPUTE", event.disputeId(),
"분쟁 패소 역분개 " + event.orderNo(),
List.of(LedgerEntry.debit(AccountType.SALES, amount),
LedgerEntry.credit(AccountType.PG_RECEIVABLE, amount)));
repository.save(tx);
}

역분개도 멱등이다. (txType, sourceType, sourceId) = (DISPUTE_LOST, DISPUTE, disputeId) UNIQUE로, 같은 패소 이벤트가 아웃박스에서 재전달돼도 역분개는 한 번만 쌓인다. 이벤트 전달은 Modulith 아웃박스가 유실 없이 보장하고, 원장은 유니크로 중복을 막는다. at-least-once 전달 위에 얹은 멱등 소비다.

남는 이야기

증빙 제출 기한(respondByDeadline)은 수신 시점 + 7일로 잡아 두었지만, 실제로 기한을 넘겼을 때 자동으로 패소 처리하는 배치는 아직 없다. 지금은 어드민이 손으로 확정한다. 부분 차지백(원금 일부만 이의)이나 재분쟁(2차 차지백)은 이번 범위에서 뺐다. 상태기계가 한 단계 더 깊어지는 주제라 따로 다룰 만하다.

그래도 “승인으로 끝난 줄 알았던 결제”에 그다음이 생겼다. 차지백이 오면 분쟁이 서고, 두 번 와도 하나만 서고, 지면 장부가 스스로 균형을 되찾는다. 결제 시스템이 조금 더 정직해졌다.


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

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 이벤트가 이미 발행될 수 있었다(승소에 역분개). 낙관적 락을 추가했다.

5. 인증 쪽: BCrypt가 무기가 된다

login/signup에 rate limit이 전혀 없었다. 두 경로는 요청마다 BCrypt(~110ms CPU)를 태우니, 미인증 공격자에게 비대칭 DoS가 된다. 공격자는 요청 한 번으로 서버 CPU 110ms를 강제로 태운다. 알려진 이메일에 비밀번호를 무제한 시도하는 크리덴셜 브루트포스도 열려 있었다. 기존 유입제어(RateLimitFilter)는 인증된 쓰기 경로만 막고 미인증은 그냥 통과시켰기 때문이다.

login/signup을 클라이언트 IP 기준으로 제한하게 했다. 그런데 여기서 이 방어 코드가 다시 감사에 걸렸다. IP를 X-Forwarded-For 헤더에서 읽었는데, 그건 클라이언트가 위조·회전할 수 있어 per-IP 한도를 우회할 수 있다. 헤더 신뢰를 걷어내고 소켓 피어(getRemoteAddr)만 쓰고, 프록시 뒤 배포는 forward-headers-strategy로 신뢰된 프록시에서만 실 IP를 대입하게 문서화했다. 분산 IP 공격은 global 한도가 backstop한다.

여기에 회원 가입 동시성 시 유니크 위반이 500으로 새던 것도 409(EMAIL_ALREADY_EXISTS)로 매핑했다.

방어 코드를 짤 때도 신뢰 경계를 착각하면 그게 곧 구멍이 된다. rate limit을 붙이면서 “클라이언트가 준 IP”를 믿으면, 막으려던 우회를 스스로 열어주는 셈이다.

마치며

감사에서 나온 건 자금 손실 2(치명)·원장 오염 3·인증 갭 3, 거기에 방어 코드의 스푸핑 구멍까지. 전부 “완성했다”고 생각한 신규 코드에서 나왔다. 자금 손실 두 개는 같은 실수에서 왔다. 새 결제수단(월렛)을 기존의 검증된 계약(포인트의 예약·보상·취소·멱등)에 대칭시키지 않은 것.

새 결제수단을 추가한다는 건 기존 수단이 가진 모든 계약(예약→확정→취소→복구, 멱등의 수명)을 빠짐없이 대칭시키는 일이다. happy path만 이어놓고 끝낼 수 있는 작업이 아니었다. 하나라도 반만 베끼면 그 틈으로 돈이 샌다. “동작하니 됐다”는 결제 도메인에서 가장 비싼 착각이고, 그걸 깨준 건 실 MySQL 라이브 재현이었다. 적립 회수 버그는 유닛테스트 초록불 아래 숨어 있다가 라이브에서야 잡혔다.

전부 회귀 테스트로 고정했고, 마이그레이션은 통합 테스트가 검증한다.

Author
작성자 @범수

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

댓글

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