# KRW 매칭 윈도우 정의서

**기준: 2026-09-17 운영 배포본 (matching-engine V2)**

이 문서는 설계안이 아니라 **지금 도는 코드**를 적는다. 값이 코드·설정과 어긋나면 코드가 맞다.
근거 위치를 각 절에 달아 두었으니, 고칠 때 그 자리를 함께 고칠 것.

---

## 0. 한 장 요약

```
                 ┌──────────── 매칭 창 (최대 5분) ────────────┐
주문 생성 ─선점→ │  P2P 구간 3분    │    LP 구간 2분           │ ─창 닫힘→ 결정 유예 5분 ─→ 자동종료
                 └───────────────────────────────────────────┘
                        │                                          │
                   매칭 성립 시 창은 그 즉시 닫힌다                 사용자: 재시도 / 취소 / 닫기
                        ↓
                   번들 확정 → 계좌 노출 → 착수 5분 → 이체 15분
```

| 시계 | 값 | 소유 | 근거 |
|---|---|---|---|
| P2P 구간 | 180초 | 엔진 | `matching.policy.p2p-phase-seconds` |
| LP 구간 | 120초 | 엔진 | `matching.policy.lp-phase-seconds` |
| **매칭 창** | **두 구간의 합** | 엔진 | `RoutingPolicy.sessionTtl()` — 파생값, 저장하지 않는다 |
| 결정 유예 | 300초 | 엔진 | `matching.policy.decision-grace-seconds` |
| 착수 기한 | 5분 | 레거시 | `p2p.start_deadline_minutes` (DB) — **스위치** `p2p.start_guard_enabled` |
| 이체 기한 | 15분 | 레거시 | `p2p.transfer_deadline_minutes` (DB) |
| 입금확인 기한 | 10분 | 레거시 | `p2p.confirm_deadline_minutes` (DB) |

**☠️ 창의 길이는 파트너마다 다르다.** `useP2p` 가 거짓이면 P2P 구간이 `ZERO` 가 되어 창은
**LP 2분**뿐이다(`RoutingPolicy.effectiveP2pPhase()`). 라우터에 「P2P 를 쓰지 않는 파트너」를
가르는 `if` 는 없다 — 같은 코드가 같은 설정에서 다른 창을 만든다.

---

## 1. 창이 열리는 순간

### 1-1. 시작점은 주문이 아니라 **선점**이다

```
p2p_deposit_orders (PENDING)
  └─ 인그레스 폴 (5초 주기, batch 10)        matching.intake.poll-delay-ms
      └─ claimDepositOrder — status PENDING→MATCHING, claimed_at 기록
          └─ 세션 생성 (MATCHING)            started_at = now
              expires_at = now + sessionTtl
```

주문 생성과 창 시작 사이에는 **최대 5초의 폴 간격**이 있다. 사용자가 보는 「매칭 중」은 그
간격을 포함한다.

### 1-2. 경로가 하나도 없으면 세션을 만들지 않는다

`useP2p` 와 `useLp` 가 **둘 다 거짓**이면 `NoRouteAvailableException` 이다. 셋은 전부
파트너 설정에서 유도되고 `krw_enabled` 가 공통 게이트다.

```
useP2p      = krw_enabled AND p2p_matching_enabled
useLp       = krw_enabled AND torq_enabled
usePartner  = krw_enabled AND partner_target_available
```

`usePartner` 는 가드에 넣지 않는다 — PARTNER 경로는 라우터에서 `useLp` 뒤에 있어, `useLp`
없이 단독으로 도달하지 못한다.

### 1-3. 창의 시작은 **움직인다**

`expires_at` 은 `started_at + TTL` 로 **파생되지 않는다.** 재시도가 같은 세션에 새 창을 열기
때문이다(§4-1). 파생값으로 두면 두 번째 창의 기한을 표현할 수 없다. 그래서 이 값은 영속이고
(`matching_sessions.expires_at`), 구간 경계도 거기서 역산한다.

```java
windowStartedAt() = deadlineAt - policy.sessionTtl()
p2pPhaseEndsAt()  = windowStartedAt() + effectiveP2pPhase()
```

---

## 2. 창이 열려 있는 동안

