# 출금 확정 멱등화 지침서 — 조건부 상태 전이 + 원장 멱등키 (DDL v2.22)

> 작성 2026-09-09 · 배경: 지식 repo `runbooks/troubleshooting.md` §93 (출금 1989 이중 DEBIT)
> DDL v2.22 는 **운영 DB 적용 완료**(2026-09-09 11:30). 코드는 이 지침서로 구현한다.
> 대상 모듈: `common`, `core`, `open-api`. 배포는 스프링 4모듈 전부.

## 1. 사고 요약

2026-09-08 11:37 파트너45 출금 1989(`wdr_2609_f16c3335`, 100 USDT) 확정 웹훅 처리 중 Telegram 무응답으로
트랜잭션이 64초 걸렸다. 모니터가 60초 뒤 같은 tx 를 재전송했고, 두 번째 처리가
`findById → status==BROADCASTING` 을 **REPEATABLE READ 스냅샷**으로 읽어(첫 번째는 미커밋) 통과 →
`modify()` 가 첫 커밋을 기다린 뒤 무조건 덮어쓰기 → `settlementService.debit()` 이 **두 번**(ledger 11794·11795).
파트너 웹훅 `WITHDRAWAL_CONFIRMED` 도 두 번(7307·7309). 온체인 출금은 1회 → 원장이 100 과소.
09-09 보정(ledger 12282 MIGRATION CREDIT 100) 완료.

같은 패턴의 입금 판은 v2.21(`uk_deposit_tx_to_active`)로 막았지만 **출금 확정 경로는 열려 있었다.**

## 2. 방어 2층

| 층 | 무엇 | 막는 것 | 못 막는 것 |
|---|---|---|---|
| 1층 | `withdrawals` 조건부 UPDATE(상태 전이 = 판정) | 확정 이중 처리 전부(DEBIT·웹훅·이력) | 상태를 안 거치고 `debit()` 을 직접 부르는 다른 경로 |
| 2층 | `ledger_entries.idem_key UNIQUE` | 어떤 경로든 같은 키의 원장 행 2개 | 상태 전이·웹훅 중복 |

둘 다 넣는다. 1층이 주 방어, 2층이 최후 방어선.

### 1층이 경합에서 통하는 이유
InnoDB 의 UPDATE 는 SELECT 와 달리 스냅샷을 보지 않는다. 행 X-lock 을 얻은 뒤 **최신 커밋 버전**으로
WHERE 를 평가한다. 두 번째 트랜잭션은 첫 번째 커밋을 기다렸다가 `status=CONFIRMED` 를 보고 0행.
affected rows 1 = "내가 전이시켰다", 0 = "누가 먼저 했다" — 둘 중 정확히 하나만 1 을 받는다.
`IXRepository.modify()` 는 non-null 전체 SET 이라 조건을 붙일 수 없다(`axim-modify-lost-update`).

## 3. DDL v2.22 (적용 완료 — 코드 짝만 맞춘다)

```sql
ALTER TABLE ledger_entries
  ADD COLUMN idem_key VARCHAR(80) NULL COMMENT '멱등키(v2.22) — 한 번만 기록돼야 하는 경로만 채움. NULL 은 복수 허용',
  ADD UNIQUE KEY uk_ledger_idem_key (idem_key);
```
- 기존 12,245행은 NULL. 백필 없음.
- `v2-docs/CRYPTOMENTS_V2_DDL.sql` 헤더에 v2.22 항목 추가 + `ledger_entries` 정의에 컬럼·UNIQUE 추가(이 지침서 작성 시 반영됨 — 확인만).

### 키 규약 (프리픽스:ID, 80자 이내)
| 경로 | idem_key | 비고 |
|---|---|---|
| 출금 확정 DEBIT (`WithdrawalService.onTxConfirmed` default 분기) | `WDR_CONFIRM:{withdrawalId}` | 이번 사고 |
| 입금 CREDIT (`DepositService.onTxConfirmed` 289행) | `DEP_CREDIT:{depositId}` | v2.21 이 deposits 행은 막았지만 원장 행은 아직 열려 있다 |
| 입금 FEE (`DepositService` 303행) | `DEP_FEE:{depositId}` | |
| 그 외 전부 | NULL | P2P 원금/안전망 보정(같은 출금에 DEBIT 복수가 정상), MIGRATION, 정산 유입, P2P 레그 등 |

