# P2P 재설계 구현 계획

> 작성 2026-08-14. 설계 문서 6종(2026-08-13)을 구현 순서로 옮긴 것.
> **설계를 새로 벌이지 않는다.** 이 문서는 "무엇을 어떤 순서로, 무엇을 근거로 합격 판정하는가"만 다룬다.
>
> 오늘 코드 실측(서브에이전트 3건)과 운영 DB 실측으로 **설계 문서를 정정해야 할 것 5건**이 나왔다(§1).
> 그 정정이 순서에 영향을 주므로 먼저 읽을 것.

---

## 0. 현재 지점

```
push        완료 — 백엔드 a8688b3..10882c8 (5) · admin 278046e..5d044f9 (1)
배포        미실행 (manual ▶ 대기)
DDL v2.10   파일 반영 완료 · 운영 적용 0 (실측: 신설 4테이블 전부 부재)
구현        0
```

운영 실측 (2026-08-14)

```
분쟁 42건        TORQ 37 · P2P 4 · PARTNER 1      dispute_events 3행
출금 주문 35건   활성 0 (PENDING/PARTIALLY_MATCHED 없음)
정산 45건        ledger P2P_SETTLEMENT 1,267행 (정산참조 95 · 매칭참조 1,172)
은행 ref         스크래핑 52 · TORQ_ 1,163 · 상수 2
```

---

## 1. 설계 문서 정정 5건

착수 전에 반영해야 한다. 전부 오늘 실측으로 확인한 것이고, 근거를 붙였다.

### 1-1. 정산 마이그레이션 검증 쿼리가 false-green 이다 — **P0**

`P2P_SETTLEMENT_RESTRUCTURE.md` §2.5 ② 는 이렇게 검증하라고 한다.

```sql
SELECT COUNT(*) FROM ledger_entries le
 WHERE le.reference_type='P2P_SETTLEMENT'
   AND NOT EXISTS (SELECT 1 FROM p2p_matches m WHERE m.id = le.reference_id);
-- 0 이어야 한다
```

**이 쿼리는 마이그레이션을 하지 않아도 거의 0 이 나온다.** 정산참조 95행 중 **89행의 `reference_id` 가 유효한 매칭 ID 로도 존재**하기 때문이다(매칭 1~45 가 실재). 즉 "0 이 나왔다"가 "매칭 ID 로 통일됐다"를 뜻하지 않는다.

판별자는 ID 가 아니라 **`description` 이다.**

```
95행 중 83행   description 에 pm_ 매칭코드 있음
               → 83건 전부 s.match_id 의 match_code 와 일치
               → 자기 reference_id 를 매칭으로 본 것과 일치하는 행: 0
95행 중 12행   pm_ 코드 없음 — 전부 DEBIT 보정이고 설명에 매칭번호 명기
               ("보정: match208 ...", "이중확정 보정: 매칭224 ...",
                "PARTNER leg phantom USDT reversal" ×8)
               → 12건 전부 s.match_id 와 일치 확인
```

~~**결론: 95행 전부 정산 참조가 맞다. ID 밴드(`<= 45`) 가정은 결과적으로 옳다.**~~

> ## ☠️ 위 결론은 **3일 뒤 반증됐다** (2026-08-17)
>
> "결과적으로 옳다"는 **그 시점의 우연**이었다. 정산이 47건으로 늘자 46·47 이 생겼고, 그 값은 TORQ
> 매칭 참조가 **이미 쓰던 값**이다 — 두 ID 공간이 실제로 충돌했다. (2026-08-28 현재 정산 **53건**)
>
> ```
> ledger 522   ref=46  "P2P TORQ 레그 입금 — pm_cacff243c4bc"   → 매칭 46
> ledger 2934  ref=46  "P2P 입금 — pm_079ebbb16cfe"            → 정산 46 (매칭 1379)
> ```
>
> **밴드는 검증뿐 아니라 선정 기준으로도 못 쓴다.** 판별자는 `description` 의 `pm_` 매칭코드다.
> 이관은 [`T4_LEDGER_REFERENCE_UNIFY.sql`](./T4_LEDGER_REFERENCE_UNIFY.sql) 로 한다.
>
> **교훈**: ID 밴드는 두 시퀀스가 각자 자라는 한 언젠가 겹친다. "지금은 맞다"를 근거로 삼지 마라.

다만 그것을 확인해 준 것은 밴드가 아니라 description 이었다. 검증 쿼리를 아래로 교체한다.

```sql
-- ☠️ 폐기 — 밴드 사용 금지 (위 경고). T4 STEP 0-2 의 description 기준 분류를 쓸 것.
-- 마이그레이션 전: 95행이 전부 정산으로 해소되는가 (95 여야 함)
-- SELECT COUNT(*) FROM ledger_entries le
--   JOIN p2p_settlements s ON s.id = le.reference_id
--  WHERE le.reference_type='P2P_SETTLEMENT' AND le.reference_id <= 45;

-- 마이그레이션 후: description 의 pm_ 코드가 reference_id 매칭과 일치하는가 (83 여야 함)
SELECT COUNT(*) FROM ledger_entries le
  JOIN p2p_matches m ON m.id = le.reference_id
 WHERE le.reference_type IN ('P2P_SETTLEMENT','P2P_MATCH')
   AND le.description LIKE CONCAT('%', m.match_code, '%');
```

