# p2p_matches 증거 무결성 점검

작성일: 2026-08-14
근거: 운영 DB 실측 (1,347행 · P2P 68 · TORQ 1,247 · PARTNER 32)

> **전제** — `p2p_matches` 는 매칭된 레그의 기록이자 **거래의 증거 원천**이다. 오염되거나 정합성이 떨어지면 안 된다. 이 문서는 그 기준으로 현재 상태를 평가한다.

---

## 요약

증거로서의 결함이 여섯이다. 심각도순.

| | 결함 | 실측 |
|---|---|---|
| **A** | 증거가 **덮어써진다** | 3개 경로 전부 단일 컬럼 갱신 |
| **B** | 증거가 **애초에 비어 있다** | 분쟁 42건 중 사유 8 · 증빙 3 |
| **C** | **되돌림이 안 돼 거짓 기록이 남는다** | 정산 취소 10건이 `SETTLED` 유지 |
| **D** | **성사 안 된 레그가 섞인다** | `CANCELLED` 가 두 의미 겸직 |
| **E** | **상대방을 특정할 수 없다** | PARTNER 32건 계좌 미기록 |
| **F** | **한 컬럼이 두 의미** | `resolution` = 분쟁 판정 + 만료 사유 |
| **G** | **같은 입금을 두 매칭이 소비** | 1건 — 5,000,000원 · 회수에 원장 조작 4번 |

> **A~F 는 사후 추적의 문제이고 G 만 자금이 실제로 어긋난 사건이다.** 우선순위가 다르다.

---

## A. 증거가 덮어써진다

`dispute_reason` / `dispute_evidence_url` 이 **단일 컬럼**이고, 세 경로가 전부 갱신한다.

```java
P2pMatchingService:787    TORQ_LP     setDisputeReason / setDisputeEvidenceUrl
P2pMatchingService:1553   DEPOSITOR   setDisputeReason / setDisputeEvidenceUrl
P2pMatchingService:1668   WITHDRAWER  setDisputeReason / setDisputeEvidenceUrl + setDisputeRound(1)
```

재제출하거나 상대방이 반박을 올리면 **이전 사유와 증빙 URL 이 복구 불가로 사라진다.** `dispute_round` 컬럼을 둔 것 자체가 여러 라운드를 상정했다는 뜻인데, 정작 내용은 한 벌만 보관한다.

### 이력 테이블이 있는데 안 쓰인다

`dispute_events` 가 이 목적으로 존재하나 **3행 / 2매칭** 뿐이다.

```
p2p_matches   disputed_at 42건        ← 실제로 쓰이는 곳
dispute_events 3행                     ← 거의 비어 있음
분쟁인데 이벤트 없음  40건
```

2026-07-14 설계는 "분쟁 스레드는 `dispute_events` 재사용"이었는데 **의도와 정반대로 굳었다.** 이력이 안 쌓이고 상태 컬럼만 12개로 늘었다. `dispute_round` 가 42건 전부 `0` 인 것도 같은 원인 — 라운드를 세는 주체가 돌지 않는다.

> ⚠️ **교정 순서 주의.** 지금 매칭 컬럼을 걷어내면 분쟁 이력이 통째로 사라진다. ① 이벤트 적재를 살리고 ② 과거 42건을 컬럼에서 이벤트로 백필한 뒤 ③ 걷어낸다.

---

## B. 증거가 애초에 비어 있다

```
분쟁 42건
  ├ 사유 있음    8건
  └ 증빙 있음    3건
```

제출 주체별로 보면 원인이 갈린다.

| 주체 | 건수 | 사유 | 증빙 |
|---|---|---|---|
| `TORQ_LP` | 34 | **0** | 1 |
| `SYSTEM` | 3 | 2 | 0 |
| `DEPOSITOR` | 2 | 2 | 2 |
| `ADMIN` 계열 | 2 | 2 | 0 |
| `WITHDRAWER` | 1 | 1 | 0 |

