# P2P 비동기 매칭 시스템 설계 지침서

작성일: 2026-08-14
대상: Cowork 서브에이전트 (구현) — **설계 확정 단계. 구현 착수 전 배포 계획과 함께 재검토할 것**
선행: 그룹 범위 + LP 라우팅 v2.9 (`0567c55` / admin `ac4cdca`)

---

## 1. 왜 분리하나

매칭이 **사용자 요청 스레드에서, 비관적 잠금을 쥔 채로, 외부 HTTP 를 호출**한다.

```
위젯 요청 (@Transactional)
  ① p2p_deposit_orders INSERT
  ② findMatchableWithdrawOrdersForUpdate   출금 주문 최대 10건 FOR UPDATE
  ③ findPartnerLockForUpdate               파트너 잠금 행 FOR UPDATE
  ④ TORQ createTrade — 외부 동기 HTTP      ← 잠금 보유 중
  ⑤ p2p_matches INSERT
  ⑥ COMMIT                                  ← 여기서야 잠금 해제
```

InnoDB 는 `FOR UPDATE` 행 잠금을 **커밋까지** 유지한다. TORQ 가 느리면 그동안 출금 주문 후보 10건과 파트너 잠금 행이 묶이고, 다른 구매자의 매칭이 대기한다. 코드에 이미 TODO 로 적혀 있다.

```java
// ⚠️ createTrade는 외부 동기 호출 — 현재 매칭 트랜잭션 내에서 실행(잠금 보유).
// TODO(하드닝): 운영 전 progressive(AFTER_COMMIT)로 분리하여 잠금 보유 중 외부 I/O 제거.
```

**지금 문제가 안 보이는 이유는 P2P 유동성이 적어서다.** 후보가 몇 건뿐이라 경합이 사실상 없다. 물량이 붙고 TORQ 가 한 번 느려지면 매칭이 줄줄이 밀린다.

---

## 2. 결정 사항

| 항목 | 결정 |
|---|---|
| 배치 | **신규 Gradle 모듈 `matching-worker`** — 독립 프로세스 |
| 큐 | **별도 테이블 없음.** `p2p_deposit_orders.status` 가 곧 큐 |
| 점유 | `FOR UPDATE SKIP LOCKED` (MySQL 8.0) |
| 처리 단위 | **주문 1건 = 독립 트랜잭션 1개** |
| 수평 확장 | 인스턴스 N개 가능 (`SKIP LOCKED` 로 안전) |
| 이벤트 | 주문 상태 전이 = 웹훅 발송 지점 |

### 2.1 왜 큐 테이블을 만들지 않는가

큐 테이블을 두면 **주문 상태와 큐 상태라는 두 개의 진실**이 생기고, 둘이 어긋나는 순간 어느 쪽이 맞는지 판단할 근거가 없다. 이번 정합성 점검에서 걷어낸 결함(TORQ 이중 링크, `p2p_matches.torq_escrow_id` vs `torq_trades.p2p_match_id`)이 정확히 그 형태였다.

`p2p_deposit_orders.status` 는 이미 큐 상태를 표현할 수 있다. `MATCHING` 은 enum 에 있으나 동기 매칭이라 **현재 아무도 거치지 않는다**(실측: 전량 종결 상태). 비동기로 가면 이 상태가 살아난다.

참고로 `collection_queue` 는 별도 테이블인데, 그건 **원본(`deposits`)이 큐 상태를 가질 수 없는** 구조라 그렇다. 주문은 다르다.

---

## 3. 주문 상태 머신

```
        생성
         ↓
      PENDING ─────────────┐  enqueue 상태. 워커가 집을 대상
         ↓ claim           │
      MATCHING ────────────┤  워커 점유. claimed_at 기록
         ↓ 구성 성공        │
      MATCHED              │  레그 확정. 구매자 송금 대기
         ↓                 │
   ┌─────┴─────┐           │
COMPLETED  PARTIALLY_      │  전액 정산 / 일부만 정산되고 종결
           SETTLED         │
                           ↓
                    CANCELLED / EXPIRED
```

### 3.1 신설 상태

**`PARTIALLY_SETTLED`** — 정산분이 있는데 미충족으로 종결.