### 1-2. 상수 ref 가 하나 더 있다

문서는 `TG_MEMBER_CONFIRM` 만 언급하지만, `P2pWithdrawPageController.java:560` 이 **`WITHDRAWER_CONFIRM`** 을 같은 방식으로 `bank_transfer_ref` 에 넣는다. 둘 다 은행 확인이 아니라 자가 신고다.

```
p2p_bank_confirmations 제외 대상 = TORQ_* (1,163) + TG_MEMBER_CONFIRM + WITHDRAWER_CONFIRM (2)
이관 대상 = 스크래핑 52건   (문서는 51건 — 1건 차이, 이관 시 실제 목록으로 재확인)
```

중복 ref 는 2쌍이고 성격이 다르다. 문서 그대로다.

```
20260614_222333_19561    매칭 20,21   주문 1개   정상 — 확인 1행을 2 레그가 공유
20260701_155249_5000000  매칭 223,224 주문 2개   사고 — 223만 확인행, 224는 p2p_incidents
```

### 1-3. `rematchWaitingDeposits` 는 존재하지 않는다

`P2P_ASYNC_MATCHING_DESIGN.md` 와 이전 세션 메모가 "재매칭 트리거 4곳"을 전제하지만, **2026-06-16 정책으로 제거됐다**(`P2pMatchingService.java:858` 주석). 만료→재매칭→재생성 루프의 근본 원인 제거가 이유였다. `findWaitingDepositOrdersForUpdate` 쿼리만 호출부 0건으로 남아 있다.

**비동기 매칭은 "재매칭 부활"이 아니라 "PENDING→claim→MATCHING 큐잉 신설"로 접근한다.** 제거된 이유(루프)를 다시 만들지 않도록 `match_attempt_count` 임계 종결이 설계에 이미 들어 있다.

### 1-4. 게이트는 3곳이 아니라 4곳이다

핸드오프 §9.5 는 `findAvailableWithdrawOrders` · `findMatchableWithdrawOrdersForUpdate` · `PartnerP2pPoolMapper` 세 곳의 일치를 요구한다. **이 셋은 이미 구조적으로 안전하다** — `GROUP_SCOPE_JOIN`/`GROUP_SCOPE_WHERE` 상수를 문자열 복사가 아니라 직접 참조하고 있다.

문제는 네 번째다.

```
admin-api  P2pWithdrawPoolMapper.java:26-68   (전역 관리자 풀 현황)
  없는 게이트: wp.krw_enabled · wp.p2p_withdraw_enabled · wp.status='ACTIVE'
              wm.trading_paused=0 · usdt_convert_status · v2.9 GROUP_SCOPE
```

SQL 을 손으로 다시 썼고 상수를 참조하지 않는다. 관리자 화면 수치가 실제 매칭 가능액보다 **넓게** 나온다. 트랙 4에 포함한다.

### 1-4b. ☠️ `torq.webhook-secret` 을 절대 설정하지 말 것 — **P0**

**TORQ 는 웹훅 서명을 보내지 않는다.** 2026-05-16 에 HMAC 을 폐기하고 URL 단독 신뢰 모델로 갔고, 그 결정이 유지되고 있다(2026-08-14 TORQ 확인).

그런데 우리 수신부에는 검증이 구현돼 있다.

```java
if (webhookSecret != null && !webhookSecret.isBlank()) {
    if (!verifySignature(...)) return 401;
}
private boolean verifySignature(...) {
    if (signature == null || timestamp == null || body == null) return false;   // ← 항상 여기
```

**시크릿을 채우는 순간 모든 TORQ 웹훅이 401 로 거부된다.** 완료 웹훅이 끊기면 TORQ 레그 정산이 통째로 멈추고 구매자 크레딧이 누락된다.

> ⚠️ 이전 세션 메모(`torq-amount-correction`)와 지식 repo 트러블슈팅 §26 에 이 상태가 **"프로드 미설정 — 서명 검증 활성화 권장(P2)"** 로 기록돼 있다. **그 권장을 따르면 장애다.** 두 기록을 정정해야 한다.

조치

```
① 코드에 경고 주석 — 시크릿을 채우면 전 웹훅 401
② 설정 키를 제거할지 방어적으로 남길지 결정 (남긴다면 주석 필수)
③ 대체 방어 — TORQ 발신 IP allowlist(목록 요청함) + X-TORQ-Event-Id 멱등 저장
   현재 Event-Id 를 로그에만 쓰고 대조하지 않는다
```

### 1-5. "엔티티가 모르는 컬럼" 은 위험하지 않다 — 방향이 반대다

Axim `IXRepository` 는 **엔티티 필드로 SELECT 컬럼 목록을 만든다.** 따라서

```
DB 에 있고 엔티티에 없음   → 무해 (SELECT 목록에 안 들어감)
엔티티에 있고 DB 에 없음   → 그 테이블을 읽는 모든 쿼리 실패 (2026-08-13 장애)
```

