분석일: 2026-08-16 / 대상 브랜치: develop (e0d9aa01) 코드 수정 없이 분석만 수행함.

결론: 이중환불이 가능합니다

매칭 단계 취소 경로에 행 잠금이 하나도 없고, 환불 멱등 가드가 잠기지 않은 행을 읽고 판단하기 때문입니다.

경로

BoormiController:87BoormiService.unsubscribeOrder (BoormiService.java:144-163)

Orders order = orderService.getOrder(orderId);   // findById — 락 없음
... 소유권/상태 검사 (MATCHING | PENDING_BOORMI_CONFIRMATION)
orderService.cancel(order, CancelerCd.BOORMI);   // 상태 전이 + CANCEL insert
paymentService.refundByPoint(orderId);           // 환불

PaymentService.refundByPoint (PaymentService.java:74-89)

PointTx pointTx = pointTxRepository.findByOrderIdAndType(...);  // ← 락 없음
if (!pointTx.markRefundedFull()) return;                        // ← check-then-act
PointWallet w = pointWalletRepository.findByIdForUpdate(...);   // ← 락은 '판단 이후'에 잡힘
w.refund(pointTx.getAmount());

레이스 시나리오 (더블클릭 / 재시도 / 다중 인스턴스)

TX1 TX2
1 SELECT ORDERS → MATCHING (스냅샷 확정) SELECT ORDERS → MATCHING
2 상태 검사 통과 상태 검사 통과
3 SELECT POINT_TX → PENDING SELECT POINT_TX → PENDING
4 markRefundedFull()true markRefundedFull()true
5 POINT_WALLET FOR UPDATE 획득, +4000 락 대기
6 commit (락 해제) 락 획득 → 현재값(이미 +4000된 잔액) 읽고 다시 +4000
7 commit

결과: 포인트 8000 환급, POINT_LEDGERS 에 +4000 두 줄, CANCEL 두 줄.

왜 기존 방어책이 안 통하나

격리 수준 때문에 로컬에서 안 잡힙니다

운영 MySQL 기본값 REPEATABLE READ 에서는 TX2 의 스냅샷이 첫 읽기(1단계)에 고정되므로, TX1 이 커밋한 뒤에도 TX2 는 POINT_TX.status = PENDING 을 계속 봅니다. 창(window)이 TX2 시작 ~ TX2 의 지갑 락 획득 전체로 넓습니다.

로컬 H2 기본값은 READ COMMITTED 라, 3단계가 TX1 커밋 이후면 REFUNDED_FULL 을 읽어 가드가 걸립니다 — 창이 훨씬 좁아 재현이 어렵습니다. DDL 대소문자 이슈와 같은 "운영에서만 터지는" 부류입니다.