# 출금 — 파트너 주문키(orderId) + Webhook 보강 구현 지침서

- 작성일: 2026-08-10
- 대상: `common`, `core`, `partner-api`, `open-api`
- DDL 변경: **없음** (컬럼·인덱스 이미 존재)
- 배경: `v2-docs/API_INTEGRATION_SCOPE_WITHDRAWAL_P2P.md` 건 1
- 오너 결정: 참멱등 / 종결·비대칭 이벤트만 보완 / 페이로드는 `orderId`+`metadata`+`fromAddress`

## ★단계 분리 (2026-08-10)

webhook 쪽은 주문키 수집과 **의존이 없어** 먼저 나갈 수 있다. 두 단계로 쪼갠다.

| Phase | 범위 | 문서 절 | DDL |
|---|---|---|---|
| **1. Webhook** ← **먼저** | `event` 비대칭 해소 / `fromAddress` 실값 / 발송 시점 3건 추가 | §4-1, §4-2(fromAddress만), §4-3, §4-4 | 없음 |
| 2. 주문키 | `orderId`·`metadata` 수집·저장·멱등·조회, 페이로드 반영 | §1, §2, §3, §4-2(orderId·metadata) | `UNIQUE` 추가 |

⚠️ Phase 1 에서는 **`orderId`/`metadata` 를 건드리지 않는다.** 값을 넣는 경로가 아직 없어
페이로드에 넣어봐야 계속 null 이고, 괜히 키만 흔들린다.

---

## 0. 현재 상태 — 끊긴 곳은 "쓰기" 하나뿐

배관은 이미 깔려 있다. **읽기 경로까지 전부 존재하고 값을 넣는 코드만 없다.**

| 계층 | 상태 |
|---|---|
| DB 컬럼 `partner_reference` · `partner_metadata` | **있음** |
| 인덱스 `idx_partner_ref (partner_id, partner_reference)` | **있음** |
| `Withdrawal` 엔티티 필드 (L53, L56) | **있음** |
| 목록/상세 SELECT (`PartnerWithdrawalMapper:27,63`) | **있음** |
| 응답 DTO (`WithdrawalResponse:59`, `WithdrawalDetailResponse:71`) | **있음** |
| Webhook `orderId` 출력 (`WebhookPayloadBuilder`) | **있음** |
| `DuplicateRequestException` ("partner_reference 기준 멱등성 위반") | **클래스까지 있음** |
| **요청 DTO 수신 필드** | **없음** ← |
| **엔티티 값 세팅** | **없음** ← |
| **repository 조회 메서드** | **없음** ← |

운영 실측: 출금 **614건 전부 `partner_reference`/`partner_metadata` NULL**. 한 번도 채워진 적 없음.
→ **출금 webhook 의 `orderId` 는 구조적으로 항상 null 이라 직렬화에서 키째 빠진다.**

---

## 1. 주문키 수집

### 1-1. 요청 DTO 3곳에 필드 추가

| DTO | 모듈 | 추가 |
|---|---|---|
| `CreateWithdrawalRequest` | partner-api | `orderId`, `metadata` |
| `UserWithdrawalRequest` | open-api `/api/v1` | `orderId`, `metadata` |
| `WithdrawalRequest` | open-api widget | `orderId`, `metadata` |

```java
/** 파트너 주문 ID (파트너 사이트 생성) — withdrawals.partner_reference 에 저장.
 *  webhook orderId 로 반환되며, 동일 키 재요청은 멱등 처리된다. */
@Size(max = 255)
private String orderId;

/** 파트너 자유 메타데이터 — Cryptoments 는 해석하지 않고 webhook 으로 그대로 돌려준다(pass-through). */
private String metadata;
```

- 네이밍은 **입금과 통일** — 입금도 `orderId` 로 받는다(`DepositReservationRequest.orderId`).
- 저장 필드명은 `partnerReference` / `partnerMetadata` (컬럼명 유지).
- 공백 문자열은 **null 로 정규화**해 저장한다(`isBlank()` → null). 빈 문자열이 들어가면
  멱등 판정이 "빈 키끼리 충돌"하는 사고가 난다.
- `metadata` 는 컬럼이 JSON 이므로 **유효한 JSON 인지 검증**하고, 아니면 400 으로 거부한다.
  (문자열을 그대로 넣으면 MySQL JSON 컬럼이 거부해 500 이 된다.)

### 1-2. 세팅 지점

`Withdrawal.builder()` 호출부에 `.partnerReference(...)` / `.partnerMetadata(...)` 추가:

- `partner-api/.../service/PartnerWithdrawalService.java` (L~212 빌더)
- `open-api/.../controller/v1/UserController.java` (L~115)
- `open-api/.../controller/widget/WithdrawalController.java` (L~73)