**DDL 을 코드보다 먼저 넣는 것이 안전한 이유가 이것이다.** v2.10 을 지금 적용해도 기존 배포는 영향을 받지 않는다.

---

## 2. 순서 — 파일 충돌이 결정한다

네 트랙이 **같은 파일 두 개**로 수렴한다.

```
core/p2p/P2pMatchingService.java     ~2,100행   트랙 1·2·4 전부 수정
core/p2p/P2pSettlementService.java   ~1,100행   트랙 2·3 수정
```

병렬 진행하면 서브에이전트끼리 충돌한다. **순차 진행이 강제된다.**

순서 원칙은 **도메인 흐름**이다. 출금이 유동성의 시작점이고 매칭이 그 잔액을 소비한다. 잔액 계산을 고치지 않고 매칭부터 손대면 잘못된 기반 위에 얹게 되고, 나중에 기반을 갈아엎을 때 앞서 만든 것을 다시 손대야 한다.

```
출금(유동성) → 매칭 → 입금확인 → 정산     ← 자금이 흐르는 순서
분쟁은 예외 경로라 옆에 붙는다
```

| # | 트랙 | 왜 이 순서인가 | 주 파일 |
|---|---|---|---|
| **S0** | DDL v2.10 운영 적용 | 전 트랙 공통. additive 라 무해하고, 코드보다 먼저여야 안전하다 | — |
| **S1** | 분쟁 **이벤트 적재 복구만** | 작고 독립적이라 흐름을 안 막는다. **지금도 유실 중**이라 앞에 뗀다 — 백필·컬럼제거는 T5 로 | Matching(분쟁 구간 2곳) |
| **T1** | **출금 원장** `p2p_withdraw_entries` (+ `network_id` 저장) | **기반이고, 지금이 적기다.** 아래 참조 | Matching(생성·취소·실패) · Withdraw |
| **T2** | **매칭 서비스** — 비동기 전환 + 게이트 4곳 통일 | 출금 원장 위에 올린다. 잔액이 `SUM` 으로 바뀐 뒤라야 후보 조회를 한 번만 고친다 | Matching 전면 |
| **T3** | 입금 확인 `p2p_bank_confirmations` | 매칭이 만들어진 뒤의 단계. `confirmBankTransfer` 는 T1·T2 에서 이미 바뀌어 있다 | Matching(confirm) · Job |
| **T4** | 정산 → `p2p_settlements` 제거 (+ `network_id` 읽기 전환) | 마지막 자금 단계. 체인이 매칭에 저장된 뒤(T1)라야 지울 수 있다 | Settlement 전면 |
| **T5** | 분쟁 마무리 — 백필 → 컬럼 제거 → admin 화면 | S1 이 신규분을 잡고 있으므로 급하지 않다. 되돌릴 수 없는 단계라 뒤로 | 전 모듈 · admin-ui |

### 왜 출금 원장이 지금인가 — 활성 0건

```
출금 주문 35건   COMPLETED 34 · CANCELLED 1 · 활성 0
미종결 매칭      0
```

**재구성 대상 35건이 전부 종결 상태다.** 마이그레이션 중 동시 변경이 없고, 잔액 재계산이 살아 있는 거래를 건드리지 않는다. 활성 주문이 생기면 난이도가 올라간다 — **미룰수록 손해다.**

> ⚠️ **실측 중 발견** — 미종결 매칭이 0인데 `p2p_partner_locks.locked_balance > 0` 인 행이 **3건** 있다. 열린 거래가 없는데 잠긴 유동성이 남아 있다는 뜻이다. 원장 재구성이 이것을 드러낼 대상이고, T1 착수 시 개별 검증한다.

### 앞선 순서에서 무엇이 바뀌었나

종전 안은 `network_id → 분쟁 → 입금확인 → 정산 → 출금원장` 이었다. 기술 의존성과 긴급도로만 짠 것이라 **도메인 흐름을 거슬렀다.**

```
문제 1  출금 원장이 createMatch·failMatch·cancelMatch 를 갈아엎는데,
        그 앞에 network_id·입금확인을 넣으면 같은 메서드를 두 번 고친다
문제 2  출금 원장을 마지막에 두면 활성 주문이 생긴 뒤 마이그레이션하게 된다
```

`network_id` 는 별도 트랙에서 내려 **T1 에 흡수한다** — 어차피 `createMatch` 를 건드리므로 그때 함께 저장하는 것이 자연스럽다. 읽기 전환만 T4 로 미룬다.

**병행 가능 — 본 순서를 막지 않는다**

| 트랙 | 조건 |
|---|---|
| TORQ2 도입 | 트랙 0 완료 + TORQ2 자격증명 수령. [LP_ADAPTER_DESIGN.md](./LP_ADAPTER_DESIGN.md) §5 체크리스트 |
| TORQ 금액 정정 (우리 쪽) | **TORQ 회신 대기** + 트랙 1 완료. 정정 이력을 `dispute_events` 에 `AMOUNT_ADJUSTED` 로 남겨야 하고, 매칭 금액 변경 연쇄가 트랙 4의 잠금 재계산과 겹친다. [TORQ_HANDOFF_2026_08_14.md](./TORQ_HANDOFF_2026_08_14.md) §D-1 회신이 부정이면 범위를 다시 잡는다 |

