# P2P 매칭 대기 룰 (정본)

**작성** 2026-09-02 · **대상** `core` / `open-api` / `matching-worker` / `widget-ui`

> 이 문서가 **매칭 대기(②구간)의 룰 정본**이다. 규칙이 흩어져 있어 만든다 —
> `P2P_MATCH_WAIT_GUIDE.md`(구현 지침, 2026-08-20), `P2P_TIME_MODEL.md`(전체 시간 모델),
> 그리고 코드 주석 세 곳에 나뉘어 있었고, 그 뒤 들어온 **선점(Reservation)** 과 **부분 성립**이
> 대기와 어떻게 맞물리는지는 어디에도 적혀 있지 않았다.
>
> | 문서 | 역할 | 이 문서와의 관계 |
> |---|---|---|
> | `P2P_MATCH_WAIT_GUIDE.md` | 대기 기능 **구현 지침**(당시 작업 지시서) | 이 문서가 결과를 정본화. 지침의 미래형 서술은 이 문서가 대체 |
> | `P2P_TIME_MODEL.md` | ①~⑤ **전체 시간 모델** | 상위 문서. 이 문서는 그중 ②만 상세화 |
> | `P2P_RESERVATION_GUIDE.md` | 선점 설계 | §4 에서 대기와의 관계만 다룸 |
> | `P2P_PARTIAL_FILL_GUIDE.md` | 부분 성립(현재 전 파트너 OFF) | §4 에서 대기와의 관계만 다룸 |

---

## 0. 한 줄 요약

파트너가 링크·위젯에 **대기 시간**을 걸면, 그 시간 동안 잔여를 LP(TORQ/BARO)·파트너 레그로
메우지 않고 **P2P 매칭만 5초마다 재시도**한다. 대기가 없으면(기본값) 동작은 대기 도입 전과 같다.

**대기의 목적은 LP 비용(약 2%)을 아끼는 것이다.** 따라서 그 값어치가 있을 때 — 즉
**전액을 P2P 로 채울 수 있을 때만** 적용된다(§2.3).

---

## 1. 대기가 걸리는 조건

### 1.1 값의 출처 — 3곳, 우선순위는 「링크 > 위젯 요청 파라미터」

| 진입 경로 | 링크 컬럼 | 우선순위 해석 지점 |
|---|---|---|
| 결제링크 위젯 매칭 | `payment_links.match_wait_seconds` | `P2pWidgetController.resolveMatchWaitSeconds` |
| 통합 매칭 링크 | `matching_links.match_wait_seconds` | `MatchingLinkService.useLink` |
| P2P 입금 링크 | `p2p_deposit_links.match_wait_seconds` | `P2pDepositLinkService.useLink` |

세 곳 모두 같은 식이다:

```
waitSeconds = link.matchWaitSeconds != null ? link.matchWaitSeconds : request.matchWaitSeconds
```

**링크 값이 있으면 요청 파라미터는 무시한다.** 파트너가 링크에 건 정책을 클라이언트가 덮을 수
없어야 하기 때문이다. 링크 조회에 실패하면 대기 옵션 문제일 뿐이므로 요청 파라미터로 폴백한다
(매칭 자체를 막지 않는다).

> ⚠️ **여기서는 출처만 고른다.** 상한 클램프와 워커 연동 잠금은
> `P2pMatchingService.resolveMatchWaitUntil` **한 곳이 소유**한다. 산식을 복제하지 마라.

### 1.2 만료 시각 산정 — 주문 생성 시 1회

`P2pMatchingService.resolveMatchWaitUntil(Integer waitSeconds)`

```
waitSeconds 가 null · 0 · 음수                 → null (대기 없음)
P2P_ASYNC_MATCHING_ENABLED = false            → null + WARN   ← 연동 잠금
p2p.match_wait_max_seconds <= 0               → null          ← 설정으로 기능 OFF
그 외 → match_wait_until = now + min(waitSeconds, p2p.match_wait_max_seconds)
```

산정은 **주문 생성 시 딱 1회**이며 이후 갱신하지 않는다(`p2p_deposit_orders.match_wait_until`).

#### ☠️ 연동 잠금 — 워커가 꺼져 있으면 대기를 걸지 않는다