**TORQ_LP 34건에 사유가 하나도 없다.** 우리 매핑 버그가 아니다 — 원천인 `torq_trades` 도 분쟁 50건 중 `dispute_statement` 가 3건뿐이다. **TORQ 웹훅이 사유를 실어 보내지 않는다.**

즉 분쟁의 80%가 "분쟁이 있었다"는 사실만 남고 내용이 없다. 사후에 판정 근거를 재구성할 수 없다.

**조치**: TORQ 측에 분쟁 페이로드의 사유 필드 포함을 요청한다. 우리 쪽에서 만들 수 없는 데이터다.

---

## C. 되돌림이 안 돼 거짓 기록이 남는다

원장만 가역이고 나머지는 전부 비가역이다.

```
P2pSettlementStatus   PENDING → PROCESSING → COMPLETED / FAILED     ← 나갈 길 없음
P2pMatchStatus        … → SETTLED                                    ← 나갈 길 없음
DepositStatus         … → SETTLED                                    ← 나갈 길 없음
ledger_entries        반대 엔트리로 가역 ✓
```

그래서 보정이 일어나면 원장과 나머지가 갈라진다. 실측 사례 셋이 전부 같은 형태다.

| 사건 | 원장 | 매칭 · 정산 |
|---|---|---|
| PARTNER 레그 유령 USDT 10건 (2026-07-02) | 역분개 완료, 순액 0 | `p2p_settlements` `COMPLETED` 유지 |
| 매칭 224 이중확정 | 크레딧→회수→재크레딧→회수, 순액 0 | 정산 `COMPLETED` + `tx_hash` 유지 |
| 매칭 855 TORQ 금액 보정 (2026-07-22) | 정상 | 매칭이 **취소된 escrow(810)** 를 계속 가리킴 |

**영향**: 관리자 정산 조회에서 취소된 10건이 정상 정산으로 보인다. 기간 합계를 내면 약 27,300 USDT 과대 계상된다. 대시보드는 `DATE(created_at)=CURDATE()` 필터라 안 잡히고, 파트너 P&L 은 원장 기반이라 정확하다.

**미회수 수수료 1건**: `pm_6479f0e7f5d0` 의 FEE 39.447731 USDT 가 유령 크레딧에 붙었는데 크레딧만 회수하고 수수료는 남았다. 현재 `settlePartnerLegFiat` 는 수수료를 기록하지 않으므로 이 건만 모델 밖에 떠 있다.

**조치**

```sql
-- P2pSettlementStatus 에 REVERSED 추가
ALTER TABLE p2p_settlements
  ADD COLUMN reversed_at DATETIME(6) NULL,
  ADD COLUMN reversal_reason VARCHAR(200) NULL,
  ADD COLUMN reversal_ledger_entry_id BIGINT NULL
    COMMENT '이 정산을 취소한 역분개 원장 엔트리. 원장과 정산을 기계적으로 잇는다';
```

지금은 연결이 `description` 자유 텍스트("PARTNER leg phantom USDT reversal")에만 있어 기계 판독이 불가능하다.

> **미결** — 매칭·입금에도 되돌림 상태를 둘지. 매칭 자체는 유효했고 정산만 취소된 경우가 대부분이라 정산 레벨이면 충분해 보이나, `deposits` 는 **파트너 웹훅이 이미 나간 뒤**라 별도 판단이 필요하다.

---

## D. 성사 안 된 레그가 섞인다

`CANCELLED` 가 두 가지를 겸한다.

| 형태 | 주문 | 레그 | 판별 |
|---|---|---|---|
| 매칭 성사 후 취소 | 48 | 54 | `remaining_amount = 0` |
| **미충족 롤백** (레그 선생성 후 회수) | **6** | **6** | `remaining_amount = krw_amount` |

레그는 **성사 전에** 생긴다. P2P 레그를 만들어 놓고 잔여를 TORQ·PARTNER 로 못 채우면 `failUnifiedOrder` 가 지우지 않고 `CANCELLED` 로 전이시킨다.