> **1순위 근거는 "설계 우선순위"가 아니라 의존과 속도다.** 트랙 0은 반나절짜리인데 트랙 3과 TORQ2를 동시에 막고 있다. 트랙 1은 지금도 증거가 유실 중이다. 반면 입금 확인 이중 소비는 P2P 레그가 생겨야 발생하고, 활성 출금 주문이 0건이다.

---

## 2-1. 실행 단계표

**한 단계 = 한 배포.** 배포하고 검증한 뒤 다음으로 간다. 되돌릴 수 없는 단계(백필·DROP)는 앞 단계 검증 통과가 조건이다.

| 단계 | 내용 | 되돌릴 수 있나 | 합격 기준 |
|---|---|---|---|
| **S0** | DDL v2.10 운영 적용 | ✔ additive (백필은 예외) | 신설 테이블 4 · 컬럼 12 · **백필 시점 기준** `network_id` NULL 0건 |
| **S1** | 분쟁 이벤트 적재 복구 + `p2p_disputes` 헤더 | ✔ 이중 기록 | 배포 후 신규 분쟁 전건 이벤트 1 + 헤더 1 |
| **T1-a** | 출금 원장 병행 기록 + `network_id` 저장 | ✔ 이중 기록 | 신규 주문·매칭이 원장 행 생성, 기존 컬럼도 그대로 갱신 |
| **T1-b** | 과거 35건 재구성 | ✘ | 음수 잔액 0. 예외는 개별 검증 후 `p2p_incidents`. **유령 잠금 3건 규명** |
| **T1-c** | 잔액 읽기를 `SUM` 으로 전환 (후보 조회 4곳 포함) | ✔ | 게이트 4곳 동일 결과. **조건은 SQL 안에** (LIMIT 기아 방지) |
| **T1-d** | **회원 화면(p2p-ui) 전환** — 계산 경로 일원화 + §7 명세서 뷰 | ✔ | 대시보드·주문상세·지난거래가 **같은 출처**를 볼 것. T1-c 와 함께 가거나 바로 뒤 |
| **T1-e** | 누적 컬럼 7개 제거 | ✘ | `refund_type` 의 '이미 처리됨' 게이트가 살아 있을 것 |
| **T2-a** | 주문 상태머신 정비 — `PARTIALLY_SETTLED` · `closed_at` · `close_reason` | ✔ | `EXPIRED` 가 '정산 0건 만료'로 좁혀짐 |
| **T2-b** | 매칭 워커 — `PENDING` claim → `MATCHING` · 좀비 회수 | ✔ 스위치 false | **스위치 OFF 시 동작 불변** |
| **T2-c** | 외부 호출(TORQ)을 잠금 밖으로 | ✔ | FOR UPDATE 보유 중 외부 I/O 0 |
| **T2-d** | 위젯 — 짧은 동기 대기 후 폴링 | ✔ | |
| **T3-a** | `bank_account_id` 채우기 + 확인 테이블 쓰기 | ✔ 이중 기록 | 그룹 확인이 확인 1행을 N레그가 공유 |
| **T3-b** | 과거 52건 이관 + `P2pScrapingVerifyJob` 트랜잭션 경계 | ✘ 이관 | 223/224 위반쌍은 223만 확인행 + 224 사고 기록 |
| **T3-c** | 매칭 `bank_transfer_ref` · `confirmed_depositor_name` 제거 | ✘ | |
| **T4-a** | 정산이 매칭 체인을 읽음 (`DEFAULT_NETWORK_ID` 제거) | ✔ | TORQ 레그 크레딧 체인 = 매칭 체인 |
| **T4-b** | 원장 참조 통일 95행 | ✘ | §1-1 교체 쿼리로 검증 (`description` 판별) |
| **T4-c** | `reference_type` 개명 + 필터 3곳 연쇄 | ✘ | 일별 집계에 P2P 원장 미혼입 |
| **T4-d** | `settlement_code` 이관 · 재시도 귀착 · 테이블 DROP | ✘ | 온체인 이중송금 방어선 유지 |
| **T5-a** | 과거 42건 백필 | ✘ | 헤더 없는 분쟁 0건 |
| **T5-b** | 매칭 분쟁 12컬럼 제거 + admin 타임라인·REQUEST_MORE 화면 | ✘ | 읽는 쪽 전환 완료 후에만 |