⚠️ `PartnerSettlementService` 의 정산출금(`SETTLEMENT_WITHDRAW`)은 **대상 아님** — 파트너가
요청하는 게 아니라 시스템이 만드는 것이다. 그대로 둔다.

---

## 2. ★멱등성 — 이번 작업의 본체

지금은 네트워크 타임아웃 후 파트너가 재요청하면 **출금이 두 번 나간다.**

### 2-1. 방침: 참멱등 + 종결실패 시 키 재사용 허용 (오너 결정 2026-08-10)

```
같은 (partner_id, orderId) 로 재요청
  ├─ 기존 건이 진행/성공 중  → 기존 건을 그대로 200 반환 (새로 만들지 않음)
  ├─ 기존 건이 종결실패      → 새 출금을 만든다 (키 재사용 허용)
  └─ 진행 중인데 요청 내용이 다름 → 409 DuplicateRequestException
```

**종결실패 = `FAILED` · `CANCELLED` · `REJECTED` · `EXHAUSTED`.**
⚠️ `STALE` 은 **포함하지 않는다** — 1시간+ 미확정일 뿐 아직 확정될 수 있다.

"요청 내용 동일" 판정 — **`amount` + `toAddress` + `currencyId` + `networkId`** 4개가 모두 같을 것.
(`partnerUserId` 는 비교에서 제외.)

### 2-2. ★DDL — 적용 완료 (2026-08-10, 오케스트레이터 직접 적용)

```sql
ALTER TABLE withdrawals
  ADD COLUMN idem_key VARCHAR(255)
    GENERATED ALWAYS AS (
      CASE WHEN status IN ('FAILED','CANCELLED','REJECTED','EXHAUSTED') THEN NULL
           ELSE partner_reference END
    ) STORED
    COMMENT '멱등키 — 종결실패 상태면 NULL 이 되어 같은 partner_reference 재사용을 허용한다',
  ADD UNIQUE KEY uk_partner_idem (partner_id, idem_key);
```

**운영 DB 실증 완료** (임시 테이블로 재현 후 삭제):

| 검증 | 결과 |
|---|---|
| `orderId` 미지정(NULL) 다중 INSERT | **허용** (MySQL UNIQUE 는 NULL 을 유니크 판정에서 제외) |
| 활성 상태에서 같은 키 중복 | **차단** `ERROR 1062` |
| `EXHAUSTED` 로 전이 | `idem_key` 가 **자동으로 NULL** 로 재계산 |
| 그 뒤 같은 키로 재요청 | **성공** |
| 실패건을 `retry()`(→APPROVED) | **`ERROR 1062`** ← §2-4 |

- `idx_partner_ref (partner_id, partner_reference)` 는 **유지**한다 — 검색·조회용으로 여전히 쓰인다
  (UNIQUE 는 `idem_key` 기준이라 대체 관계가 아니다).

### 2-3. 구현 — ★조회는 `idem_key` 로 한다

```java
// Withdrawal 엔티티 — GENERATED 컬럼이므로 읽기 전용
@XColumn(value = "idem_key", insert = false, update = false)
private String idemKey;

// WithdrawalRepository — 현재 조회 메서드 자체가 없다
Withdrawal findByPartnerIdAndIdemKey(Long partnerId, String idemKey);
```

★★ **멱등 조회는 `partner_reference` 가 아니라 `idem_key` 로 한다.** 요청받은 `orderId` 를 그대로
`idemKey` 자리에 넘기면, 종결실패 건은 `idem_key` 가 NULL 이라 **자동으로 조회에서 빠진다.**
- `partner_reference` 로 조회하면 종결실패 건까지 잡혀 **"재사용 허용" 정책과 어긋난다.**
  그러면 앱과 DB 가 서로 다른 규칙으로 동작하게 된다.
- 종결실패 상태 목록을 **Java 에 다시 하드코딩하지 말 것.** DDL 의 `CASE` 가 유일한 정의다.
  상태 집합을 바꿀 일이 생기면 DDL 만 고치면 되도록 둔다.

- `orderId` 가 **null 이면 멱등 검사를 건너뛴다**(주문키는 선택 사항 — 기존 동작 유지).
- 검사 위치는 **출금 생성 서비스의 가장 앞** — 잔액 검증·정책 검증보다 먼저.
  뒤에 두면 중복 요청이 잔액 부족으로 먼저 실패해 엉뚱한 에러가 나간다.
- 동시 경합에서 진 쪽은 `DuplicateKeyException` 을 받는다 →
  **잡아서 재조회 후 기존 건 반환**으로 수렴시킨다(경합에서도 참멱등이어야 한다).