대기 재시도는 `matching-worker` 루프만 수행한다. 워커가 꺼져 있는데 `match_wait_until` 을 세우면
PENDING 으로 돌아간 주문을 **아무도 집지 않아** 구매자가 주문 만료까지 "매칭 대기" 화면에 묶인다.

**주문이 갇히는 것보다 대기가 무시되는 편이 낫다.**

#### ⚠️ 상한 폴백값은 파트너 콘솔 프리셋과 함께 움직인다

코드 폴백 `1500`(25분)은 파트너 콘솔 프리셋 최대(25분)에 맞춘 값이다. 낮추면 로컬·스테이징에서
25분 프리셋이 조용히 잘려 운영과 다르게 동작한다.

### 1.3 파트너 콘솔 프리셋

링크 생성 폼: **없음(기본) · 5 · 10 · 15 · 20 · 25분** (전송값 초 300/600/900/1200/1500).
'없음'이면 `matchWaitSeconds` 필드를 **보내지 않는다**.

서버는 프리셋 집합을 강제하지 않는다 — 클램프(`min(요청, 상한)`)만 건다.

---

## 2. 대기 중 무슨 일이 일어나는가

### 2.1 주문은 PENDING 으로 태어나 워커가 집는다

비동기 매칭이 켜져 있으면(`P2P_ASYNC_MATCHING_ENABLED=true`) `P2pDepositService.createAndMatch`
는 매칭을 걸지 않고 `PENDING` 으로 반환한다. 이후는 `matching-worker` 의 루프다:

```
P2pMatchingWorker.poll()                 @Scheduled(fixedDelay = poll-interval-ms = 5000)
  tx A  claimPendingOrders(batchSize)    PENDING → MATCHING 선점 (FOR UPDATE SKIP LOCKED)
   B    runMatchAttempt(order)           tryMatchDeposit — 트랜잭션 없음(Phase 1/2/3 이 각자 소유)
  tx C  finishAttempt(orderId, max)      결과 판정 + 카운터
```

**새 워커·새 스케줄러를 만들지 마라.** 대기는 이 루프에 얹혀 있다.

### 2.2 게이트 위치 — LP 레그를 만들기 **직전**

`P2pMatchingService.tryMatchDepositUnifiedPhase1`, P2P 레그 루프와 더스트 분기 **뒤**,
`planTorqLeg` **앞**이다.

```
P2P 레그 루프 (최대 p2p.max_p2p_legs)
  ↓ need = remaining - covered
(a) covered > 0 && need <= 주문액 × dust_adjust_rate%   → 주문 하향 후 성립       [대기 무관]
(b) covered > 0 && need <= dust_floor_krw               → P2P 레그 전량 폐기 + 전액 LP [대기 무관]
  ↓
★ 대기 게이트   covered == 0 && isWaitingForMatch(order)
  ↓
planTorqLeg → (불가) fillRemainderWithPartner → (불가) failUnifiedOrder
```

게이트가 발동하면:

```
① LP 레그를 만들지 않는다            (planTorqLeg 호출 자체를 하지 않는다)
② 파트너 레그도 만들지 않는다
③ failUnifiedOrder 를 부르지 않는다
④ 확보한 P2P 레그는 그대로 둔다      (대기 중 유동성 스래싱 방지)
⑤ 재개 중 남은 escrow 미확정 LP 예약 레그는 정리한다 (고아 방지)
⑥ 주문을 PENDING 으로 되돌린다       → 다음 폴에서 워커가 다시 집는다
```

#### ☠️ MATCHING 이 아니라 PENDING 이다

`claimPendingOrders` 는 **PENDING 만** 집는다. `MATCHING` 으로 남기면 좀비 회수 잡이 돌 때까지
(`zombie-timeout-minutes`, 기본 5분) 아무도 재시도하지 않는다.

### 2.3 ☠️ 대기는 P2P 가 하나도 안 붙었을 때만 적용한다 (`covered == 0`)

2026-08-21 오너 판정. 초안(무조건 대기)을 뒤집은 규칙이다.

> "p2p 가 1개라도 매칭 되었고 그 금액이 충분하지 않다면 BARO 가 바로 같이 매칭돼야지."

