# 출금 연타(이중 클릭) 방어 — 짧은 시간창 중복 감지 구현 지침서

- 작성일: 2026-08-10
- 대상: `common`, `core`
- DDL 변경: **없음**
- 선행: `WITHDRAWAL_ORDER_KEY_WEBHOOK_GUIDE.md` Phase 2 (`orderId` 멱등) — **배포 전이지만 구현 완료**

---

## 0. 배경 — 실제 사고가 있었다

`orderId` 멱등은 파트너가 주문키를 **보낼 때만** 동작한다. 안 보내면 아무 방어가 없다.
운영 데이터에서 그 공백으로 생긴 사고를 확인했다.

**2026-05-21 21:07:52, 파트너 34 — 1초 안에 97 USDT 출금 53건.**

| 항목 | 실측 |
|---|---|
| 발생 간격 | `.270` → `.406` — **1초 이내 53건** |
| tx_hash | **53개 전부 상이** → 실제로 53번 온체인 전송, 합 **5,141 USDT** |
| 타임스탬프 순서 | id 229(`.397`)가 228(`.399`)보다 앞 → **동시 요청** |
| 요청 출처 | `API`, `requested_by = NULL` |
| 파트너 34 평소 | **하루 2~5건** (당일 66건 / 5,544 USDT) |

배치 지급이면 초 단위로 흩어진다. 1초에 53건 동시 도착은 **클라이언트 폭주 또는 재시도 루프**다.
⚠️ 지식 repo 에 이 사건 기록이 **없다** — 파트너 확인 필요(§6).

같은 조건(파트너+회원+금액+분)으로 전 기간을 훑으면 충돌은 **이 사건 + 파트너13 2건/16 USDT** 뿐이다.
즉 **정상 업무를 방해할 위험이 거의 없다.**

---

## 1. 방침 — `orderId` 유무로 동작을 나눈다

| 상황 | 의미 | 동작 |
|---|---|---|
| `orderId` **있음** | 파트너가 **"같은 주문"이라고 선언** | 기존 건 반환 (멱등, 구현 완료) |
| `orderId` **없음** + 짧은 시간창 중복 | **같은 주문인지 알 수 없음** | **409 거부 + 안내** |

★ **모르는 상태에서 "같다"고 치면 위험하다.** 조용히 기존 건을 반환하면 파트너는 두 건 다 성공한
줄 알지만 실제로는 하나만 나간다 — 돈이 안 나간 걸 성공으로 오인하는 게 더 나쁘다.
**모르면 막고 사람에게 판단을 넘긴다.** 정말 두 번 보내려면 잠시 뒤 다시 요청하면 된다.

⚠️ `orderId` 가 **있는** 요청에는 이 감지를 적용하지 않는다. 서로 다른 `orderId` 두 건은
파트너가 "다른 주문"이라고 명시한 것이므로 통과시켜야 한다.

---

## 2. 구현

### 2-1. 위치 — ★비관적 잠금 **직후**

`WithdrawalService.requestWithdrawal` (현재 구조):
```
⓪ normalizePartnerOrderKey + 멱등 검사(orderId)        ← 기존
① lockPartnerBalanceForWithdrawal (SELECT ... FOR UPDATE)
   ★★ 여기에 연타 감지를 넣는다
② freeze (사전 가용액 검증)
③ save
④ save-후 원자적 재검증
```

★★ **반드시 ① 잠금 이후여야 한다.** 앞에 두면 동시 요청이 전부 통과한다 —
이번 사고가 정확히 그 케이스(1초에 53건 동시 도착)다. 잠금이 파트너×통화×네트워크 단위로
접수를 직렬화하므로, 그 뒤에서 조회하면 앞 요청이 이미 커밋/저장된 것을 본다.

⚠️ ③ save 보다 앞이므로, 조회는 **직전 요청이 save 를 마친 뒤**여야 의미가 있다.
① 잠금이 그것을 보장한다(앞 트랜잭션이 커밋되어야 잠금이 풀린다).

### 2-2. 감지 키

**파트너 + 회원 + 통화 + 네트워크 + 금액 + 수신주소** — 하나라도 다르면 다른 출금이다.

```sql
SELECT * FROM withdrawals
 WHERE partner_id  = #{partnerId}
   AND currency_id = #{currencyId}
   AND network_id  = #{networkId}
   AND amount      = #{amount}
   AND partner_user_id <=> #{partnerUserId}   -- NULL-safe
   AND to_address      <=> #{toAddress}       -- NULL-safe
   AND created_at >= DATE_SUB(NOW(6), INTERVAL #{windowSeconds} SECOND)
 ORDER BY id DESC LIMIT 1
```

⚠️ **`<=>` (NULL-safe equal) 필수.** `partner_user_id` 는 실제로 NULL 인 행이 있고
(파트너1 사례), `to_address` 도 P2P 전환 출금에서 NULL 이다. `=` 로 쓰면 NULL 끼리
비교가 UNKNOWN 이 되어 **감지가 조용히 무력화**된다.

⚠️ **상태 필터를 두지 않는다.** 10초 안에 이미 종결된 경우는 무시할 만하고,
종결실패 상태 목록을 Java 에 다시 열거하면 `idem_key` DDL 과 이중 정의가 된다
(§WITHDRAWAL_ORDER_KEY_WEBHOOK_GUIDE §2-3 과 같은 이유).