### 2-4. ⚠️ `retry()` 부작용 — 반드시 처리할 것

`WithdrawalService.retry()` 는 `FAILED`/`EXHAUSTED` → `APPROVED` 로 되돌린다.
그런데 그 사이 파트너가 **같은 주문키로 새 출금을 만들었다면**, retry 시점에
옛 건의 `idem_key` 가 다시 값으로 되살아나 **UNIQUE 충돌(1062)** 이 난다. (실증 완료)

- **동작 자체는 옳다** — 같은 주문에 대해 새 출금이 이미 있는데 옛 건을 되살리면 이중 출금이다.
- 문제는 **날 DB 예외가 그대로 500 으로 나간다**는 것이다.
- → `retry()` 에서 `DuplicateKeyException` 을 잡아
  **"동일 주문키로 새 출금이 이미 존재합니다"** 취지의 명확한 도메인 예외로 변환할 것.

---

## 3. 조회

- 출금 목록 검색 키워드에 `partner_reference` 추가
  (현재 `withdrawal_code`, `tx_hash` 뿐 — `PartnerWithdrawalMapper`)
- ⚠️ LIKE 검색이므로 `<script>` 안에서 부등호 이스케이프 규칙 준수.

---

## 4. Webhook 보강

### 4-1. ★`event` 키 비대칭 해소 — 사용자 발견 (2026-08-10)

현재 같은 연동 안에서 이벤트마다 키가 다르다.

| 발송 주체 | 이벤트 | `legacyKeys` | `event`·`withdrawalId` |
|---|---|---|---|
| `WithdrawalService` | REQUESTED·APPROVED·REJECTED·CANCELLED·EXHAUSTED | `true` | **있음** |
| `WebhookProcessingService:492` | **CONFIRMED·FAILED** | `false` (1-인자 오버로드) | **없음** |

2026-08-07 페이로드 통일(`1faad2c`) 때 "그 키를 받고 있던 경로에만" 하위호환을 유지한 결과다.
의도는 맞았으나 **파트너 입장에서 설명이 안 되는 상태**가 됐다.

**조치**: `WebhookProcessingService:492` 의 1-인자 호출을 3-인자 + `legacyKeys=true` 로 바꾼다.
- 순수 추가라 안전하다 — `eventType` 을 쓰던 파트너는 무영향, `event` 를 쓰던 파트너는 이제 전 이벤트에서 받는다.
- ⚠️ **5개에서 `event` 를 빼는 방향은 금지.** 파싱 중인 파트너가 깨진다.
- eventType 결정 로직(FAILED → `WITHDRAWAL_FAILED`, 그 외 `WITHDRAWAL_CONFIRMED`)은 그대로 유지.

### 4-2. 페이로드 필드 (오너 선택분만)

| 필드 | 현재 | 조치 |
|---|---|---|
| `orderId` | 항상 null → **키째 누락** | §1 로 해결. 값이 생기면 **키가 새로 등장**한다 |
| `metadata` | 없음 | `partnerMetadata` 를 그대로 실어 반환 |
| `fromAddress` | **하드코딩 `null`** | 파트너 MASTER 지갑 주소로 채움 |

`fromAddress` 소스 — 출금 전송 원천은 MASTER 다(§27/§44 확인).
```java
walletAddressRepository.findByPartnerIdAndWalletType(withdrawal.getPartnerId(), WalletType.MASTER)
```
`WithdrawalService:974,982` 가 이미 쓰는 것과 동일한 조회를 사용할 것.
⚠️ MASTER 가 없는 파트너가 실재한다(§42 마이그레이션 누락 건). **null 이면 기존처럼 null 로 두고
예외를 던지지 말 것** — webhook 발송이 실패하면 안 된다.

**이번에 하지 않는 것** (오너 미선택): `withdrawalCode`, `errorMessage`, `feeAmount` 실값, `blockNumber`.
`feeAmount` 는 소스 확정(정책 수수료 vs 실제 가스비)이 선행돼야 하므로 별건으로 남긴다.

### 4-3. 하위호환 3원칙 (지식 repo §41)

1. **기존 키 제거 금지.**
2. `txHash` null → `""` 는 **페이로드와 서명 데이터에 동시 적용**(한쪽만 바꾸면 파트너 서명 검증이 깨진다).
3. 서명식 `partnerId|txHash|amount|timestamp` **불변**.

⚠️ 이번 추가 필드는 **전부 서명 대상이 아니다.** 서명식을 건드리지 말 것.

### 4-4. 발송 시점 — 종결·비대칭만 보완 (오너 결정)