```
ord 39  P2P/CANCELLED  주문종료까지 0초  krw 40,000  remaining 40,000
ord 40  P2P/CANCELLED  주문종료까지 0초  krw 40,000  remaining 40,000
```

증거 관점에서 "이 레그는 성립한 적이 없다"와 "성립했다가 취소됐다"가 구분되지 않는다. 판별하려면 주문 행을 되짚어야 한다.

**조치**: 종결 사유를 명시한다. 비동기 매칭 전환 시 주문의 `close_reason` 과 함께 레그에도 사유를 남긴다.

> 참고: **재시도 누적은 이미 해결됐다.** 2026-06-16 재매칭 정책 제거 이후 0건이고, 과거 5건(주문 12는 7레그, 29는 5레그)만 유물이다.

---

## E. 상대방을 특정할 수 없다

레그마다 상대방 링크 방식이 다르다.

| 레그 | 링크 | 상태 |
|---|---|---|
| P2P | `withdraw_order_id` → `bank_account_id` | 시점 고정 ✓ (68건 중 3건 NULL — 1원 테스트) |
| TORQ | `torq_escrow_id` ↔ `torq_trades.p2p_match_id` | **양방향 이중 기록** — 855 에서 갈라짐 |
| PARTNER | **없음** | 읽을 때 `.get(0)` 재해석 ✗ |

```java
// PARTNER 레그 — 매칭이 어느 계좌를 썼는지 기록하지 않는다
List<BankAccount> accts = bankAccountRepo.findByOwnerTypeAndOwnerId(PARTNER, dpo.getPartnerId());
return accts.get(0);   // 정렬 보장 없음
```

현재 PARTNER 서비스 계좌가 시스템 전체에 **1개뿐**이라 우연히 안전하다. 계좌가 추가·교체되는 순간 **이미 정산된 21건의 "입금한 계좌"가 소급 변조된다.** 분쟁이 나면 어디로 보냈는지 증거가 없다.

**조치**

```sql
ALTER TABLE p2p_matches
  ADD COLUMN bank_account_id BIGINT NULL
    COMMENT '이 레그의 입금 대상 계좌 — 매칭 시점 고정. PARTNER 레그는 파트너 서비스계좌,
             P2P 레그는 출금주문 계좌(중복이나 증거로서 자기완결). TORQ 레그는 NULL(외부 LP)';
```

TORQ 는 `torq_trades` 를 **정본**으로 확정하고 `p2p_matches.torq_escrow_id` 는 표시용 사본으로 격하한다. 855 에서 매칭은 취소된 810 을, 역링크는 완료된 812 를 가리켜 이미 갈라졌다.

---

## F. 한 컬럼이 두 의미

`resolution` 이 분쟁 판정과 만료 사유를 겸한다.

```
TORQ_EXPIRED                24   ← 만료 사유
TORQ_DISPUTE_BUYER_TIMEOUT   9   ← 분쟁 판정
TORQ_DISPUTE_REJECTED        8   ← 분쟁 판정
CONFIRM                      5   ← 분쟁 판정
CANCEL                       2
MANUAL_LOOP_STOP             1
```

`resolution` 97건인데 `disputed_at` 은 42건이다. 분쟁이 아닌 종결에도 `resolution` 이 찍힌다. 그리고 `resolved_by` 는 7건뿐이라 **90건은 누가 판정했는지 없다.**

---

## 부수 발견

**`manual_confirm_started_at` 이 엉뚱한 레그에 찍힌다.** 479건 중 **478건이 TORQ 레그**다. `submitTransferDone` 이 레그 타입 무관하게 찍는데, 주석은 "수동확인 리마인더 창 시작 기준"으로 P2P 전용 의도다. 리마인더 잡이 `withdraw_order_id == null` 이면 스킵하므로 무해하나, 컬럼 이름과 내용이 어긋난다.