현재는 `EXPIRED` 가 "아무것도 못 받음"과 "일부 받고 끝남"을 겸한다. 실측으로 `pdo_674b294e0cb4` 가 **148.68 USDT 크레딧 완료 상태에서 `EXPIRED`** 로 남아 있다. 파트너가 종결 통지를 받아도 크레딧 여부를 판단할 수 없다.

`EXPIRED` 는 **정산 0건 만료**로 의미를 좁힌다.

### 3.2 이 표에 답할 수 있어야 한다

| 질문 | 근거 |
|---|---|
| 언제 시작했나 | `created_at` |
| 어떻게 끝났나 | `status` + `close_reason` |
| 언제 끝났나 | `closed_at` |
| 얼마 못 받았나 | `remaining_amount` (만료 시 0으로 밀지 **않는다**) |
| 얼마 받았나 | 레그에서 유도 — 저장하지 않는다 |

정산 누적액(`settled_usdt`)은 **컬럼으로 만들지 않는다.** `SUM(SETTLED 레그의 usdt_amount)` 로 유도되고, 저장하면 레그와 어긋날 수 있는 두 번째 진실이 된다(v2.8 에서 `fee_amount_krw` 를 제거한 것과 같은 원칙). 레그가 최대 3개라 계산 비용도 없다.

---

## 4. DDL

```sql
ALTER TABLE p2p_deposit_orders
  ADD COLUMN closed_at DATETIME(6) NULL
    COMMENT '종결 시각 — COMPLETED/PARTIALLY_SETTLED/CANCELLED/EXPIRED 공통.
             기존 completed_at/cancelled_at 은 하위호환으로 유지하되 이 컬럼을 정본으로 쓴다',
  ADD COLUMN close_reason VARCHAR(100) NULL
    COMMENT '종결 사유. cancel_reason 은 CANCELLED 전용이라 만료·부분정산을 담지 못한다',
  ADD COLUMN match_attempt_count INT NOT NULL DEFAULT 0
    COMMENT '워커 매칭 시도 횟수. 임계 초과 시 CANCELLED 종결',
  ADD COLUMN claimed_at DATETIME(6) NULL
    COMMENT '워커 점유 시각 — MATCHING 상태 좀비 감지용';

CREATE INDEX idx_status_created ON p2p_deposit_orders (status, created_at);
```

### 4.1 백필

```sql
UPDATE p2p_deposit_orders
   SET closed_at = COALESCE(completed_at, cancelled_at, updated_at)
 WHERE status IN ('COMPLETED','CANCELLED','EXPIRED');

-- EXPIRED 인데 SETTLED 레그가 있는 건 → PARTIALLY_SETTLED (실측 2건)
UPDATE p2p_deposit_orders o
   SET o.status = 'PARTIALLY_SETTLED'
 WHERE o.status = 'EXPIRED'
   AND EXISTS (SELECT 1 FROM p2p_matches m
                WHERE m.deposit_order_id = o.id AND m.status = 'SETTLED');
```

> ⚠️ `completed_at` 오염 1건(`pdo_1d43142284aa`)이 있다. `EXPIRED` 인데 `completed_at` 이 찍혀 있고, 원장은 크레딧→회수→재크레딧→회수로 순액 0 인 **이중확정 보정 완료 건**이다. 백필 전 개별 확인할 것.

---

## 5. 워커

### 5.1 모듈

```
matching-worker/          신규 Gradle 모듈 (settings.gradle include)
  └ core, common 의존
```

`node-service` 에 두지 않는다 — 매칭 로직 전체가 `core` 에 Java 로 있어 재사용이 불가능하다.

### 5.2 점유

```sql
SELECT * FROM p2p_deposit_orders
 WHERE status = 'PENDING'
 ORDER BY created_at
 LIMIT #{batchSize}
 FOR UPDATE SKIP LOCKED
```

집은 즉시 `MATCHING` + `claimed_at` 으로 전이하고 **커밋**한다. 그 다음 주문별로 새 트랜잭션을 연다.

> `SKIP LOCKED` 덕에 워커를 늘려도 같은 주문을 두 번 집지 않는다. 인스턴스 수는 운영 중 조정 가능.

### 5.3 루프