> **T1-a / T1-c 를 나눈 이유** — 쓰기(원장 적재)는 무해하지만 읽기 전환은 매칭 성립 여부를 바꾼다. 원장 값이 기존 컬럼과 일치하는지 확인한 뒤 읽어야 한다.
>
> ⚠️ **회원 화면은 "매칭 대기 UI" 가 아니라 잔고 화면이다. T1-c 부터 이미 흔들린다.**
>
> `p2p-ui` 대시보드 히어로 3값이 전부 제거 대상 필드로 계산된다(실측).
> ```js
> receivedKrw:  o.confirmedAmount
> incomingKrw:  max(o.matchedAmount - o.confirmedAmount, 0)
> waitingKrw:   max(o.krwAmount - o.matchedAmount - o.usdtConvertRequestedKrw, 0)
> ```
>
> **그리고 이미 두 갈래로 계산하고 있다** — 이것이 §7 이 고치려는 것이다.
> ```
> 대시보드   서버 필드 (o.confirmedAmount, o.matchedAmount)
> 주문 상세   클라이언트가 matches 배열로 재계산 (rowKind → sumBy)
> 지난 거래   서버 필드 (confirmedAmount · remainderKrw · remainderResolution)
> ```
> 같은 "받음" 을 화면마다 다르게 구한다.
>
> | 단계 | p2p-ui 영향 |
> |---|---|
> | T1-a | 없음 — 원장은 쌓이기만 하고 API 응답 불변 |
> | **T1-c** | **값이 흔들릴 수 있음** — 서버 SUM 과 클라이언트 재계산이 어긋나면 화면마다 숫자가 달라진다 |
> | T1-e | 깨짐 — 히어로 3값 + 지난 거래가 빈다 |
>
> 따라서 **T1-d 는 T1-c 와 함께 가거나 바로 뒤여야 한다.** 컬럼 제거(T1-e) 직전이 아니다.
>
> **이 단계에서 [P2P_WITHDRAW_LIQUIDITY_DESIGN.md](./P2P_WITHDRAW_LIQUIDITY_DESIGN.md) §7 을 함께 한다** —
> "주문 목록에서 계좌 명세서로". 원장이 생기면 회원이 자기 유동성을 보는 방식 자체가 바뀐다.
> ```
> 08-13  충전                  +6,110,000   잔액 6,110,000
> 08-13  거래  pm_44cbd9…      -  180,000   잔액 5,930,000  정산완료
> 08-13  반환  pm_b8817500…    +4,870,000   잔액 8,800,000  취소
> 08-13  인출                  -  870,000   잔액         0  종결
> ```
> 지금은 회원이 보는 값과 시스템이 매칭에 쓰는 값이 **각각 계산되어 어긋날 수 있다.** 원장 이후엔 같은 데이터를 본다.
> 필드 갈아타기만 하고 화면을 그대로 두는 선택도 가능하나, **그러면 이 개편 기회를 놓친다.**
>
> **T4-a 가 T1 이 아니라 여기 있는 이유** — 저장은 T1 에서 하지만, 읽기 전환은 정산 경로를 바꾸므로 정산 트랙에서 함께 한다.

---

## 3. 트랙별 실행

각 트랙은 **지침서 작성 → 서브에이전트 구현 → 리뷰 → 판정**을 반복한다. 단계마다 배포·검증하고 다음으로 간다.

### 트랙 1 — 분쟁

#### 1단계: 적재 복구 (배포해도 기존 동작 불변)

**고칠 것**

| 지점 | 파일:라인 | 현재 |
|---|---|---|
| TORQ 웹훅 분쟁 | `P2pMatchingService.markDisputedFromTorq` 774-793 | 컬럼만 세팅, `logDisputeEvent` 호출 **없음** — 분쟁의 88% |
| 스크래핑 자동분쟁 | `P2pMatchingService.autoDisputeMatch` 1745-1768 | 동일하게 없음 |
| 만료 잡 재승격 | `P2pMatchingService.failExpiredMatch` 1970-1974 | `markDisputedFromTorq` 재호출 — 위 수정으로 자동 해소 |
| 예외 삼킴 | `DisputeEventService.java:58-63` | `catch (Exception e) { log.warn }` |

`logDisputeEvent` 를 부르는 경로는 이미 6곳 있다(`submitDispute` 1578 · `submitWithdrawerDispute` 1683 · `submitDisputeEvidence` 1728 · 파킹 1898 · `resolveDispute` 236 · `requestMoreEvidence` 284). **누락된 2곳을 같은 패턴으로 채우는 것이 1단계의 전부다.**

**예외 삼킴 처리** — 지금은 보조 로그라 삼키는 게 옳았지만, 정본이 되면 조용한 누락과 원본 부재가 구분되지 않는다. 다만 **분쟁 제기 자체를 실패시키면 안 된다**(자금이 걸린 흐름). 절충:

```
이벤트 적재 실패 시 → 예외를 던지지 않되 ERROR 레벨 + 알림
                   → 실패를 세는 지표를 남긴다
전면 예외 전파는 2단계(백필)로 정본성이 확인된 뒤 검토
```

**함께 신설** — `p2p_disputes` 헤더 생성. 분쟁 제기 지점 7곳에서 헤더 1행을 만들고, 이벤트에 `dispute_id` 를 채운다. 매칭 컬럼은 **그대로 둔다**(이중 기록). 되돌릴 수 있는 상태를 유지하는 것이 목적이다.

**신설 필요**

```
common/entity/P2pDispute.java                  (없음)
common/repository/P2pDisputeRepository.java    (없음)
common/entity/DisputeEvent.java                dispute_id · delivered_* 필드 추가
core/p2p/DisputeEventService.java              헤더 연동 + 예외 정책
```

**완료 기준**

```
분쟁 7경로 전부 dispute_events 1행 + p2p_disputes 1행 생성
기존 매칭 컬럼도 그대로 세팅 (이중 기록 — 회귀 0)
compileJava 그린
```