근거:
- P2P 가 이미 일부 붙었으면 **나머지가 더 올 보장이 없다.** 기다려 봐야 구매자만 묶인다
- 구매자가 첫 이체를 이미 한 뒤 한참 있다가 두 번째 카드가 튀어나온다 —
  **부분 송금 상태로 대기하는 것이 가장 나쁘다**
- 대기의 목적은 LP 비용 절약인데, 그 값어치는 **전액을 P2P 로 채울 수 있을 때만** 있다

**예외 하나** — `covered > 0` 이라 게이트를 통과했는데 LP·파트너가 **모두 불가**한 경우에는
실패시키지 않고 PENDING 으로 되돌린다. 그러지 않으면 **대기를 건 주문이 대기를 안 건 주문보다
빨리 죽는다.** 확보한 P2P 레그는 롤백하지 않는다.

### 2.4 ☠️ 대기 중에는 재시도 카운터를 올리지 않는다

`P2pAsyncMatchingService.finishAttempt`:

```java
boolean waiting = matchingService.isWaitingForMatch(order);
int attempts = waiting ? current : current + 1;   // 대기 중에는 동결
if (!waiting && attempts >= maxAttempts) { closeDepositOrder(CANCELLED, MATCH_FAILED); }
```

대기 중 폴링은 **실패가 아니다.** 올리면 대기 사이클당 2씩(게이트 release + tx C) 증가해
만료 시점에 재시도 예산이 0 이 되고, LP 가 한 번만 흔들려도 즉시 CANCELLED 된다.

반환도 전용 메서드를 쓴다:

| 경로 | 메서드 | 카운터 |
|---|---|---|
| 대기 중 반환 | `releaseWaitingDepositOrderToPending` | **미증가** |
| 일반 미체결 반환 | `releaseDepositOrderToPending` | +1 |

> ⚠️ `releaseDepositOrderToPending` 의 계약(+1)은 건드리지 마라. 대기가 아닌 미체결 경로들이
> 그 증가에 의존한다 — 임계 종결이 무한 루프를 막는 **유일한** 장치다.

결과: 대기가 아무리 길어도 **만료 시점 카운터는 대기 진입 전 값 그대로**다.

---

## 3. 대기가 끝나는 경로 — 4가지

| # | 경로 | 결과 |
|---|---|---|
| ① | 대기 중 P2P 로 **전액** 충족 | `need <= 0` → `markOrderMatched` → **MATCHED**. 대기 잔여 시간은 버린다 |
| ② | 대기 중 P2P 가 **일부** 붙음 | 게이트를 통과하지 못함(`covered > 0`) → 즉시 LP·파트너로 잔여를 메움 |
| ③ | `match_wait_until` 경과 | `isWaitingForMatch = false` → 기존 캐스케이드 복귀(LP → 파트너 → `failUnifiedOrder`) |
| ④ | 구매자 취소 | `BUYER_CANCEL`. 취소 버튼은 **배너**가 소유한다(§6) |

만료 후 LP·파트너가 모두 불가하면 `failUnifiedOrder` → `CANCELLED / UNFILLED_NO_LIQUIDITY`.
LP 가 일시 실패를 반복하면 재시도 예산 소진 → `CANCELLED / MATCH_FAILED`.

**만료 감지 지연은 최대 1 폴(5초)이다.** `match_wait_until` 을 보고 동작하는 스케줄러 잡은
**없다** — 워커가 다음 폴에서 `isWaitingForMatch` 로 자연히 알아챈다.
(`P2pDepositLinkExpiryJob` 이 `match_wait_until` 을 언급하지만 javadoc 설명일 뿐이고,
그 잡이 실제로 읽는 것은 `expires_at` 이다.)

---

## 4. 대기를 **타지 않는** 경로

여기 해당하면 `match_wait_until` 이 있어도 대기가 동작하지 않는다. 룰을 바꿀 때 반드시 함께 본다.

| 경로 | 이유 |
|---|---|
| **선점(Reservation) 주문** | `tryMatchDeposit` 이 `reservedWithdrawOrderId != null` 이면 `matchReserved` 로 **캐스케이드 진입 전에 return** 한다. 대기 게이트는 통합 캐스케이드 안에 있으므로 도달하지 않는다. 선점은 판매자를 이미 확보했으므로 기다릴 이유가 없다 — 의도된 동작이다 |
| **워커 OFF** | `resolveMatchWaitUntil` 이 연동 잠금으로 `match_wait_until = null` 을 준다(§1.2) |
| **`covered > 0`** | §2.3 |
| **더스트 분기 (a)(b)** | 게이트보다 **앞**에 있다. 두 분기에 걸리면 대기 판정 전에 종결된다 |
| **레거시 매칭 경로** | `p2p.unified_matching_enabled` 가 OFF 인 파트너. 대기 게이트는 통합 경로에만 있다 |