☠️ **P2P 경로에 키를 넣지 마라.** `WITHDRAWAL/{id}/DEBIT` 이 정당하게 여러 번 찍힌다(348: 레그별 6건, 915·1483·1489: 원금+안전망).
P2P_SETTLEMENT 도 한 매칭에 CREDIT+FEE 등 복수라 이번 범위에서 제외. 키를 넣을 경로는 "참조 하나에 행 하나" 가
코드로 보장되는 3곳만이다.

## 4. 수정 대상 파일

### 4-1. `common/.../entity/LedgerEntry.java`
`@XColumn("idem_key") private String idemKey;` 추가(nullable). 다른 컬럼과 같은 스타일.

### 4-2. `common/.../mapper/WithdrawalMapper.java` — 조건부 확정 전이
```java
/**
 * 출금 확정 전이 — 상태 조건부 UPDATE. affected rows 가 판정값이다(1: 내가 전이, 0: 이미 종결/타 상태).
 * ☠️ modify() 로 바꾸지 마라 — 스냅샷을 믿고 덮어써서 2026-09-08 이중 DEBIT(1989)이 났다.
 */
@Update("UPDATE withdrawals SET status = 'CONFIRMED', tx_hash = #{txHash}, confirmed_at = NOW(6) "
      + "WHERE id = #{id} AND status IN ('BROADCASTING', 'PROCESSING', 'STALE')")
int confirmIfInFlight(@Param("id") Long id, @Param("txHash") String txHash);
```
- 허용 선행 상태는 `WebhookProcessingService.confirmWithdrawal` 이 지금 검사하는 3개(BROADCASTING/PROCESSING/STALE)와 **동일**하게.
- `<script>` 아님(부등호 없음).

### 4-3. `core/.../settlement/SettlementService.java`
- `debit(...)`, `credit(... , txHash)`, `recordFee(...)` 에 **`String idemKey` 를 마지막 인자로 받는 오버로드** 추가. 기존 시그니처는 `idemKey=null` 로 위임(호출부 무변경).
- 빌더에 `.idemKey(idemKey)`.
- `ledgerEntryRepository.save(entry)` 를 감싸서 `DataAccessExceptions.isDuplicateKey(e)` 이면
  `log.warn("원장 멱등 히트 — 이미 기록됨: idemKey={}, ref={}/{}", ...)` 후 **`null` 반환**(credit 의 txHash 스킵과 같은 계약).
  아니면 그대로 전파. Axim `save()` 는 유니크 위반을 RuntimeException 으로 감싸므로 **원인 체인으로 판정**(deposit-dedup 커밋 8516dff 와 동일).
- ⚠️ 잠금 순서 유지: `lockAndReadBalance` → INSERT. 멱등 히트도 게이트 잠금 안에서 일어나야 balance_after 체인이 흔들리지 않는다.
- ⚠️ `debit` 의 `@Transactional(noRollbackFor = ConflictException.class)` 유지. DuplicateKey 는 우리가 잡아서 던지지 않으므로 rollback-only 마킹 없음 — **단위 테스트로 고정**(아래 5-3).

### 4-4. `core/.../withdrawal/WithdrawalService.java` — `onTxConfirmed` (1083행~)
반환형을 `boolean` 으로 바꾼다(전이했으면 true).
```java
@Transactional
public boolean onTxConfirmed(Long withdrawalId, String txHash) {
    int updated = withdrawalMapper.confirmIfInFlight(withdrawalId, txHash);
    if (updated == 0) {
        Withdrawal now = findById(withdrawalId);
        log.warn("출금 확정 스킵 — 이미 전이됨(멱등): withdrawalId={}, status={}, txHash={}",
                withdrawalId, now.getStatus(), txHash);
        return false;                       // DEBIT·이력·알림 전부 없음
    }
    Withdrawal withdrawal = findById(withdrawalId);   // 전이 후 fresh
    String previousStatus = ... // 이력용 — 전이 전 상태가 필요하면 confirmIfInFlight 전에 읽은 값을 쓰되 "표시용" 임을 주석
    ... 기존 switch 유지 ...
    default 분기의 debit 호출에 idemKey = "WDR_CONFIRM:" + withdrawalId 전달.
    ... saveStatusHistory / 알림 등 기존 그대로 ...
    return true;
}
```
- `previousStatus` 는 이력(`saveStatusHistory`)에 쓰인다. UPDATE 전에 `findById` 로 읽어 두고 그 값을 쓴다(경합 시 스냅샷 값이지만 이력 표시용이라 허용 — 주석으로 명시).
- 기존 호출부 시그니처: `void` → `boolean` 변경으로 컴파일 깨지는 곳은 반환값 무시하면 되므로 전 모듈 `compileJava` 로 확인.