> **⛔ 취소됨 (2026-08-22)** — "P2P 레그 한정으로 정리" 계획을 **폐기**한다. 위젯 "확인 대기"
> 카드의 경과 시간 표시가 이 값을 **전 레그(P2P/TORQ/PARTNER)에서** 읽게 됐다
> (`P2pWidgetMatchDetailResponse.transferReportedAt` ← `P2pWidgetController#toMergedDetail`).
> "무해하므로 정리 시 함께"의 전제가 깨졌다 — 레그 타입 분기를 넣으면 TORQ/PARTNER 카드의
> 경과 시간이 조용히 사라진다. 코드 쪽 취소 표시는 `P2pMatchingService#submitTransferDone`
> (`manual_confirm_started_at` 쓰기 지점) 주석에 있다. 근거: `P2P_DEPOSIT_BACKEND_PREP_GUIDE.md` §C.

**한 번도 쓰이지 않은 컬럼 7개** (1,347행 기준)

```
relock_failed            0    v2.6 신설, 미발동
dispute_round            0  ┐
dispute_waiting_on       0  ├ 분쟁 스레드 — A 항목의 원인
dispute_due_at           0  ┘
manual_reminder_sent_at  0  ┐
manual_escalated_at      0  ├ P2P 레그 전용인데 P2P 수동확인이 1건뿐 → 정상
manual_reminder_count    0  ┘
```

**`withdraw_bonus_rate`** — v2.8 폐기분, 레거시 23건 잔존.

---

## G. 같은 입금을 두 매칭이 소비한다

앞의 여섯이 **사후 추적**의 문제라면 이것은 **자금이 실제로 어긋난** 사건이다.

### 실측

```
20260614_222333_19561   매칭 20, 21    주문 1개   정상 — 1 송금을 2 레그가 나눠 씀
20260701_155249_5000000 매칭 223, 224  주문 2개   사고 — 1 송금을 2 주문이 씀
TG_MEMBER_CONFIRM       매칭 728, 841              ref 가 아닌 상수 문자열
```

매칭 224는 5,000,000원 · 3,280 USDT 였고 회수에 원장 조작 4번이 들었다(크레딧→회수→재크레딧→회수).

### 원인 — 가드가 타이밍에 의존한다

```java
if (matchingMapper.countRefUsageInAccountScope(bankAccountId, ref, match.getId()) > 0) continue;
```

`SELECT` 후 판단이라 그 사이가 열려 있다. 그리고 `P2pScrapingVerifyJob` 에 **`@Transactional` 이 하나도 없다.** 5초 주기로 매칭을 순회하는데 트랜잭션 경계가 없어 가드가 자기 배치 안의 앞선 소비를 못 본다.

```
매칭 223  bank_confirmed_at  2026-07-01 15:52:54.831
매칭 224  bank_confirmed_at  2026-07-01 15:53:01.575   ← 6.7초 뒤, 같은 사이클
```

### 왜 단순 UNIQUE 로 못 막나

`UNIQUE(bank_account_id, bank_transfer_ref)` 를 걸면 **정상인 매칭 20·21 까지 막힌다.** DB 에서는 둘 다 "같은 ref 를 쓰는 매칭 2개"로 똑같이 보인다. 구분 기준은 "같은 주문이냐"인데 매칭 행만으로는 표현되지 않는다.

### 조치 — 입금 확인을 1급 개체로

```
p2p_bank_confirmations
  id
  bank_account_id · bank_transfer_ref · deposit_order_id
  amount_krw · depositor_name · confirmed_at · source
  UNIQUE (bank_account_id, bank_transfer_ref)
```

**1 송금 = 1 확인 행, N 레그가 그것을 참조**한다. 그러면 유니크가 자연스럽게 성립한다.

```
매칭 20, 21   확인 1행(주문 12)을 둘 다 참조     → 통과
매칭 223      확인 1행 생성
매칭 224      같은 ref 로 생성 시도 → UNIQUE 위반 → DB 가 거부
```