> ⚠️ **부분 성립(`tryPartialFill`)은 현재 전 파트너 OFF** 이며, 켜면 대기와 충돌한다.
> 부분 성립은 "풀에 보이는 것이 곧 재고"라고 가정하는데 선점은 그 가정을 최대 30분 위반한다.
> 켜기 전 해결 과제는 `P2P_PARTIAL_FILL_GUIDE.md` 최상단 참조.

---

## 5. 시간 모델 안에서의 위치

`P2P_TIME_MODEL.md` 의 ②구간이 이 문서의 대상이다. **구간마다 자기 시계를 갖고, 앞 구간이
길어져도 뒤 구간이 깎이지 않는다.**

```
① 링크 유효   매칭링크 60분 / P2P입금링크 30분     생성 → 사용
② 매칭 대기   match_wait_until (없으면 즉시)      주문 생성 → 매칭 성립     ← 이 문서
③ 이체        p2p.transfer_deadline_minutes       매칭 성립 → "입금완료" 클릭
④ 입금 확인   p2p.confirm_deadline_minutes        입금완료 → 확인 완료 (초과 시 분쟁)
⑤ 분쟁        마감 없음
```

### 5.1 주문 만료 산정 — 대기는 이체 시간을 잠식하지 않는다

`P2pDepositService.createAndMatch`:

```java
LocalDateTime matchDoneBase = matchWaitUntil != null ? matchWaitUntil : LocalDateTime.now();
LocalDateTime expiresAt = matchDoneBase.plusMinutes(getTransferDeadlineMinutes());
```

레그(`p2p_matches.expires_at`)는 **주문의 `expires_at` 을 그대로 상속**한다(`legExpiresAt`).
레그가 각자 `now + 마감` 을 쓰면 대기가 걸린 주문에서 레그가 주문보다 오래 살고, 같은 주문의
레그끼리도 생성 시각만큼 카운트다운이 어긋난다.

#### ⚠️ 기준은 실제 성립 시각이 아니라 **대기 만료 예정 시각**이다

25분 대기를 건 링크는 유동성이 있어 5분 만에 성립해도 이체 창이 45분이 된다.
**구매자가 더 기다리는 것이 아니다** — 계좌는 성립 즉시 나오고, 마감만 넉넉해진다.

의도한 선택이다:
- 구매자에게 보이는 마감이 도중에 **바뀌지 않는다**(줄어드는 마감이 가장 나쁘다)
- 레그가 주문을 상속하므로 카드마다 다른 시계가 생기지 않는다

감수하는 비용: 방치된 주문이 잡아둔 출금 유동성이 그만큼 늦게 풀린다. → **미결 C**(§9)

### 5.2 착수 기한 가드는 대기와 무관하다

`p2p.start_guard_enabled` 가 ON 이면, **첫 레그 생성 시각(`MIN(p2p_matches.created_at)`) +
`p2p.start_deadline_minutes`** 안에 착수(`transfer_started_at`)하지 않은 주문을 만료시킨다.

기준이 **매칭 성립 시각**이므로 대기 길이의 영향을 받지 않는다. 따라서 §5.1 의 "늘어난 마감"에
실제로 노출되는 구간은 **착수는 했는데 이체를 끝내지 않은** 경우뿐이다.

---

## 6. 위젯 표시 규칙

### 6.1 응답 필드

```
matchWaitUntil             대기 만료 시각 (대기 없으면 null)
matchWaitRemainingSeconds  잔여 초 (대기 없거나 끝났으면 null)
```

세 위젯 경로가 모두 싣는다 — `P2pWidgetController` · `MatchingLinkWidgetController` ·
`P2pDepositLinkWidgetController`.

### 6.2 ☠️ 잔여초 계산은 `P2pWidgetMatchResponse.computeMatchWaitRemainingSeconds` 한 곳이 소유한다