### 2-1. 구간 게이트는 **금액이 아니라 시각**이다

라우팅 패스 한 번의 순서는 이렇다.

```
① 종결·결정대기 세션이면 즉시 반환
② now >= expires_at 이면 창을 닫는다 (§3)
③ 밸런싱 한도 예비 검사 (제공자를 부르기 전)
④ P2P 앵커 리스 갱신          ← 패스 맨 앞이어야 하는 이유는 2-3
⑤ P2P 예약 시도
⑥ 잔여 0 이면 확정 요청 → 창 닫힘
⑦ now < p2pPhaseEndsAt() 이면 여기서 대기   ☠️ 이 줄이 제품이 약속한 P2P 대기시간이다
⑧ LP 예약 시도 (useLp 이고 어댑터가 배선된 경우)
⑨ 그래도 남으면 PARTNER 확인
```

**⑦ 위로 LP 호출을 옮기면 P2P 대기시간이 사라진다.** 코드에 그 경고가 달려 있다.

### 2-2. 창은 **매칭이 되면 그 즉시 닫힌다**

기한까지 기다리지 않는다. 잔여가 0 이 되는 순간 `BUNDLE_READY` 이고, 확정이 큐에 걸린다.
LP 계약에는 부분 체결이 없으므로 `RESERVED` 는 곧 번들 완성이다.

### 2-3. 리스가 창보다 훨씬 짧다

| | 길이 | 갱신 |
|---|---|---|
| P2P 앵커 리스 | 30초 | 라우팅 패스 맨 앞에서 |
| LP 예약 lease | 30초 | **엔진은 갱신하지 않는다** |
| LP 예약 hard | 90초 | 갱신으로도 넘지 못함 |

앵커는 1분에 잡아 5분까지 살아야 하므로, 라우터가 **앵커를 쥔 세션의 재시도 간격을 리스의
절반으로 깎는다**(`holdSafe`). 단 `min-backoff-ms` 하한은 지키고(「retry in 0ms」 스핀 방지),
창이 닫히기 직전이면 그 너머까지 자르지 않는다. 리스를 줄이면 이 상한도 함께 줄어든다.

**LP 는 갱신하지 않아도 된다** — 예약이 서는 시점이 이미 창의 끝이고, `RESERVED` 직후 같은
패스에서 확정이 큐에 걸린다(지연 0). 예약~확정은 통상 1초 미만이다.

### 2-4. 재고가 없을 때

| 제공자 답 | 엔진 | 다음 |
|---|---|---|
| `BUSY` | `LP_QUEUE_WAITING` | 제공자 `retry_after_ms`. **단 `BANK_MAINTENANCE` 만 사유면 5분** |
| `NO_POOL` — 회복 가능 | `MATCHING`/`PARTNER_CHECK` | 백오프 후 재시도 |
| `NO_POOL` — 회복 불가 | 같음 | **이 큐 행만 멈춘다.** 세션은 살아 있고 P2P 재시도는 계속 |

**회복 가능 여부는 `skip_reasons` 로만 갈린다.** 이름(`NO_POOL`)만으로는 「지금 후보 0명」과
「사람이 고쳐야 한다」가 구분되지 않는다.

```
INSUFFICIENT_OR_BUSY · LOCKED_BY_ANOTHER_MATCH   수 초
BANK_MAINTENANCE                                 점검 종료 후(10~30분)
ACCOUNT_CAP_EXHAUSTED · ALL_ACCOUNTS_INACTIVE
NO_BANK_ACCOUNT · NO_ACTIVE_LP                   ✗ 사람이 고쳐야 한다
```

**☠️ 사유를 모르면 회복 가능으로 본다.** `capacity:check` 는 이 값을 주지 않고, 그쪽
`NO_POOL` 은 「지금 후보 0명」일 뿐 회복 불가 판정이 아니다.

---

## 3. 창이 닫히는 세 가지

### 3-1. 매칭 성립 — 정상

`BUNDLE_READY` → 확정 → `PAYMENT_IN_PROGRESS`. 여기서부터 **레거시 레일이 소유**한다(§5).

### 3-2. 기한 경과 — 미매칭