```
poll(주기 1s)
  claim(batchSize)                     ← 트랜잭션 A (짧게)
  for each order:
      try:
          matchingService.tryMatchDeposit(order)   ← 트랜잭션 B (주문 1건)
          publish(상태 전이 이벤트)
      catch:
          match_attempt_count++
          임계 초과 → CANCELLED(close_reason='MATCH_FAILED')
          아니면   → PENDING 복귀 (백오프)
```

**주문 하나의 실패가 다른 주문을 막지 않아야 한다.** 배치 전체를 한 트랜잭션으로 묶지 말 것.

### 5.4 좀비 회수

워커가 죽으면 `MATCHING` 상태로 남는다. 별도 잡이 회수한다.

```sql
UPDATE p2p_deposit_orders
   SET status = 'PENDING', claimed_at = NULL
 WHERE status = 'MATCHING'
   AND claimed_at IS NOT NULL
   AND claimed_at &lt; NOW() - INTERVAL 5 MINUTE
```

> `<` 는 MyBatis `<script>` 내부에서 XML 파싱된다. `&lt;` 또는 `<![CDATA[ ]]>` 를 쓸 것 (2026-06-11 기동 실패 장애).

---

## 6. 이벤트 전이 = 웹훅

전이 지점이 워커 한 곳으로 모이므로 **발송 누락이 생길 자리가 없다.** 이것이 분리의 부수 이득이다.

> ⚠️ **아래 3이벤트 안은 폐기됐다 (2026-08-16). 확정안은 이벤트 하나다.**
>
> ```
> P2P_ORDER_COMPLETE   주문 종결 시 1회 · 정산분이 1원이라도 있을 때만
>   result   FULL     전액 확정
>            PARTIAL  일부만 확정되고 종결
> ```
>
> **파트너가 알아야 하는 것은 "결국 얼마를 받았나" 하나다.** 중간 과정(레그별 정산)은 우리 사정이고,
> 파트너는 그 콜백으로 충전·결제를 완료한다.
>
> 정상 거래는 이것이 곧 즉시다 — 전액 정산되면 그 순간 주문이 `COMPLETED` 로 닫힌다. 부분으로
> 끝나는 경우만 만료까지 기다렸다가 `PARTIAL` 로 나간다.
>
> **정산 0건 종결(`EXPIRED`·`CANCELLED`)은 보내지 않는다.** 보낼 게 없는 이벤트를 만들지 않는다.
> 실측으로 최근 30일 `EXPIRED` 16 · `CANCELLED` 20 이 여기 해당한다.
>
> 구현 `d1e4a7f` · `settledAmount`(net USDT) · `orderAmount`(KRW) · 멱등키 `evt_p2po_{orderCode}_{result}`

<details><summary>폐기된 3이벤트 안 (참고)</summary>

| 전이 | 웹훅 | 파트너 조치 |
|---|---|---|
| → `MATCHED` | `P2P_ORDER_MATCHED` | 입금 안내 |
| 정산분 증가 | `P2P_ORDER_SETTLED` | 크레딧 (누적값 동봉) |
| → 종결 | `P2P_ORDER_CLOSED` | `result` 로 확정 |

`P2P_ORDER_CLOSED.result` = `COMPLETED` / `PARTIAL` / `EXPIRED` / `CANCELLED`

</details>

### 6.1 왜 레그 단위가 아닌가

현재는 레그마다 `deposits` 행이 생기고 `DEPOSIT_CONFIRMED` 가 그만큼 나간다. 실측 1,205건 중 6건이 웹훅 2~3회를 받았고, **파트너는 언제 끝인지 알 방법이 없다.**

`DEPOSIT_CONFIRMED` 는 파트너에게 세 가지를 약속하는데 P2P 는 셋 다 못 지킨다.

| 약속 | P2P 실제 |
|---|---|
| 1 트랜잭션 = 1 웹훅 | 레그 수만큼 |
| `transactionHash` 로 체인 대사 | `INNER_ps_...` 합성값 |
| 온체인 확정 | 원화 이체 + 내부 정산 |

**지금이 무손실 전환 시점이다.** 실 파트너 중 P2P 입금 웹훅을 받는 곳이 0곳이다(파트너 1 = test, 파트너 36 = webhook_url 미설정). 반면 P2P 활성 + 웹훅 보유 파트너가 **22곳** 대기 중이다.

### 6.2 부분 정산 통지