`matchWaitUntil` **이 유일한 근거**다. 예전엔 배너와 폴링 응답이 각자 계산했고 한쪽만 폴백을 갖고
있어서, `match_wait_until = NULL` 인 주문이 **배너엔 "약 22분 남음", 도착한 대기 화면엔
카운트다운 없음**이 됐다.

> ☠️ **폴백에 `p2p.match_wait_max_seconds` 를 빌려 쓰지 마라.** `match_wait_until` 이 NULL 인
> 주문은 **대기 옵션이 없는 주문**이라 그 상한과 아무 관계가 없다.
> (이 이유로 `getMatchWaitMaxSeconds()` 는 2026-08-29 제거됐다. 되살리지 마라.)

### 6.3 화면

| 상태 | 표시 |
|---|---|
| 대기 중 | "매칭 대기 중 · mm:ss 남음" + 금액 + 결말 예고. **하단 버튼 없음** |
| 성립 | 기존 매칭 화면 |
| 만료 후 LP | 기존 LP 레그 화면 |

- 남은 시간은 **서버가 준 잔여초**로 재동기화한다. 위젯 자체 타이머로 세지 마라(탭 백그라운드·시계 오차)
- 만료 시각이 지났는데도 대기 응답이 오면 계속 폴링한다 — 다음 사이클에 붙는다
- ☠️ **대기 화면에 취소 버튼을 두지 않는다** — 매칭 워커와 경합하는 창이 된다.
  대신 "결제수단 변경"(deposit 경유) 또는 "닫기"(링크 진입) 링크를 준다
- ☠️ **취소의 소유자는 `deposit.vue` 배너다.** 대기 상한이 25분이고 그동안 신규 주문이 409 로
  전부 막히므로, 배너의 취소 버튼이 사라지면 사용자는 최대 25분간 갇힌다

---

## 7. 운영 설정 현황 (2026-09-02 확인)

### 7.1 `system_settings` 에 **있는** 값

| 키 | 운영값 | 의미 |
|---|---|---|
| `p2p.match_wait_max_seconds` | **1500** | 대기 상한(25분). 0 이하면 기능 OFF |
| `p2p.max_p2p_legs` | **2** | P2P 분할 레그 한도 |
| `p2p.transfer_deadline_minutes` | **15** | ③ 이체 |
| `p2p.confirm_deadline_minutes` | **10** | ④ 입금 확인 → 분쟁 |
| `p2p.start_deadline_minutes` | **10** | 착수 기한 |
| `p2p.start_guard_enabled` | **true** | 착수 기한 가드 |
| `p2p.trading_auto_pause_minutes` | **30** | 판매자 매칭 노출 시간 |
| `p2p.unified_matching_enabled` | **true** | 통합 캐스케이드(대기 게이트가 여기 있다) |
| `p2p.torq_fallback_enabled` | **true** | |
| `p2p.partner_fallback_enabled` | **true** | |

> ⚠️ **`trading_auto_pause_minutes`(30) 는 대기 상한(25분)보다 커야 한다.**
> 작으면 구매자가 기다리는 동안 판매자가 먼저 후보에서 빠져, 대기 후반부에 매칭 가능한
> 판매자가 구조적으로 줄어든다. 2026-08-31 에 10분이던 시절 실제로 발생했다 —
> 같은 그룹에 5,230,000원이 있는데 판매자가 자동 중지되어 구매자가 UNFILLED 로 죽었다.
> **둘 중 하나를 바꾸면 반드시 다른 쪽을 함께 본다.**

### 7.2 ☠️ `system_settings` 에 **없는** 값 — 코드 기본값으로 돈다

| 키 | 코드 기본값 | 소유 |
|---|---|---|
| `p2p.dust_floor_krw` | **10,000** | `P2pMatchingService.getDustFloorKrw` |
| `p2p.min_amount` | **10,000** | `P2pMatchingService.getMinAmount` |
| `p2p.dust_adjust_rate` | **1(%)** | `P2pMatchingService.getDustAdjustRate` |
| `p2p.dust_leftover_guard` | **false** | 2026-08-21 폐기. 되돌리기 전 근거 재확인 |
| `p2p.partial_fill_partner_ids` | **빈 값 = 전 파트너 OFF** | 켜기 전 `P2P_PARTIAL_FILL_GUIDE.md` 최상단 필독 |