**배포 후 검증** — TORQ 분쟁이 실제로 들어올 때까지 기다린다.

```sql
-- 배포 시각 이후 분쟁이 전건 이벤트를 남기는가
SELECT m.id, m.leg_type, m.disputed_at,
       (SELECT COUNT(*) FROM dispute_events e WHERE e.p2p_match_id=m.id) ev,
       (SELECT COUNT(*) FROM p2p_disputes d WHERE d.match_id=m.id) hdr
  FROM p2p_matches m
 WHERE m.disputed_at >= '<배포시각>' ORDER BY m.disputed_at;
-- ev >= 1 AND hdr = 1 이어야 한다
```

#### 2단계: 과거 42건 백필

1단계가 신규분을 확실히 잡는 것이 확인된 뒤에 한다. **먼저 하면 백필 도중에도 유실이 계속된다.**

```
TORQ_LP 34건은 사유가 없어 껍데기만 생긴다. 그래도 옮긴다 —
그 34건의 유일한 기록이 매칭 컬럼이고, 옮기지 않으면 컬럼을 제거할 수 없다.
```

백필은 SQL 이지만 **되돌릴 수 없으므로 지침서에 INSERT 문 전문을 넣고 리뷰 후 실행**한다.

**검증**

```sql
SELECT COUNT(*) FROM p2p_matches m WHERE m.disputed_at IS NOT NULL
   AND NOT EXISTS (SELECT 1 FROM p2p_disputes d WHERE d.match_id=m.id);
-- 0 이어야 한다 (42건 전건 헤더 생성)
```

#### 3단계: 매칭 컬럼 12개 제거

2단계 검증 통과 후. **읽는 쪽을 먼저 끊고 컬럼을 나중에 지운다.**

읽는 쪽 실측 — 다행히 좁다.

```
admin  P2pMatchDetailResponse(8필드) · P2pTransactionLegResponse(9필드)
       P2pMatchDetailView.vue 306-360
open   P2pWidgetMatchDetailResponse(5) · P2pWidgetMatchResponse.DisputeResult
       P2pPageMatchResponse(2) · widget-ui p2p.vue
partner TransactionDetailResponse.Leg(7) · TransactionTrackingView.vue
```

`dispute_round`/`dispute_waiting_on`/`dispute_due_at` 는 **어느 DTO에도 없고 프론트에도 0건**이다. 컬럼만 지우면 된다.

> **부수 발견 — 조회 API 부재.** `DisputeEventRepository.findByP2pMatchId` 도, 그것을 감싼 `DisputeEventService.findByMatch` 도 **호출부가 0건**이다. 백필해도 볼 수단이 없다.
> **3단계와 함께 admin 분쟁 상세에 이벤트 타임라인 API+화면을 넣는다.** 컬럼을 걷어내면서 표시할 것이 필요하기도 하다.
>
> 같은 성격으로, `requestMoreEvidence` 백엔드 EP 는 있는데 **호출하는 admin UI 가 없다**(`request-more` 문자열 프론트 0건). `dispute_due_at` 42건 전부 NULL 인 이유다. 이 화면도 같이 넣는다.

---

### 트랙 2 — 입금 확인

#### 1단계: `p2p_matches.bank_account_id` 채우기 (선행, 무해)

PARTNER 레그가 서비스계좌를 런타임에 다시 찾는 로직이 **3벌 독립 구현**돼 있고 전부 정렬 없는 `.get(0)` 이다.

```
scheduler  P2pScrapingVerifyJob.resolveBankAccountId    268-270
core       P2pMatchingService.getBankAccountForMatch   2073-2075
core       P2pMatchingService.fillRemainderWithPartner  634-636
```

매칭 생성 시점에 고정한다.

```
P2P 레그      createMatch 915-947 — wo.getBankAccountId() 를 빌더에 추가
PARTNER 레그  fillRemainderWithPartner 가 636행에서 svc 를 이미 조회해 놓고
              650행 createPartnerLeg 호출 시 넘기지 않는다 → 인자 추가
TORQ 레그     NULL 유지
```

읽는 쪽 3벌은 `bank_account_id` 가 있으면 그것을 쓰고, NULL(과거 건)이면 기존 런타임 조회로 폴백. 과거 건 백필은 dangling 9건 때문에 전건 복원이 불가능하므로 **가능한 것만** 채우고 나머지는 NULL 로 둔다.

#### 2단계: 확인 테이블 도입 + 이관

```
① p2p_bank_confirmations 쓰기 경로 신설 — confirmBankTransfer 가 확인 행을 만들고
   match.bank_confirmation_id 를 채운다. 기존 컬럼도 계속 채운다 (이중 기록)
② 과거 52건 이관 — 20/21 은 확인 1행 공유, 223/224 는 223만 확인행 + 224 사고 기록
③ 검증 후 UNIQUE 발효 확인
```

**`P2pScrapingVerifyJob` 트랜잭션 경계** — 클래스에 `@Transactional` 이 **한 곳도 없다**(import 자체 없음). 5초 주기로 매칭을 순회하는데 경계가 없어 `countRefUsageInAccountScope` 가드가 자기 배치 안의 앞선 소비를 못 본다. 확인 테이블의 UNIQUE 가 근본 방어선이지만, **매칭 단위 트랜잭션 경계도 함께 넣는다**(P2P_MATCHES_INTEGRITY P0-3).