```
now >= expires_at 이고 창이 열려 있다
  → 예약을 전부 돌려준다
  → AWAITING_USER_DECISION      ☠️ 종결이 아니다
  → deadlineAt = now + decisionGrace (5분)
```

**주문은 돌려주지 않는다.** 돌려주면 좀비 회수가 `PENDING` 으로 되돌리고 인그레스가 다시
집어 **사용자가 아무것도 누르지 않았는데 창이 계속 새로 열린다** — 제품에 없는 자동 반복이다.

이 전이의 생산자는 둘이다. 라우팅 패스가 깨어나 직접 보거나, **만료 스윕**(60초 주기,
유예 120초)이 `WINDOW_CLOSE` 를 큐에 넣는다. 스윕이 없으면 재시도 불가로 끝난 큐 행의
세션이 영원히 비종결로 남는다.

### 3-3. 사용자가 끝냄

`CANCEL`(주문까지 취소) / `CLOSE`(세션만). 순서는 **세션 먼저, 주문 나중**이다. 반대로 하면
주문이 취소된 채 세션이 계속 매칭을 시도하는 창이 생긴다.

---

## 4. 창이 닫힌 뒤

### 4-1. 재시도는 **같은 세션에 새 창**을 연다

```
reopenWindow(now):
  조건  AWAITING_USER_DECISION 이고 레그가 하나도 없을 것
  효과  status = MATCHING
        deadlineAt = now + sessionTtl        ← 구간 경계도 함께 움직인다
```

세션을 새로 만들지 않는 이유는 **맥락·노출 이력·결정 로그·주문 소유**가 한곳에 남아야 하기
때문이다. 재시도한 사용자도 온전한 P2P 구간을 받는다.

### 4-2. 아무도 누르지 않으면

유예가 지나면 만료 스윕이 `EXPIRE` 를 넣고 `AUTO_CLOSED` 다. **최후 수단이고, 그 횟수가 곧
줄여야 할 지표다.**

### 4-3. 결정 유예의 바깥 상한

`decision-grace-seconds` 를 아무리 늘려도 **주문의 `expires_at`** 이 지나면 레거시 만료 잡이
주문을 닫는다. 유예 동안 엔진은 아무것도 붙들고 있지 않아 판매자 재고는 묶이지 않는다.

---

## 5. 확정 이후 — 소유가 넘어간다

`PAYMENT_IN_PROGRESS` 부터 **입금확인·이체 기한·정산은 전부 레거시 레일**이 가져간다. 세션은
그 시점부터 기록이다.

```
p2p_matches (CREATED)  ← 엔진이 번들 확정 시점에 레그 수만큼 만든다
torq_trades            ← LP 레그의 미러. 위젯이 계좌를 읽는 곳
```

### 5-1. 확정 이후의 시계

| 구간 | 시작 | 길이 | 초과하면 |
|---|---|---|---|
| 착수 | 계좌 노출 | **5분** | 주문 `CANCELLED`, `close_reason=START_TIMEOUT`, 제공자에 취소 전파 |
| 이체 | 매칭 성립 | 15분 | 레그 취소 |
| 입금확인 | 이체 신고 | 10분 | 분쟁 |

**☠️ 착수 기한에는 스위치가 있다.** `p2p.start_guard_enabled` 가 **코드 기본 OFF** 이고, 그 행이
없으면 이 시계는 아예 돌지 않는다. 구 위젯이 시작 신호를 보내지 않기 때문에 그렇게 만들어졌다
(켜진 채 배포하면 진행 중인 모든 거래가 기한에 죽는다). **운영 실측(2026-09-17): 스위치 `true`,
기한 `5`** — 즉 지금은 실제로 도는 시계다. 코드 폴백은 3분이지만 DB 행이 있으므로 쓰이지 않는다.

> ⚠️ `CRYPTOMENTS_V2_DDL.sql` 의 시드는 `10` 으로 **운영과 어긋나 있다**(2026-09-17 대조). 그 값이
> 적용된 환경은 아래 불변식을 정확히 위반한다 — 새 환경을 세울 때 그대로 들어가지 않도록 시드를
> 운영값으로 맞출 것.