### 4-5. `open-api/.../webhook/service/WebhookProcessingService.java` — `confirmWithdrawal` (532행~)
```java
boolean transitioned = withdrawalService.onTxConfirmed(w.getId(), dto.getTxHash());
if (!transitioned) { continue; }             // 알림도 보내지 않는다 — WITHDRAWAL_CONFIRMED 이중 발송 차단
Withdrawal confirmed = withdrawalRepository.findOne(w.getId());
runAfterCommit(() -> sendWithdrawalConfirmedNotification(confirmed));
```
- 기존 `if (status == BROADCASTING || …)` 사전 검사는 **남겨도 된다**(빠른 스킵) — 단 주석으로 "스냅샷 검사라 방어선이 아니다. 판정은 onTxConfirmed 반환값" 명시.

### 4-6. `core/.../deposit/DepositService.java` — 289·303행
`credit(..., txHash, "DEP_CREDIT:" + deposit.getId())`, `recordFee(..., "DEP_FEE:" + deposit.getId())`.
credit 이 null(멱등 히트) 을 돌려주면 기존 null 처리 흐름(txHash 스킵과 동일)을 그대로 탄다 — 새 분기 불필요한지 확인.

## 5. 테스트 (순수 단위, Mockito — DB 없음)

### 5-1. `WithdrawalServiceConfirmIdempotencyTest`
- `confirmIfInFlight` 가 1 → debit 1회(idemKey `WDR_CONFIRM:{id}` 검증), true 반환
- 0 → debit 0회, saveStatusHistory 0회, false 반환
- SETTLEMENT_WITHDRAW / 자가전송 분기는 기존 동작 유지(debit 0회) — 회귀 고정

### 5-2. `WebhookProcessingServiceConfirmTest` (open-api)
- onTxConfirmed false → `sendWithdrawalConfirmedNotification` 0회 / true → 1회

### 5-3. `SettlementServiceIdemKeyTest`
- save 가 DuplicateKey 원인 체인 RuntimeException → debit/credit/recordFee 가 null 반환, 예외 없음
- 다른 RuntimeException → 그대로 전파
- idemKey null 이면 빌더에 null(기존 동작)

## 6. 완료 기준
- [ ] `./gradlew compileJava -q` 전 모듈 통과, 위 테스트 통과
- [ ] `IXRepository.modify()` 로 CONFIRMED 전이하는 코드가 `onTxConfirmed` 에 남아 있지 않다
- [ ] idem_key 가 설정되는 곳은 정확히 3곳(WDR_CONFIRM / DEP_CREDIT / DEP_FEE). P2P·MIGRATION·정산 경로는 NULL
- [ ] DDL 문서 v2.22 반영 확인
- [ ] 커밋 메시지 `fix(withdrawal): 확정 멱등화 — 조건부 상태 전이 + 원장 idem_key UNIQUE (DDL v2.22, §93)`. **push 금지** — 오케스트레이터가 MR 생성

## 7. 배포 후 검증 (운영)
```sql
-- 이중 DEBIT 재발 0 이어야 함
SELECT reference_id, COUNT(*) c FROM ledger_entries
 WHERE reference_type='WITHDRAWAL' AND entry_type='DEBIT' AND idem_key IS NOT NULL
 GROUP BY reference_id HAVING c > 1;
-- 새 확정 건은 idem_key 가 채워진다
SELECT id, idem_key FROM ledger_entries WHERE reference_type='WITHDRAWAL' ORDER BY id DESC LIMIT 5;
```
로그: `출금 확정 스킵 — 이미 전이됨(멱등)` 이 재전송 웹훅마다 1회 찍히면 정상.