**주의** — 그룹 확인(`verifyGroup` 281-368)은 같은 ref 로 그룹 전 레그에 `confirmBankTransfer` 를 반복 호출한다(352-354). 확인 테이블 도입 후 이 경로는 **확인 1행을 만들고 N 레그가 참조**하는 형태가 되어야 한다. UNIQUE 를 그대로 두면 2번째 레그에서 터진다. **여기가 이 트랙의 최대 함정이다.**

#### 3단계: 매칭 컬럼 2개 제거

`bank_transfer_ref` · `confirmed_depositor_name`. `bank_confirmed_at` 은 레그 상태 전이 시각이라 잔류.

---

### 트랙 3 — 정산 제거

```
① 원장 참조 통일 (95행)      ← 정산 행이 살아 있을 때만 가능
② 검증 (§1-1 교체 쿼리로)
③ reference_type 개명         P2P_SETTLEMENT → P2P_MATCH
④ 코드 이관
⑤ 테이블 DROP
```

**③이 연쇄하는 곳** — 빠뜨리면 P2P 원장이 일반 입금 집계에 섞인다.

```
common   SettlementMapper.aggregateDailyFeesByDate 33-45   NOT IN 제외 필터
admin    AdminSettlementRebateMapper 170,189-198           JOIN p2p_settlements ps ON ps.id=le.reference_id
partner  PartnerSettlementRebateMapper 89,92               동일
```

뒤 둘은 **조인 대상 자체를 바꿔야 한다** — 지금 `reference_id` 가 정산 ID 라서 정산에 조인하는데, 통일 후에는 매칭 ID 이므로 `JOIN p2p_matches`.

**`settlement_code` 의 거취가 이 트랙의 핵심 결정이다.** node-service `p2p_transfer_requests.settlement_code` 가 **온체인 이중송금을 막는 단일 방어선**이고, 그 값은 `P2pSettlementService.startSettlementForMatch:208` 의 `"ps_" + generateId()` 에서 나온다. 테이블을 지우면 발급처가 사라진다.

```
재정의: "정산 코드" → "매칭 기반 온체인 전송 멱등키"
발급처를 이관하되 기존 값과 충돌하지 않게 (과거 45건이 p2p_transfer_requests 에 살아 있다)
```

> 재시도 책임은 **이관되지 않았다.** `P2pSettlementRetryJob` 은 얇은 래퍼이고 판단은 전부 `P2pSettlementService.retryFailedSettlements` 에 있다. `p2p_transfer_requests` 는 노드 측 멱등 저장소일 뿐이다. 재시도 상태(`status`/`retry_count`/`failure_reason`)를 어디로 옮길지 지침서에서 확정한다.

**`tx_hash`** — `p2p_matches.settlement_tx_hash` 가 모든 leg_type 에서 정합적으로 정의된 유일한 필드다(PARTNER=null 의도적, TORQ=null). `p2p_settlements.tx_hash` 는 P2P 레그에만 있는 파생값이라 제거해도 손실이 없다. 단 **admin 화면이 둘을 나란히 노출**하고 있다(`settlementTxHash` vs `settlementTxHashRecord`) — 화면 정리 포함.

---

### 트랙 4 — 출금 원장 + 비동기 매칭

가장 크다. 두 개를 **하나의 트랙으로 묶는 이유는 둘 다 `P2pMatchingService` 를 전면 수정하기 때문**이다. 따로 하면 두 번 갈아엎는다.

#### 4-A. 출금 원장

**교체 대상 실측**

```
matched_amount RMW      10곳 (P2pMatchingService 9 + P2pWithdrawService 1)
                        각 지점이 Math.max(0,...) 클램프를 개별 재구현
PENDING/PARTIALLY_MATCHED 전이   6곳에 동일 패턴 중복
                        newMatched==0 ? PENDING : PARTIALLY_MATCHED
confirmed_amount        쓰기는 confirmBankTransfer 1곳뿐 (읽기는 여럿)
refund_type             '어떻게 처리했나' + '이미 처리했다' 이중 의미
                        assertRemainderProcessable:568 이 후자로 쓴다 — 게이트가 사라지지 않게 할 것
```

**단계**

```
① 원장 테이블 병행 기록 (기존 컬럼도 계속 갱신 — 이중 기록)
② 과거 35건 재구성 → 여기서 음수 잔액이 드러난다
   remainder_resolution=NULL 인 5건이 전액 환불로 기록돼 원장과 안 맞는다
   차이는 개별 검증 후 p2p_incidents 로 (자동 보정 금지)
③ 읽는 쪽을 SUM 으로 전환 — 후보 조회 쿼리 4개 포함
④ 컬럼 제거
```

**③에서 게이트 4곳이 같은 정의를 쓰게 한다** — §1-4. `P2pWithdrawPoolMapper` 를 상수 공유로 끌어오는 것이 이 단계의 부수 성과다.

