# P2P 매칭 개선 지침서 (v1.0) — 네트워크 무관 매칭 + 재매칭 + 만료 + 링크 재진입

> **작성일**: 2026-06-11
> **배경**: 운영 검증 중 발견 — ① 대기 출금(BSC)과 입금(TRON)이 네트워크 불일치로 미매칭 → **설계 변경 확정: 구매자는 네트워크를 지정하지 않는다** ② 재매칭 메커니즘 부재 ③ P2P 출금 주문 48시간 만료 미구현 ④ 매칭 중 이탈 시 원본 링크로 재진입 불가(위젯 측은 1차 수정 배포됨)
> **구현**: IntelliJ (§1~§4). DDL은 Cowork가 운영 적용 완료. 위젯/파트너 UI 후속은 Cowork.

---

## 1. 네트워크 무관 매칭 (설계 변경 — 최우선)

### 1.1 확정 모델

- **구매자(입금)는 "USDT"를 살 뿐 체인을 지정하지 않는다.** 네트워크는 판매자(출금) USDT의 위치 속성.
- 매칭: 입금 주문은 **전체 네트워크의 출금 풀**에서 후보 선택. **1:N 분할 시 서로 다른 네트워크 혼합 허용**.
- 정산: **매칭별**로 해당 출금 주문의 네트워크에서 MASTER(A)→MASTER(B) 온체인 전송 (기존 매칭 단위 정산 구조 그대로).
- 구매자측 원장(CREDIT/FEE): **매칭된 출금 주문의 체인**으로 기록 (현 정산 로직이 settlement.networkId 기준이므로 유지).
- `p2p_deposit_orders.network_id` / `p2p_deposit_links.network_id`: **매칭 조건에서 제거, NULL 허용으로 전환됨** (운영 DDL 적용 완료). 신규 주문/링크는 NULL 저장.

### 1.2 코드 변경

| 위치 | 변경 |
|------|------|
| `P2pMatchingMapper.findMatchableWithdrawOrdersForUpdate` | `AND network_id = #{networkId}` 제거, 파라미터 제거 |
| `P2pMatchingMapper.findExactMatchForUpdate` | 동일 |
| `P2pMatchingMapper.sumAvailableKrwByNetwork` | → `sumAvailableKrw()` — network 조건/파라미터 제거 (라우팅 커버리지는 전체 풀 기준) |
| `P2pMatchingService.tryMatchDeposit` | 네트워크 인자 사용 제거. 정산 생성 시 `network_id = 매칭된 출금 주문(wo).getNetworkId()` 명시 확인 (입금 주문 기준이면 수정) |
| `P2pDepositService.createAndMatch` | `networkId` 파라미터 제거(또는 무시), 주문에 network_id 저장하지 않음(NULL) |
| `P2pDepositLinkService.createLink` | networkId 입력 무시/제거, 링크 network_id NULL |
| Widget/Partner API (route/match/링크 생성 EP) | `networkId` 요청 필드는 **하위호환 위해 받되 무시** (위젯 구버전 캐시 대응). 응답/검증에서 제거 |
| `P2pSettlementService` | `getCurrencyIdForNetwork(settlement.getNetworkId())` — 이미 정산 기준이므로 변경 없음 (확인만) |

⚠️ `p2p_settlements.network_id`는 **반드시 출금 주문의 체인** — B파트너 MASTER 지갑도 그 체인 것을 사용 (`to_wallet_address_id` 선택 로직 확인).
⚠️ MyBatis `<script>` 내 `<` 계열 연산자 금지 (CLAUDE.md 규칙 — 오늘 장애 원인).

### 1.3 검증 케이스

1. TRON 출금 + 입금(네트워크 미지정) → 매칭 성립.
2. BSC 출금 13 USDT + TRON 출금 보유 상태에서 30,000원 입금 → 혼합 분할 매칭(2건), 각 매칭 정산이 각자 체인에서 수행.
3. 라우팅 커버리지: 전체 풀 합 기준으로 P2P/TORQ 판정.

---

## 2. 재매칭 — 대기 입금 주문 소진

**현상**: 매칭은 입금 주문 생성 시 1회만 시도(`tryMatchDeposit`). 대기 중(MATCHING) 입금은 이후 출금이 등록돼도 만료까지 미매칭.