타이밍에 의존하지 않는다. 트랜잭션 경계가 어떻든 두 번째 INSERT 가 DB 에서 막힌다.

### 매칭에서 나가는 것

```
bank_transfer_ref · confirmed_depositor_name  →  p2p_bank_confirmations
매칭에는 confirmation_id 참조만
```

`bank_confirmed_at` 은 레그의 상태 전이 시각이기도 하므로 매칭에 남긴다(§"거래 원장 + 상태" 원칙).

### 마이그레이션 — 대상 52건, 위반 1쌍

```
TORQ_ (escrow)        TORQ 1,157건   escrow 참조 — 은행 확인 아님. 대상 제외
스크래핑 (실제 은행)   P2P 31 · PARTNER 20 = 51건   ← 이관 대상
형식 변형             PARTNER 1건 (매칭 269, `20260703_1111_9000000` — 시각 4자리)
TG 수동확인            P2P 2건       은행 확인 아님. 제외
```

위반은 223/224 한 쌍뿐이고 이미 알려진 사고 건이다. 223 만 확인 행으로 만들고 224 는 사고로 표시한다.

> ⚠️ `buildRef` 의 시각 포맷이 일정하지 않다(6자리 vs 4자리). 유니크에는 영향이 없으나 정리 대상이다.
>
> ⚠️ `TG_MEMBER_CONFIRM` 은 텔레그램 수동 확인인데 은행 참조 컬럼에 상수로 들어가 있다. 확인 테이블에는 넣지 않고 `source` 로 구분한다.

---

## 조치 우선순위

### P0 — 자금 어긋남을 막는다

| 순서 | 조치 |
|---|---|
| 1 | `p2p_bank_confirmations` 신설 + `UNIQUE(bank_account_id, bank_transfer_ref)` (§G) |
| 2 | 스크래핑 확인 51건 이관 · 223/224 위반 처리 |
| 3 | `P2pScrapingVerifyJob` 에 매칭 단위 트랜잭션 경계 부여 |

### P0 — 증거 소실을 막는다

| 순서 | 조치 |
|---|---|
| 1 | `dispute_events` 적재 복구 — 분쟁 3경로가 이벤트를 남기게 한다 |
| 2 | 과거 42건을 매칭 컬럼 → 이벤트로 백필 |
| 3 | TORQ 측에 분쟁 사유 페이로드 요청 (우리가 만들 수 없는 데이터) |

### P1 — 거짓 기록을 없앤다

| 조치 |
|---|
| `P2pSettlementStatus.REVERSED` + `reversed_at` / `reversal_reason` / `reversal_ledger_entry_id` |
| 기존 10건 `REVERSED` 백필, 미회수 FEE 39.447731 처리 결정 |
| TORQ 정본 확정 — `torq_trades` 를 정본으로, `torq_escrow_id` 사본 격하 |

### P2 — 증거를 자기완결로

| 조치 |
|---|
| `p2p_matches.bank_account_id` 신설 (PARTNER 레그 시점 고정) |
| `CANCELLED` 사유 명시 — 비동기 매칭 전환과 함께 |
| `resolution` 을 분쟁 판정 전용으로 좁히고 만료 사유는 분리 |
| 분쟁 12컬럼 → `dispute_events` 이관 (**A-3 백필 완료 후**) |

---

## 범위 밖

- ~~`manual_confirm_started_at` 을 P2P 레그로 한정 — 무해하므로 정리 시 함께~~
  **⛔ 취소 (2026-08-22)** — 위젯이 전 레그에서 이 값을 읽는다(위 "부수 발견" 항목의 취소 주석 참조).
  **P2P 한정 정리 금지.**
- `withdraw_bonus_rate` 물리 제거 — DROP 은 별건
- 구매자 신원 정본 부재 (105명이 1,316번 주문, 이름·전화·계좌가 주문마다 전량 복사)