> ⚠️ **`LIMIT` 이 Java 필터보다 먼저 적용된다.** 잔여를 `SUM(entries)` 로 바꾸면 후보 조회가 조인+집계가 되는데, 조건을 Java 후처리로 빼면 상위 N건이 전부 걸러져 기아 상태가 된다. **조건은 SQL 안에.**

#### 4-B. 비동기 매칭

**현재 결함** — `createAndMatch` 의 `@Transactional` 하나가 전부를 감싸고, 그 안에서

```
findMatchableWithdrawOrdersForUpdate  FOR UPDATE (출금주문 최대 10건)
lockForMatch                          FOR UPDATE (p2p_partner_locks)
  ↓ 잠금을 쥔 채
torqService.getQuote     496행   외부 동기 HTTP
torqService.createTrade  524행   외부 동기 HTTP   ← 코드 자체 TODO 주석 존재
  ↓
COMMIT 에서야 잠금 해제
```

**단계**

```
① 상태 머신 정비 — PARTIALLY_SETTLED enum 신설, closed_at/close_reason 채우기
   (현재 EXPIRED 가 '아무것도 못 받음'과 '일부 받고 끝남'을 겸한다)
② 워커 도입 — PENDING claim → MATCHING, claimed_at, 좀비 회수
   스위치 기본 false. 배포만으로는 아무것도 안 바뀌어야 한다
③ 외부 호출을 잠금 밖으로
④ 위젯 — 짧은 동기 대기 후 폴링 (§5 결정 필요)
```

> 위젯 부담은 작다. 이미 응답 직후 3초 폴링을 걸고 있고(`p2p.vue:2077`, `P2P_POLL_MS=3000`), 폴링 EP 도 운영 중이다(`getMatchStatus`). "생성 응답을 기다리지 않고 즉시 폴링 화면으로" 조건 분기 정도다.

> ⚠️ **재매칭을 부활시키지 않는다**(§1-3). 2026-06-16 에 루프 때문에 제거된 기능이다. `match_attempt_count` 임계 종결이 그 자리를 대신한다.

---

## 4. 트랙별 배포 단위

각 트랙의 각 단계가 독립 배포 단위다. **한 단계 배포 → 검증 → 다음 단계.**

```
트랙 1  1단계 open-api·core·scheduler + admin-api   (분쟁 7경로)
        2단계 SQL only
        3단계 전 모듈 + admin-ui
트랙 2  1단계 core·scheduler
        2단계 core·scheduler + SQL
        3단계 전 모듈 + 프론트 3종
트랙 3  ①②③ SQL + 배포 동시 (개명은 코드·데이터 동시 전환)
        ④⑤ core·admin-api·partner-api + admin-ui
트랙 4  A①②③④ / B①②③④ 각각
```

> **푸시 ≠ 배포.** 전 배포 잡이 `when: manual` ▶ 이고 `allow_failure:true` 라 파이프라인 green 이 배포됨을 뜻하지 않는다. 각 단계마다 ▶ 실행과 실제 반영을 확인한다.

---

## 5. 착수 전 확정할 것

| 항목 | 필요 시점 | 선택지 |
|---|---|---|
| **이벤트 적재 실패 정책** | 트랙 1-1 | ERROR+알림(권장) / 예외 전파 / 현행 유지 |
| **분쟁 이벤트 조회 화면** | 트랙 1-3 | admin 타임라인 + REQUEST_MORE 화면을 같이 넣을지 |
| **`settlement_code` 발급처** | 트랙 3 | 매칭 기반 재발급 / 별도 시퀀스 / p2p_transfer_requests 로 이관 |
| **재시도 상태 귀착** | 트랙 3 | p2p_transfer_requests / 매칭 컬럼 / 신설 |
| **위젯 대기 UX** | 트랙 4-B | 2초 동기 후 폴링(권장) / 전면 비동기 |
| **사고 단위** | 트랙 2·4 | 매칭 단위 + order_code 병기(제안) |
| **P2P 요율 상한** | P2P 개시 전 | 파트너 단위 `maxFeeCap` 부재 + 하위 방향 검증 갭. **요율은 소급 정정 불가** |

---

## 6. 판정 기준

각 단계 종료 시 **합격 / 조건부 합격 / 반려** 중 하나로 명확히. 근거는 파일:라인 또는 실측 쿼리 결과.

```
P0   서비스 다운 · 자금 오차 · 조용한 오동작
P1   배포 후 곧 문제
P2   개선 제안
```

**즉시 반려 항목** (하나라도 있으면 판정 없이 반려)

```
MyBatis @Select <script> 안의 < · <= · <>      기동 시점 전 서비스 다운
엔티티에 있는데 운영 DB 에 없는 컬럼           그 테이블 전 쿼리 실패
ORDER BY / LIMIT / COUNT 직접 작성             XResultInterceptor 와 충돌
되돌릴 수 없는 단계를 검증 전에 실행           백필 전 DROP, 통일 전 개명
```

**애매하면 합격시키지 않는다.** 8/13 세션에서만 세 번 틀렸다(PARTNER 판정 주체 · 기한 부재 · 요율 분포). 전부 확인 없이 단정한 것이었다. 오늘도 §1-1 은 설계 문서를 믿었으면 false-green 을 통과시킬 뻔했다.