| 전이 | 현재 | 조치 | 사유 |
|---|---|---|---|
| `COMPLETED` (P2P 원화 경로 종료) | 없음 (`WithdrawalService:796`) | **추가** `WITHDRAWAL_COMPLETED` | 종결 상태인데 알림이 없어 파트너가 건을 영원히 못 닫는다 |
| 관리자 `cancel()` | 없음 (`:345-370`) | **추가** `WITHDRAWAL_CANCELLED` | 파트너 취소는 나가는데 관리자 취소는 안 나가는 비대칭 |
| `P2P_PENDING` 전환 | 없음 (`:691`) | **추가** `WITHDRAWAL_P2P_PENDING` | 상태가 바뀌었는데 파트너는 모른다 |

- 세 건 모두 `legacyKeys=true` 로 발송해 §4-1 과 일관되게 한다.
- 관리자 취소는 `cancelByPartner()`(`:407`)의 발송 코드를 참고해 동일 형태로.
- **`PROCESSING`/`BROADCASTING` 은 이번 범위 밖** — Node relayer 가 DB 를 직접 갱신해
  Spring 에 훅 지점이 없다. 추가하려면 relayer→Spring 통지 경로 신설이 필요하다.
- `retry()`·`STALE`·정산출금 생성도 범위 밖.

---

## 5. 완료 기준

1. `./gradlew :common:compileJava :core:compileJava :partner-api:compileJava :open-api:compileJava` 성공.
2. 요청 DTO 3개가 `orderId`/`metadata` 를 받고, 빈 문자열은 null 로 정규화될 것.
3. `metadata` 가 유효 JSON 이 아니면 400 으로 거부될 것.
4. 동일 `(partner_id, orderId)` + 동일 내용 재요청 → **새 출금이 생기지 않고 기존 건이 반환**될 것.
5. 동일 키 + 다른 내용 → 409 `DuplicateRequestException`.
6. `orderId` 가 null 인 요청은 **멱등 검사 없이 기존대로 동작**할 것(회귀 방지).
7. `DuplicateKeyException` 발생 시 재조회로 기존 건 반환에 수렴할 것(경합).
8. CONFIRMED·FAILED webhook 에 `event`·`withdrawalId` 가 **포함**될 것.
9. 기존 5개 이벤트의 `event`·`withdrawalId` 가 **여전히 존재**할 것.
10. 서명식·기존 키 불변. `fromAddress` 는 MASTER 없으면 null (예외 금지).
11. `COMPLETED`·관리자 취소·`P2P_PENDING` 에서 webhook 이 발송될 것.
12. 출금 목록 검색이 `partner_reference` 로도 될 것.
13. CLAUDE.md 규칙 준수 — XML 매퍼 금지, `<script>` 부등호 이스케이프, DTO JavaDoc 필수.

## 6. 배포 후 검증

```sql
-- 주문키가 실제로 저장되는지
SELECT id, withdrawal_code, partner_reference, partner_metadata, created_at
  FROM withdrawals WHERE partner_reference IS NOT NULL ORDER BY id DESC LIMIT 10;

-- 멱등 위반이 없는지 (0건이어야 정상)
SELECT partner_id, partner_reference, COUNT(*) FROM withdrawals
 WHERE partner_reference IS NOT NULL GROUP BY 1,2 HAVING COUNT(*) > 1;

-- webhook 에 event / orderId 가 실리는지
SELECT id, JSON_EXTRACT(request_payload,'$.eventType') et,
       JSON_EXTRACT(request_payload,'$.event') ev,
       JSON_EXTRACT(request_payload,'$.orderId') oid,
       JSON_EXTRACT(request_payload,'$.fromAddress') fa
  FROM webhook_delivery_logs
 WHERE callback_type='WITHDRAWAL' AND created_at >= CURDATE() ORDER BY id DESC LIMIT 10;
```

## 7. 파트너 안내 필요 (배포 후)

페이로드에 **키가 새로 등장**한다 — `orderId`, `metadata`, `fromAddress`, 그리고
CONFIRMED·FAILED 의 `event`·`withdrawalId`. 전부 순수 추가라 기존 파싱은 깨지지 않지만,
**엄격한 스키마 검증을 하는 파트너가 있으면 사전 공지가 필요하다.**

## 8. 범위 밖

- `feeAmount` 실값 — 소스 확정(정책 수수료 vs `gas_cost_records` 실제 가스비) 선행 필요
- `withdrawalCode`·`errorMessage`·`blockNumber` 페이로드 추가
- `PROCESSING`/`BROADCASTING` webhook (relayer→Spring 통지 경로 신설 필요)
- `/api/v1` 출금 조회·취소 엔드포인트 신설
- P2P 관련 전부 (별건 — `API_INTEGRATION_SCOPE_WITHDRAWAL_P2P.md` 건 2)