### 2-3. 시간창

- **기본 10초.** 사고는 1초 안에 발생했고, 사람이 같은 회원에게 같은 금액을 의도적으로
  두 번 보내려면 그보다 오래 걸린다.
- `system_settings` 에서 읽고 **코드 기본값 10** 으로 폴백한다(설정 없이도 동작해야 한다).
  키 예: `withdrawal.rapid_duplicate_window_seconds`. 0 이하면 **기능 비활성**으로 해석.

### 2-4. 제외 대상

- `withdrawalType == SETTLEMENT_WITHDRAW` — 시스템이 만드는 정산 인출이라 연타 개념이 없다.
- `orderId`(= `partnerReference`) 가 **있는** 요청 — §1 참조.

### 2-5. 응답

```java
// ErrorCodes 신설
public static final ErrorCode WITHDRAWAL_IN_PROGRESS =
        new ErrorCode("...", "출금이 이미 처리 중입니다. 잠시 후 다시 시도해주세요.");
```
- HTTP **409**.
- 상세 메시지에 **기존 건의 `withdrawalCode` 를 포함**한다 — 파트너가 즉시 대조하고,
  의도한 요청이었다면 근거를 갖고 재시도할 수 있다.
- 예: `"출금이 이미 처리 중입니다. 잠시 후 다시 시도해주세요. (기존 요청: wdr_2608_ab12cd34)"`

### 2-6. 로깅 — 발동 빈도를 봐야 한다

감지 시 **WARN** 로그. 파트너·회원·금액·기존 withdrawalCode·경과 밀리초를 남긴다.
이 기능이 실제로 얼마나 발동하는지가 곧 "이중 클릭이 얼마나 일어나는가"의 답이다.
운영 관찰 후 시간창을 조정한다.

---

## 3. 완료 기준

1. `./gradlew :common:compileJava :core:compileJava :open-api:compileJava :partner-api:compileJava :admin-api:compileJava :scheduler:compileJava` 성공.
2. 감지 로직이 **`lockPartnerBalanceForWithdrawal` 호출 이후**에 위치할 것.
3. `partner_user_id` / `to_address` 비교에 **`<=>`** 를 쓸 것 (`=` 사용 시 불합격).
4. `orderId` 가 있는 요청에는 감지가 **적용되지 않을** 것.
5. `SETTLEMENT_WITHDRAW` 는 **제외**될 것.
6. 시간창이 `system_settings` 에서 읽히고, 설정이 없으면 **10초**로 동작할 것. 0 이하면 비활성.
7. 409 응답 메시지에 기존 `withdrawalCode` 가 포함될 것.
8. 감지 시 WARN 로그가 남을 것.
9. 기존 멱등(`orderId`)·잔액 가드·freeze 동작 **불변**.
10. CLAUDE.md 규칙 준수 — XML 매퍼 금지, `<script>` 부등호 이스케이프, DTO JavaDoc.

## 4. 검증 시나리오

| # | 조건 | 기대 |
|---|---|---|
| 1 | `orderId` 없이 동일 조건 2회 연속(10초 내) | 2번째 **409** + 기존 withdrawalCode |
| 2 | 10초 경과 후 동일 조건 재요청 | **통과** |
| 3 | 금액만 다름 | **통과** |
| 4 | 수신주소만 다름 | **통과** |
| 5 | `partner_user_id` 가 둘 다 NULL, 나머지 동일 | **409** (NULL-safe 확인) |
| 6 | `orderId` 를 서로 다르게 지정, 나머지 동일 | **둘 다 통과** |
| 7 | `orderId` 동일 | 기존 멱등 — 기존 건 반환(409 아님) |
| 8 | `SETTLEMENT_WITHDRAW` | 감지 미적용 |

## 5. 배포 후 관찰

```sql
-- 이 기능이 없었다면 발생했을 연타 (배포 전 소급 확인용)
SELECT partner_id, partner_user_id, amount, to_address,
       COUNT(*) n, MIN(created_at) fr, MAX(created_at) t
  FROM withdrawals
 GROUP BY partner_id, partner_user_id, amount, to_address,
          FLOOR(UNIX_TIMESTAMP(created_at)/10)
HAVING COUNT(*) > 1 ORDER BY n DESC;
```
WARN 로그 발동 건수를 주 단위로 확인하고, 오탐이 보이면 시간창을 줄인다.

## 6. 별건 — 2026-05-21 파트너 34 확인 필요

5,141 USDT 가 1초에 53건으로 나갔는데 **어디에도 기록이 없다.**
파트너에게 의도한 것인지 확인하고 결과를 지식 repo 에 남길 것.
- 사고였다면 → 이 기능의 근거가 된다.
- 의도였다면 → 시간창을 더 짧게 잡거나 해당 파트너를 예외로 둬야 한다.

## 7. 범위 밖

- `orderId` 필수화 (파트너별 플래그) — 별건. 이 기능은 그 전까지의 안전망이다.
- P2P 출금 경로 연동 — 아직 엮지 않는다(오너 지시).
- `feeAmount` 등 webhook 페이로드 추가 항목.