**구현**:

- `P2pWithdrawService`에서 출금 주문이 **매칭 가능 상태가 되는 시점**(신규 등록 PENDING, 매칭 취소/만료로 잔여 복구되어 PENDING/PARTIALLY_MATCHED 복귀)에 대기 입금 재매칭 트리거:
  - 대상: `p2p_deposit_orders` `status=MATCHING(/PENDING)` + 미만료, `created_at ASC` (선착순)
  - 각 대상에 `matchingService.tryMatchDeposit(dpo)` — 출금 잔여 소진 시 중단
- 동시성: 기존 매칭 트랜잭션의 FOR UPDATE 패턴 준수 — 입금 주문도 잠금 후 잔여 재확인(이중 매칭 방지)
- 1.1 적용 후이므로 네트워크 필터 없이 전체 대기 입금 대상

**검증**: 입금 먼저 생성(매칭 중 대기) → 출금 등록 → 수 초 내 매칭 성립 + 위젯 폴링이 입금 안내로 전환.

### 2-B. 🔥 핫픽스 — 취소-재매칭 루프 (운영 발생 2026-06-12 00:21)

**현상**: 구매자가 주문 전체 취소 → `cancelMatch()`가 매칭 취소 + 잔여 복구 후 같은 트랜잭션에서
`rematchWaitingDeposits()` 호출 → **취소 중인 입금 주문이 아직 MATCHING이라 후보로 다시 잡혀
0.2초 만에 동일 입금↔동일 출금 재매칭**(pm_d3a5 CANCELLED → pm_4215 CREATED). 구매자 취소가 무효화되고
주문이 MATCHING으로 부활, 출금은 FULLY_MATCHED 잔류.

**수정** (구매자 주문 취소 경로 — P2pDepositService/Widget cancel 흐름):

1. **입금 주문 상태를 먼저 CANCELLED로 마킹**(+cancelled_at) → 그 다음 개별 매칭 취소/잔여 복구 수행.
   재매칭 후보 쿼리가 `status IN (PENDING, MATCHING)`만 보므로 자기 자신이 자연히 제외된다.
2. 방어선: `rematchWaitingDeposits(Long excludeDepositOrderId)` 오버로드 추가 — 취소 트리거에서는
   취소 대상 입금 ID를 제외하고 호출 (1번이 깨져도 이중 안전).
3. 개별 매칭만 취소(주문은 유지)하는 경로 — 예: 매칭 1건 취소 후 잔여 재매칭 — 는 의도된 동작이므로 제외 불필요.

**운영 데이터 정정**: 수정 배포 후 해당 주문(pdo_7ed26e2504ea)을 구매자 위젯/관리자에서 다시 전체 취소
→ 유령 매칭(pm_421524064897) 취소 + 출금(pwo_31d9d374077f) 잔여 복구 + 주문 CANCELLED 정상 종료 확인.
(배포 전 취소 재시도는 같은 루프 재발 — 금지)

**검증**: 전체 취소 → 매칭 전부 CANCELLED + 입금 CANCELLED + 출금 PENDING(잔여 복구) + **신규 매칭 미생성**.
이후 새 입금 생성 시에만 재매칭 동작.

---

## 3. ~~P2P 출금 주문 48시간 만료 잡~~ → **폐기 (정책 변경 2026-06-11)**

> ⚠️ **자동 만료 없음으로 정책 확정** — 출금 대기의 종료는 회원/파트너의 취소 결정으로만 이루어진다.
> 아키텍처 문서의 "PENDING 48시간 만료" 명세는 폐기. CLAUDE.md 반영 완료.
>
> **롤백 작업 (구현 필요)**: §3으로 배포된 `P2pWithdrawExpiryJob` **제거** (잡 + SchedulerController 트리거 EP.
> `TransactionType.P2P_WITHDRAW_EXPIRED`는 무사용으로 남겨도 무방).
> 운영 데이터는 Cowork가 복원 완료 (`pwo_31d9d374077f` EXPIRED→PENDING — 자금 미변동이라 상태 복원만으로 정합).

## 3-B. 출금 주문 취소 시 자금 원복 (신규 — 실버그)