레그 하나가 분쟁 중이어도 정산된 레그의 USDT 는 **이미 파트너 원장에 크레딧돼 있다.** 종결까지 기다리면 "원장엔 있는데 웹훅은 안 온" 구간이 생긴다. `P2P_ORDER_SETTLED` 를 정산분 변화마다 보내 그 구간을 없앤다.

---

## 7. 위젯

동기일 때는 요청 한 번에 입금 계좌가 나왔다. 비동기면 `MATCHING` 구간을 거친다.

폴링 엔드포인트는 이미 있다 — `GET /p2p/match/{orderCode}`.

**권고: 짧은 동기 대기 후 폴링 전환.** 생성 응답에서 최대 2초까지 `MATCHED` 를 기다리고, 그 안에 끝나면 지금과 동일한 경험을 준다. 초과하면 "매칭 중" 화면으로 넘어가 폴링한다. 대부분의 매칭이 2초 안에 끝나므로 체감 변화가 거의 없고, TORQ 가 느린 날에만 대기 화면이 뜬다.

> 이 항목은 **미확정**이다. 전면 비동기(항상 대기 화면)로 갈지 결정 필요.

---

## 8. 전환

기본값이 동기 유지가 되도록 스위치를 둔다.

```
p2p.async_matching_enabled = false   (기본)
```

`true` 로 바꾸면 주문 생성이 `PENDING` 으로 끝나고 워커가 집는다. 롤백은 값만 되돌리면 된다 — 단, 전환 중 생성된 `PENDING` 주문은 워커가 마저 처리해야 하므로 **워커를 먼저 내리지 말 것.**

순서:

```
① DDL 적용 + 백필
② matching-worker 배포 (스위치 false — 유휴)
③ 스위치 true
④ PENDING/MATCHING 적체 감시 (Vigil)
⑤ 이상 시 스위치 false
```

---

## 9. 감시

| 지표 | 임계 |
|---|---|
| `PENDING` 적체 건수 | 10건 이상 지속 |
| `MATCHING` 체류 시간 | 1분 초과 |
| `match_attempt_count >= 임계` 건수 | 1건이라도 |
| 워커 프로세스 | Vigil agent |

---

## 10. 함께 처리할 정합성 결함

이번 점검에서 확인된 것 중 이 작업과 같은 파일을 건드리는 것들.

| 결함 | 조치 |
|---|---|
| `p2p_lock_history` LOCK 87건 전량 `reference_id` NULL | 잔액 증가는 임계구역 유지, **이력 INSERT 를 `createMatch` 이후로**. `lockForMatch` 가 `lockedAfter` 를 반환하도록 시그니처 변경. 호출부 5곳(`:291` `:337` `:421` 최초, `:1529` `:1645` 재잠금 — 재잠금은 이미 matchId 보유) |
| PARTNER 레그 계좌 미기록 (`.get(0)` 재해석) | `p2p_matches.bank_account_id` 신설 — 매칭 시점 고정 |
| TORQ 이중 링크 (매칭 855 실증) | `torq_trades` 를 정본으로 확정, `p2p_matches.torq_escrow_id` 는 표시용 사본으로 격하 |
| 정산 → 거래 3홉 | `p2p_settlements.deposit_order_id` 신설 |
| `partner_reference` 유일성 없음 | `UNIQUE (partner_id, partner_reference)` — NULL 다중 허용이라 기존 1,310건 무영향. 종결 주문의 키 재사용 정책 결정 필요 |

---

## 11. 범위 밖

- **구매자 신원 정본 부재** — 105명이 1,316번 주문했는데 이름·전화·계좌가 주문마다 전량 복사. 판매자는 `p2p_members` + `bank_accounts` 로 정본이 있는데 구매자는 없다. 식별 기준(`kyc_uid`? `partner_user_id`?)부터 정해야 해서 별건
- 계좌번호 평문 저장
- TORQ `createTrade` 를 트랜잭션 **앞으로** 빼기 — escrow 고아 처리가 붙는다. 워커 분리만으로 사용자 경로에서는 빠지므로 2단계로 둔다
- `findByTxHash` 다건화 (`torq_trades.tx_hash` 는 LP 배치 전송 시 정당하게 중복 가능하므로 UNIQUE 를 걸지 않는다)
