분석일: 2026-08-16 / 대상 브랜치:
develop(e0d9aa01) 코드 수정 없이 분석만 수행함.
매칭 단계 취소 경로에 행 잠금이 하나도 없고, 환불 멱등 가드가 잠기지 않은 행을 읽고 판단하기 때문입니다.
BoormiController:87 → BoormiService.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 두 줄.
markRefundedFull() 멱등 가드: 단일 트랜잭션 재실행에는 유효하지만, 두 트랜잭션이 같은 PENDING 스냅샷을 동시에 읽으면 둘 다 true 를 반환합니다. 상태 전이 판단이 락 밖에 있는 게 핵심입니다.findByIdForUpdate 지갑 락: 잠금 순서가 판단 이후라, 직렬화되는 건 amount += 산술뿐입니다. 두 번째 트랜잭션은 최신 잔액을 읽고 정상적으로 한 번 더 더합니다. 락이 오히려 lost update 를 막아주는 바람에 두 번 다 반영됩니다.UQ_POINT_TX_ORDER_TYPE (sql/sym-boorm-ddl.sql:365): 결제(INSERT)는 막지만 환불은 새 행이 아니라 기존 행의 UPDATE 라 제약이 걸리지 않습니다.unsubscribeOrder 는 orderService.getOrder → findById(락 없음)를 씁니다. 드리미 수락 경로용으로 만들어 둔 OrderRepository.findByOrderId(PESSIMISTIC_WRITE, OrderRepository.java:39)를 이 경로는 쓰지 않습니다. 설령 ORDERS UPDATE 가 flush 되며 락이 걸려도, 그건 커밋 시점이고 POINT_TX 비잠금 읽기는 여전히 옛 스냅샷을 봅니다.@Version: 프로젝트 전체에 없습니다. 낙관적 락도 없습니다.운영 MySQL 기본값 REPEATABLE READ 에서는 TX2 의 스냅샷이 첫 읽기(1단계)에 고정되므로, TX1 이 커밋한 뒤에도 TX2 는 POINT_TX.status = PENDING 을 계속 봅니다. 창(window)이 TX2 시작 ~ TX2 의 지갑 락 획득 전체로 넓습니다.
로컬 H2 기본값은 READ COMMITTED 라, 3단계가 TX1 커밋 이후면 REFUNDED_FULL 을 읽어 가드가 걸립니다 — 창이 훨씬 좁아 재현이 어렵습니다. DDL 대소문자 이슈와 같은 "운영에서만 터지는" 부류입니다.