**행이 없다는 것은 "미설정"이 아니라 "코드 기본값 적용"이다.** 대기 룰을 계산할 때 이 값들을
DB 에서만 확인하면 틀린다.

### 7.3 워커 설정

| 항목 | 값 | 출처 |
|---|---|---|
| `P2P_ASYNC_MATCHING_ENABLED` | **true** | `/opt/cryptoments/config/.env.matching-worker` (open-api 에도 동일) |
| `p2p.async-matching.poll-interval-ms` | **5000** | `matching-worker/application.yml` |
| `p2p.async-matching.max-attempts` | **24** | ⚠️ env `P2P_ASYNC_MATCHING_MAX_ATTEMPTS=24` 가 yml 기본값 5 를 **덮는다**(Spring 완화 바인딩) |
| `p2p.async-matching.batch-size` | 10 | yml |
| `p2p.async-matching.zombie-timeout-minutes` | 5 | yml |

> ⚠️ **재시도 예산 = `max-attempts × poll-interval` ≈ 24 × 5초 ≈ 2분.**
> yml 만 보고 "5회"로 판단하면 틀린다.

---

## 8. 관측 — 지금 볼 수 있는 것

전용 화면·지표가 없다(**미결 G**). 아래 SQL 이 사실상 유일한 관측 수단이다.

```sql
-- 대기 사용량과 결말
SELECT DATE(created_at) d,
       COUNT(*)                                   AS 전체,
       SUM(match_wait_until IS NOT NULL)          AS 대기건,
       SUM(match_wait_until IS NOT NULL
           AND close_reason = 'ALL_SETTLED')      AS 대기_성립,
       SUM(close_reason = 'UNFILLED_NO_LIQUIDITY') AS 유동성부족,
       SUM(close_reason = 'MATCH_FAILED')          AS 재시도소진
FROM p2p_deposit_orders
WHERE created_at >= CURDATE() - INTERVAL 7 DAY
GROUP BY 1 ORDER BY 1 DESC;

-- 대기 주문이 실제로 무엇으로 채워졌는가
SELECT IFNULL(d.close_reason,'(진행중)') cr, COUNT(*) c,
       ROUND(AVG(TIMESTAMPDIFF(SECOND, d.created_at, d.match_wait_until))) 대기초,
       (SELECT GROUP_CONCAT(DISTINCT m.leg_type) FROM p2p_matches m
         WHERE m.deposit_order_id = d.id AND m.status NOT IN ('CANCELLED','FAILED')) legs
FROM p2p_deposit_orders d
WHERE d.match_wait_until IS NOT NULL AND d.created_at >= CURDATE() - INTERVAL 7 DAY
GROUP BY 1, legs;
```

**실측 (2026-09-02)** — 전체 77건 중 대기 사용 23건(30%), 그중 **22건 ALL_SETTLED · 1건
BUYER_CANCEL**, 평균 대기 313초. `UNFILLED_NO_LIQUIDITY` 0건.

참고로 2026-08-31 에는 대기 24건 중 15건이 UNFILLED 로 죽었다. 차이는 대기 룰이 아니라
**공급**이었다 — 그날 그룹 51 의 출금 공급은 3건/585만원, 수요는 91건/2,808만원이었다.
**대기 룰의 성능을 판단할 때는 반드시 같은 기간의 공급량을 함께 본다.**

---

## 9. 미결 항목

| # | 항목 | 내용 |
|---|---|---|
| **C** | 이체 창 부풀림 | `expires_at` 이 **대기 만료 예정 시각** 기준이라, 일찍 성립해도 마감이 안 당겨진다(§5.1). 구매자 손해는 없고 **출금 유동성이 늦게 풀린다**. 착수 가드가 미착수 건을 먼저 자르므로 노출 구간은 좁다 |
| **D** | 구매자 재진입 차단 | 대기 중에는 활성 거래 1건 불변식 때문에 신규 주문이 409. 최대 25분간 배너 취소 버튼이 유일한 출구 |
| **E** | 규칙이 두 갈래 | `covered == 0` 이면 대기, `covered > 0` 이면 즉시 LP. 그런데 LP·파트너 모두 불가하면 `covered > 0` 도 결국 대기로 흐른다(§2.3 예외). 같은 "대기"가 두 경로에서 다른 이유로 발생한다 |
| **F** | 출처 3곳, 실사용 1곳 | 운영에서 `match_wait_seconds` 가 설정된 링크는 `payment_links` 뿐이다(`matching_links` · `p2p_deposit_links` 는 0건). 코드 경로 3개 유지 비용 |
| **G** | 관측 수단 없음 | 대기 성공률·평균 대기·만료 후 결말 분포를 볼 화면이 없다. §8 SQL 이 전부다. **대기 시간을 5분으로 할지 25분으로 할지 판단할 근거가 계속 없다** |