**☠️ 착수 기한이 제공자 escrow 만료(~10분)보다 짧아야 한다.** 같거나 길면 누가 먼저 끝내는지가
매번 달라지고, 제공자가 먼저 끝내면 우리 취소 루프가 레그를 `CREATED` 에서 놓쳐 건너뛴다.
5분은 그 여유를 두고 정한 값이다.

### 5-2. 만료 스윕은 `PAYMENT_IN_PROGRESS` 를 건드리지 않는다

기한을 **정상적으로** 넘긴다 — 확정된 세션의 레그가 이체 중이다. 그래서 스윕의 후보 조건이
「비종결」이 아니라 **「매칭 창이 열림」**이다.

---

## 6. 상태 표

| 상태 | 분류 | 창 | 주문 소유 |
|---|---|---|---|
| `MATCHING` | 창 열림 | ○ | 세션 |
| `LP_QUEUE_WAITING` | 창 열림 | ○ | 세션 |
| `PARTNER_CHECK` | 창 열림 | ○ | 세션 |
| `BUNDLE_READY` | 창 열림 | ○ | 세션 |
| `AWAITING_USER_DECISION` | **결정 대기** | ✗ | **세션** ☠️ |
| `PAYMENT_IN_PROGRESS` | 확정됨 | ✗ | 레거시 |
| `COMPLETED` `PARTIALLY_COMPLETED` `CANCELLED` `CLOSED_BY_USER` `AUTO_CLOSED` `EXPIRED` `MATCHING_FAILED` `EXCEPTION` `DISPUTED` | 종결 | ✗ | 놓음 |

**☠️ `AWAITING_USER_DECISION` 은 종결이 아니다.** 창은 닫혔어도 주문은 그 세션의 것이고,
그래야 좀비 회수가 건드리지 않아 자동 반복이 생기지 않는다.

**☠️ 종결 목록은 SQL 과 같은 집합이어야 한다** — `P2pEngineIntakeMapper.TERMINAL_SESSION_STATUSES`.
한쪽만 늘면 소유자가 둘이 되거나 주문이 영원히 잠긴다. `MatchSessionStatusSqlContractTest` 가
두 목록을 대조한다.

---

## 7. `expires_at` 한 컬럼이 두 가지를 뜻한다

| 상태 | `expires_at` 의 의미 |
|---|---|
| 창이 열림 | **창이 닫히는** 시각 |
| 결정 대기 | **유예가 끝나는** 시각 |
| 그 외 | 의미 없음 — 아무도 보지 않는다 |

**상태를 함께 보지 않으면 틀린다.** 창이 열린 세션의 `expires_at` 을 결정 마감으로 읽어
카운트다운을 그리면 사용자에게 「결정하라」고 5분 일찍 말하게 된다.

---

## 8. 바꿀 때 함께 봐야 하는 것

| 바꾸는 값 | 함께 봐야 하는 것 |
|---|---|
| `p2p-phase-seconds` | 앵커 리스 갱신 간격(`holdSafe`) — 구간이 길수록 갱신 횟수가 는다 |
| `lp-phase-seconds` | LP 미배선 배포의 창 길이. 없애면 창이 조용히 3분으로 줄어든다 |
| `decision-grace-seconds` | 주문 `expires_at` — 그것이 바깥 상한이다 |
| `reservation-lease-seconds` | 앵커가 창 끝까지 살 수 있는가 |
| `p2p.start_deadline_minutes` | **제공자 escrow 만료**(~10분)보다 짧은가 (§5-1) |
| `p2p.transfer_deadline_minutes` | 레그 만료가 이 값을 상속한다 |
| 만료 스윕 `grace-seconds` | 살아 있는 라우팅 행의 최대 지연(LP 백오프 60s + 클레임 리스 20s + 회수 주기 10s = 90s) |

---

## 9. 이 문서가 다루지 않는 것

- **가격·수수료** — 창과 무관하다. LP 레그의 USDT 는 제공자 확정 스냅샷이 정본이고
  시스템 시세로 근사하지 않는다.
- **제공자 API 규격** — `CRYPTOMENTS_V2_RESERVATION_SPEC.md` 와 제공자 회신에 있다.
- **분쟁·정산** — 확정 이후 레거시 레일의 영역이다.