**현상**: `P2pWithdrawService.cancelOrder()`가 상태만 CANCELLED로 바꾸고 **원본 withdrawal의 freeze를 해제하지 않음**.
P2P 전환 출금(withdrawal_id 보유)을 취소하면 `settlement_balances.frozen_amount`에 USDT가 영구 동결로 잔류
(운영에서 확인: withdrawal #298 / 13 USDT 동결). 만료 잡에도 같은 누락이 있었음 — 잡은 폐기되지만 취소 경로는 수정 필수.

**구현**: `cancelOrder()`에서 (매칭 0 기준 — PENDING 취소):

```java
if (order.getWithdrawalId() != null) {
    // 전액(미매칭) freeze 해제 — cancelRemaining()과 동일 수단
    withdrawalService.completeP2pConvertedWithdrawal(
            order.getWithdrawalId(), computeRemainder(order).usdt, "P2P_CANCELLED", reason);
}
```

- `PARTIALLY_MATCHED` 취소는 기존 TODO(Phase 2) 유지 — 진행 매칭 정리와 함께 처리
- 위젯/파트너 직접 등록 주문(withdrawal_id NULL)은 Cryptoments 측 동결이 없으므로 원복 대상 없음 (파트너 webhook 알림으로 충분)
- 검증: PENDING 취소 → `frozen_amount` 감소 + withdrawal 상태 종료 확인

---

## 4. 링크 재진입 복구 (백엔드 — 위젯 1차 수정은 배포됨)

**현상**: 위젯이 URL `p2pOrderCode`로 복구하도록 수정됐으나(배포 완료), **쿼리 없는 원본 링크 URL** 재클릭 시 여전히 "이미 사용된 링크".

**구현**:

- `p2p_deposit_links.deposit_order_id BIGINT NULL` — **운영 DDL 적용 완료**. `useLink()`에서 생성 주문 ID 기록.
- 링크 info EP(`GET /widgets/p2p/links/{code}`): status=USED이고 연결 주문이 **진행 중**(COMPLETED/EXPIRED/CANCELLED 아님)이면 응답에 추가:
  - `resumeOrderCode` (주문 코드)
  - `accessToken` (현재 isUsable=true일 때만 발급한다면 → 이 케이스에도 동일 사용자 컨텍스트로 발급)
- 위젯 후속(Cowork): info 응답에 `resumeOrderCode` 있으면 USED 실패 화면 대신 토큰 저장 후 해당 주문으로 복구.

---

## 4-B. 구매자 eKYC 검증 누락 (운영 확인 2026-06-12 — 미인증 사용자 매칭 통과)

**현상**: 아키텍처 명세 "입금자: eKYC 인증 후 매칭 요청"이 **P2P 경로에 미구현**.
TORQ 위젯 거래 생성(`TorqWidgetController.createTrade`)에는 `aximPayClient.getEkycStatus` → 미인증 시
`EKYC_NOT_VERIFIED` 차단이 있으나, **P2P 매칭 생성 2곳에는 검증이 전혀 없음** — Axim 미연결 사용자(iu0001)가
매칭까지 진행됨.

**구현** (TORQ 패턴 그대로 — 매칭 생성 진입점 2곳):

| 위치 | 추가 |
|------|------|
| `P2pWidgetController` 매칭 생성 EP (`/match`) | 주문 생성 전 `getEkycStatus(partnerId, partnerUserId)` → `verified != true`면 `EKYC_NOT_VERIFIED` (TORQ와 동일 에러코드) |
| `P2pDepositLinkWidgetController.useLink` (또는 `P2pDepositLinkService.useLink`) | 동일 검증 — 링크 경로도 차단 |

- TORQ처럼 `getEkycInfo`로 입금자 실명을 확보해 주문에 저장하는 것 권장 — 출금자 계좌 스크래핑의
  입금자명 대조/분쟁 판정에 필요 (현 P2P 이체 확인 로직이 무엇으로 대조하는지 확인 후 동일 소스 사용).
- 라우팅 판정 EP(`/route`)는 조회뿐이므로 검증 불요 (선택).
- 위젯 UX 후속(Cowork): 미인증 에러 수신 시 TORQ 위젯과 동일한 Axim 연결 유도 화면으로 안내.
  1차는 서버 차단 + 실패 사유 표시(이미 동작)로 충분.

**검증**: 미인증 partnerUserId로 링크/매칭 시도 → EKYC_NOT_VERIFIED 차단. 인증 사용자는 정상 매칭.

## 6. 스크래핑 입금 매핑 강화 (확정 2026-06-12)

**현황 (AS-IS)**: `P2pScrapingVerifyJob`의 매핑 키는 사실상 **금액 하나** — ① BANK_PENDING만 검사
(이체 후 미신고 이탈 시 영영 미감지 → 기한 만료 → 실자금만 이동) ② 입금자명 대조 없음
(위젯 안내 "다른 명의 송금은 자동 분쟁"이 미구현 — 누가 보내든 금액만 맞으면 확정)
③ 입금 건 중복 소비 방지 없음(동일 계좌·동일 금액 매칭 2건이 입금 1건으로 모두 확정 → 이중 정산)
④ 조회창(매칭 10분 전~) 내 우연한 동일 금액 입금 오확정 가능.

### 6.1 확정 매핑 조건 (TO-BE)

| # | 조건 | 구분 |
|---|------|------|
| 1 | 검사 대상: **BANK_PENDING + CREATED** (CREATED는 15~30초 주기로 완화 가능 — 신고는 "우선 확인" 힌트로 격하) | 대상 확대 |
| 2 | 입금액 == 매칭 KRW (완전 일치) | hard |
| 3 | **입금자명(desc1) == 주문 저장 eKYC 실명** — 공백 제거 후 비교, 은행이 이름을 절단 송신하는 경우 대비 prefix 일치(최소 2자) 허용 | hard |
| 4 | **ref 미사용** — 동일 출금 계좌(bank_account) 스코프에서 이미 다른 매칭에 기록된 `bank_transfer_ref`(날짜_시간_금액)는 재사용 불가 (매퍼 존재 검사. ⚠️ ref가 글로벌 유니크는 아님 — 계좌 스코프 필수) | hard |
| 5 | 입금은행 soft check: desc2/desc4에 eKYC `financeCode` 은행명 문자열 포함 여부 — **불일치여도 차단하지 않음**, 로그·분쟁 참고 정보로만 (은행별 desc 포맷 비표준. 추후 `banks` 카탈로그에 패턴 축적) | soft |

- **자동 분쟁**: 금액 일치(2,4 통과)인데 **이름 불일치(3 실패)** 입금 건 발견 시 → 해당 매칭을 DISPUTED 전이
  + `dispute_reason="입금자명 불일치(자동)"` + 관리자/파트너 알림. 위젯 안내문과 동작 일치화.
- 만료 직전 안전망: `P2pMatchExpiryJob.expireMatch` 직전 스크래핑 1회 최종 확인 — 확인되면 만료 대신 확정 (1번 적용 시 자연 커버되나 이중 안전).

### 6.2 선행 — 구매자 실명/은행 스냅샷 (§4-B 연계)

- **DDL 적용 완료 (Cowork)**: `p2p_deposit_orders.buyer_name VARCHAR(100) NULL`, `buyer_bank_code VARCHAR(20) NULL`
- `P2pEkycGuard.verify()`를 `verifyAndGetInfo()`로 확장 — 검증 통과 시 `getEkycInfo()`의
  `name`/`financeCode` 반환 → 매칭 EP·useLink에서 `createAndMatch`로 전달, 주문에 스냅샷 저장
- 기존 주문(buyer_name NULL)은 이름 대조 생략(금액+ref만) — 하위호환

### 6.3 검증

1. 이체 후 미신고 이탈(CREATED 유지) → 스크래핑이 자동 확정 → 정산 진행.
2. 타인 명의 입금(금액 일치·이름 불일치) → 자동 DISPUTED + 알림, 확정 안 됨.
3. 동일 계좌·동일 금액 매칭 2건 + 입금 1건 → 1건만 확정(ref 소비), 나머지는 미확인 유지.
4. buyer_name NULL인 구주문 → 기존(금액) 방식으로 확정 — 회귀 없음.

## 7. 전액 매칭 정책 + 이체 안내 정보 확장 (확정 2026-06-12)

**배경**: 부분 매칭(주문 20,000 중 19,461만 매칭, 잔여 대기) 상태로 이체 안내가 나가 구매자가
"총 결제 ≠ 이체 금액" 혼란. **정책 확정: 입금 주문은 전액 커버 가능할 때만 매칭 성립** — 이체 안내
합계는 항상 총 결제 금액과 일치한다.

### 7.1 매칭 엔진 — 전액 성립 (백엔드)

- `tryMatchDeposit`: ① exact(잔여==주문) 우선 유지 ② 분할은 **후보(최대 3건) 잔여 합 ≥ 주문액일 때만**
  생성(큰 잔여 우선, 마지막 후보 부분 소진) ③ 합산 미달이면 **매칭 0건** — 주문 MATCHING 대기,
  출금 잠금/소진 없음 (재매칭이 tryMatchDeposit 재사용이므로 자동 적용).
- 성립 시 항상 `remaining_amount = 0` — 입금 주문의 부분 잔여 상태는 더 이상 존재하지 않음
  (위젯의 부분 매칭 표시 분기도 제거 가능).
- 출금 주문의 부분 소진(PARTIALLY_MATCHED)은 기존 유지 — 금지되는 건 "입금 주문의 잔여"뿐.
- `checkRoute` 라우팅 기준을 **전액 커버(available ≥ depositKrw)**로 정정 — 아키텍처 원문
  ("출금 잔여 ≥ 입금이면 P2P, 아니면 TORQ 전액")과 일치화. 기존 70% 커버리지 기준 폐기.

### 7.2 위젯 매칭 응답 확장 (계약 — 위젯이 이 필드명으로 구현됨)

`P2pWidgetMatchResponse`(매칭 생성/폴링/useLink 공통)에 추가:

| 필드 | 타입(직렬화) | 내용 |
|------|------|------|
| `feeRate` | STRING | 주문 스냅샷 수수료율 (소수 비율, "0.020000") |
| `feeAmount` | number | 수수료 KRW (주문 fee_amount) |
| `expectedUsdt` | STRING | 예상 수령 USDT ≈ Σ(match.usdtAmount) × (1 − feeRate), 소수 4자리 |
| `buyerName` | STRING/null | 주문 스냅샷 eKYC 실명 |
| `buyerBankName` | STRING/null | `buyer_bank_code` → `banks.name` 변환 (코드 미존재 시 null) |

### 7.3 ⚠️ 분할 정산 수수료 중복 차감 점검 (잠재 버그)

`P2pSettlementService.completeSettlement`의 `p2pFeeUsdt = dpo.feeAmount / match.exchangeRate`가
**매칭마다 주문 수수료 전액**을 차감하는 것으로 보임 — 1:N 분할이면 수수료가 N번 차감된다.
**수정**: 매칭별 비례 차감 — `feeKrw_i = dpo.feeAmount × (match.krwAmount / dpo.krwAmount)`
(마지막 매칭에서 잔여 보정으로 합계 = fee_amount 보장). 단일 매칭(1:1)은 기존과 동일 결과.

### 7.4 위젯 (Cowork 구현)

- 이체 금액 합계 = 총 결제 표기, 수수료(2%) 금액·예상 수령 USDT·잔여 시간 요약 카드
- eKYC 안내: "반드시 {buyerName}님 명의의 {buyerBankName} 계좌에서 송금" (+분할 시 "모든 건 같은 계좌에서")
- 카드 상태 인터랙션: 이체 대기(앰버) → 확인 중(스피너, 신고 시/자동감지) → 입금 확인됨(그린·계좌 접힘)
  + 진행률 바(n/N) + 전건 확인 시 헤더 전환("입금이 모두 확인되었어요 — USDT 지급 진행 중")
- 분할 시 카드 N개, DISPUTED 카드는 적색 표기 → 기존 분쟁 화면 연동

### 7-B. 부분 종결 — "보낸 만큼만 USDT 지급" (확정 2026-06-12)

분할 N건 중 일부만 이체 후 구매자가 중단하고 싶은 케이스:

- **정산 트리거 변경**: 기존 "출금 주문 전액 확인 시 정산" → **매칭 단위 정산** —
  `confirmBankTransfer`에서 해당 매칭 BANK_CONFIRMED 즉시 그 매칭의 정산을 시작한다
  (출금 주문 전액 조건 제거. 출금자 잔여/잠금은 매칭 단위로 이미 관리되므로 정합).
- **남은 이체 취소(부분 종결)**: 주문 취소 EP가 확정/신고 매칭이 존재하면 —
  ① `CREATED`(미이체) 매칭만 취소 → 출금 잔여 복구 + 재매칭 ② `BANK_PENDING`은 확인 절차 계속
  (돈이 이동했을 수 있어 취소 불가) ③ `BANK_CONFIRMED`/정산 진행분은 그대로 지급.
  잔여 매칭이 모두 종결되면 주문 COMPLETED — 실수령 = Σ확정 매칭 (부분 수령).
- **수수료**: §7.3 비례 차감과 결합 — 확정 매칭분에만 비례 부과(미이체 취소분 수수료 없음).
- **만료 시 동일 일관 처리**: 확인분 정산 진행 + 미이체분 실패 — 취소와 같은 결과.
- 위젯: 확정/신고 매칭 존재 시 버튼 라벨 "남은 이체 취소" + 확인 다이얼로그
  ("보낸 {확정합}원은 USDT로 지급되고, 남은 {미이체합}원은 취소됩니다").

### 7-C. 매칭룰 추가 — 은행 점검시간 30분 전 계좌 제외 (확정 2026-06-12)

- 출금 주문 계좌의 은행이 `bank_maintenance_windows` 기준 **현재 점검 중이거나 30분 이내 점검 시작**이면
  매칭 후보에서 제외 — 이체(기한 20~30분) + 스크래핑 자동확인이 점검과 겹치는 것을 사전 차단.
- 구현: 후보 조회(FOR UPDATE) 후 Java 필터 — 계좌→은행코드→활성 점검창 조회,
  `is_in_or_starts_within(now, 30min)` 판정. 요일(MON~SUN/DAILY)·KST·자정 경계(start>end) 창 처리 주의.
  스크래핑 verify 사전 차단에 동일 판정이 이미 있다면 공통 유틸로 추출해 재사용.
- **라우팅(checkRoute) 가용액 합산에도 동일 필터 적용** — 점검 임박 계좌가 가용으로 잡혀
  "P2P 진입 후 매칭 0건 대기"가 되는 불일치 방지. (전액 정책에서 라우팅 = 성립 가능성 판정이므로 필수)
- exact 매칭 경로에도 동일 필터.

### 7.5 검증

1. 풀 부족(주문 > 가용 합) → 매칭 0건·MATCHING 대기, 출금 소진 없음 → 출금 추가 등록 시 재매칭으로 전액 성립.
2. 분할 성립 시 Σ매칭 = 주문액, remaining=0, 이체 카드 합계 = 총 결제.
3. 응답 필드 5종 직렬화 확인(위젯 계약 일치). 4. 분할 정산 시 총 fee 차감 = fee_amount 1회분(§7.3).
5. 부분 종결: 3건 중 1건 확정 후 취소 → 확정분 정산·지급(수수료 비례), 미이체 2건 취소·출금 풀 복구, 주문 COMPLETED(부분 수령).
6. BANK_PENDING 보유 상태 취소 → 해당 건은 확인 절차 지속, CREATED만 취소.
7. 점검창: 점검 25분 전 은행 계좌 → 매칭 후보·라우팅 가용액 모두 제외 / 35분 전 → 포함. 자정 경계 창(예: 23:50~00:10) 판정 정상.

## 5. 적용 완료된 것 (이 지침과 별개로 이미 운영 반영)

- DDL: `p2p_deposit_orders.network_id` NULL 허용, `p2p_deposit_links.network_id` NULL 허용 + `deposit_order_id` 추가
- 위젯: 경로 중복 수정, 금액 입력 스텝, 실패 사유 표시, `p2pOrderCode` 복구
- 백엔드 에러코드 백로그: `useLink` 금액 필수 검증이 1010(링크 없음) 재사용 → 전용 코드 분리 권장

---

## 6. 배포 순서

1. IntelliJ §1~§4 구현 → 전 모듈 compileJava → push (CI/CD)
2. Cowork: 위젯 §4 후속(resumeOrderCode 처리) + 파트너 콘솔 링크 생성 폼 네트워크 선택 제거(partner-ui)
3. 검증: §1.3 + §2 + §3 케이스, 매칭 성립 시 20분 이체 타이머/만료 흐름까지