### 별건 — 대기와 무관하나 대기의 결말에 영향을 주는 것

`TorqClient.getQuote` 는 LP 에러 응답 body 를 파싱하지 않고 `TorqApiException(message, cause)`
(= `torqCode == null`)로 던진다. `isRetryableLpFailure` 는 `torqCode == null` 을 **통신 계열로
보고 재시도 대상**으로 분류하므로, LP 의 영구 업무 에러도 재시도 예산 24회를 태운다.

```
실측(2026-09-02): 700만원 주문 2건이
  409 code=405 "호스트 거래 한도를 벗어난 금액입니다"
      Amount 7000000 is out of host range [10000, 5000000] for host #2
를 24회 연속 받고 MATCH_FAILED. 5초 뒤에도 상한을 넘으므로 재시도는 무의미했다.
```

`createTrade` 는 같은 상황에서 body 를 파싱해 `torqCode` 를 싣는다 — **`getQuote` 만 빠져 있다.**
LP 호스트 한도(`[10000, 5000000]`)를 `lp_providers` 에 두면 `planTorqLeg` 단계(외부 호출 전)에서
걸러낼 수 있다. **이 문서의 범위 밖이며 별도 작업으로 다룬다.**

---

## 10. 룰을 바꿀 때 반드시 함께 볼 곳

```
core/.../p2p/P2pMatchingService.java
    resolveMatchWaitUntil        대기 만료 산정 · 클램프 · 연동 잠금   (단일 소유)
    isWaitingForMatch            대기 중 판정                        (단일 소유)
    tryMatchDepositUnifiedPhase1 대기 게이트 (covered == 0)
    tryMatchDeposit              선점 우회 분기 (matchReserved)
    legExpiresAt                 레그 만료 = 주문 상속
core/.../p2p/P2pAsyncMatchingService.java
    finishAttempt                카운터 동결 · 임계 종결 스킵
core/.../p2p/P2pDepositService.java
    createAndMatch               expires_at = (matchWaitUntil ?? now) + 이체 마감
common/.../mapper/P2pMatchingMapper.java
    claimPendingOrdersForUpdate  PENDING 만 집는다
    releaseWaitingDepositOrderToPending   카운터 미증가 (대기 전용)
    releaseDepositOrderToPending          카운터 +1     (계약 변경 금지)
matching-worker/src/main/resources/application.yml   poll-interval-ms · max-attempts
open-api/.../widget/P2pWidgetController.java          resolveMatchWaitSeconds (출처 우선순위)
open-api/.../dto/p2p/P2pWidgetMatchResponse.java      computeMatchWaitRemainingSeconds (단일 소유)
core/.../p2p/MatchingLinkService.java                 useLink — 링크 > 요청
core/.../p2p/P2pDepositLinkService.java               useLink — 링크 > 요청
widget-ui/src/views/p2p.vue                           대기 화면
widget-ui/src/views/deposit.vue                       배너 = 취소 소유자
```

### 코딩 규칙 (이 영역에 손댈 때)

- 대기 옵션이 없을 때 **기존 경로가 한 줄도 달라지지 않아야 한다** — 회귀가 최대 위험이다
- 새 워커·새 스케줄러·새 테이블 금지. 기존 루프에 얹는다
- `tryMatchDeposit` 의 Phase 경계(트랜잭션)를 바꾸지 마라. 외부 HTTP 를 잠금 안으로 끌어들이면 안 된다
- MyBatis `<script>` 안에 `<` `<=` `<>` 금지 — SAXParseException 으로 전 서비스 다운
- `IXRepository.modify()` 는 non-null 필드만 갱신 — NULL 로 지우려면 전용 `@Update`
