# 매칭 윈도우 라우터 — 운영 검증 케이스

2026-09-19. **배포된 것을 하나씩 확인하고, 통과한 것은 통과로 못박는다.**

이 문서는 `MATCHING_WINDOW_ROUTER_DESIGN.md` 2·3단계와 그 코드 리뷰 수정이 **운영에서 실제로 도는지**를 본다. 단위 테스트는 1532건 초록이다 — 여기서 보는 것은 그것으로 못 보는 것들이다: 영속, 스위퍼, 큐 한 바퀴, 구매자 화면.

---

## 0. 지금 환경

파트너 `P000001`(id=1) 기준. **라우팅이 보는 값은 `p2p_enabled` 가 아니라 `p2p_matching_enabled` 다** — 컬럼이 셋이라 헷갈린다.

| 레그 | 게이트 | 현재 | 막는 것 |
|---|---|---|---|
| P2P | `krw_enabled && p2p_matching_enabled` | ❌ | `p2p_matching_enabled = 0` |
| VA | `krw_enabled && vap_enabled && 금액 ≥ va-min-krw(1만)` | ✅ | |
| LP | `krw_enabled && torq_enabled` | ✅ | `lp_provider = BARO` |
| PARTNER | `krw_enabled && 파트너 수취계좌 존재` | ❌ | `bank_accounts` 0건 |

그래서 **현재 체인은 `VA → LP` 두 칸**이다.

### 공통 조회

```sql
-- 세션 상태 한 줄
SELECT session_id, status, current_window, expires_at, deadline_at,
       JSON_EXTRACT(policy_json,'$.useP2p')  p2p, JSON_EXTRACT(policy_json,'$.useVap') vap,
       JSON_EXTRACT(policy_json,'$.useLp')   lp,  JSON_EXTRACT(policy_json,'$.usePartner') partner
  FROM matching_sessions ORDER BY created_at DESC LIMIT 1\G

-- 전환 로그
sudo journalctl -u cryptoments-matching-engine --since "-10min" -o cat | grep -a "윈도우 전환"
```

---

## 1. 지금 설정으로 되는 것

### ✅ T1 — VA 가 첫 윈도우로 잡힌다 · 두 시계가 갈린다  **(2026-09-19 통과)**

주문 `pdo_7ea3a80414b7`, 세션 `39cebb4d`.

| 확인 | 결과 |
|---|---|
| `current_window` 가 행에 기록 | `VA` |
| `expires_at` = 시작 + VA 2분 | 23:49:07 → 23:51:07 |
| `deadline_at` > `expires_at` | 23:58:07 (**7분 뒤**) |
| 비활성 윈도우는 시간을 안 먹음 | `useP2p=false` → P2P 건너뜀 |
| 지연이 창 경계에서 잘림 | `VA_LANDING_OPEN` 이 창 끝까지 |
| 기동 점검 | `스키마 확인 완료 (9건)` |

> ☠️ **VAP 「그만두기」 건(K-6)이 고쳐지기 전까지는 「VA 진입까지」를 통과로 본다.**
> 그 뒤 화면은 VAP 이 종료 신호를 안 주는 탓이라 이 배포의 판정 대상이 아니다.

### ✅ T2·T3·T5 — 윈도우 전환 · 창 닫힘 · 안전망  **(2026-09-20 통과)**

주문 `pdo_08333746f288`(6만원), 세션 `04834822`.

```
01:58:07  세션 생성      current_window=VA,  창 02:00:07,  legs=NULL
02:00:07  윈도우 전환    VA → LP,            창 02:02:07   ← window-switch-LP
02:02:07  갈 곳 없음     창 닫힘 → 결정 대기, 유예 02:07:07
02:07:07  유예 경과      AUTO_CLOSED / 주문 EXPIRED
```

| 확인 | 결과 |
|---|---|
| **T2** 전환 | `VA` → `LP` ✅ |
| 새 창 길이 | **정확히 2분** — 남은 시간을 물려받지 않음 ✅ |
| `deadline_at` | 전환 중 불변, 창 닫힐 때만 유예를 덮도록 갱신 ✅ |
| 레그 | 보존(없음) ✅ |
| **T3** 창 닫힘 | 갈 곳 없을 때만 닫힘 → 결정 대기 ✅ |
| **T5** dedupe 키 | **`window-switch-LP`** — 윈도우별 ✅ |

☠️ **T5 가 특히 중요하다.** 키가 세션당 하나였으면 첫 전환이 안전망을 먹고, 이후 세션이
예약을 쥔 채 영구히 남았다. 리뷰가 찾은 것이 운영에서 실증됐다.

#### ☠️ 그 과정에서 드러난 것 — 화면이 전환을 몰랐다

서버는 02:00:07 에 LP 로 갔는데 화면은 「안타깝지만 지금은 매칭 가능한 대상이 없어요」에
멈춰 있었다. 구매자가 보기엔 끝났는데 **서버는 2분 더 찾고 있었다.** 거기서 「다시 시도」를
누르면 **돌고 있는 LP 탐색을 버리고** 체인을 처음부터 돌린다.

원인은 `vaIssueState` 가 **순수 클라이언트 값**이라 서버가 뭘 하든 되돌아오지 않는 것이었다.
서버 신호(`engineWindowOpen`)를 함께 보게 고쳤다 — 발급 실패는 **VA 레그의 사실**이지
매칭의 결말이 아니다.

---

### (옛) T2 절차 — 관찰 방법 기록

> ☠️ **2만원으로는 관찰할 수 없다** (2026-09-20 두 번 확인). 랜딩 진입 = 발급 = 레그 부착이고,
> VA 는 단독 레그라 그 레그가 **전액을 덮는다**. 잔여가 0 이 되면 세션은 곧장 확정으로 가고
> 창이 끝날 일이 없다. 주문 생성 → 부착까지 **4~5초**라 손으로 그 틈을 피할 수도 없다.
>
> **250만원으로 한다.** `va-max-krw` 가 아직 코드에 없어 VA 윈도우에는 들어가고, 발급은
> VAP 1회 한도(200만)에서 실패한다 → **레그가 안 붙은 채 2분** → LP 전환.
> 부수 효과로 「VA 상한이 아직 없다」는 것도 함께 확인된다(5단계 항목).

- **준비**: **250만원** 주문, VA 랜딩까지 진입 (발급 실패 화면이 뜬다)
- **행위**: **아무것도 누르지 않고 2분** 둔다
- **기대**
  - `current_window` 가 `VA` → `LP`
  - `expires_at` 이 **전환 시점 + LP 2분** (남은 시간을 물려받지 않는다)
  - `deadline_at` 은 **그대로**
  - 로그에 `[MatchingRouting] 윈도우 전환: … → LP`
  - 큐에 `window-switch-LP` dedupe 키로 `START` 1건

### ☐ T3 — 갈 곳이 없으면 그때 창이 닫힌다

- **준비**: T2 에 이어 LP 윈도우도 흘려보낸다 (LP 재고 없음 전제)
- **기대**
  - 세션 `AWAITING_USER_DECISION`
  - `expires_at` 이 **결정 유예(5분)** 로 바뀐다
  - 구매자 화면: 「상대를 찾지 못했어요 / 다시 시도하기 · 결제 취소하기」

### ☐ T4 — 재시도가 체인을 처음부터 돌린다

- **준비**: T3 상태에서 **「다시 시도하기」**
- **기대**
  - `current_window` 가 **다시 첫 윈도우**(`VA`)
  - `expires_at` = 누른 시각 + VA 2분
  - `deadline_at` 도 **지금 기준으로 다시** 잡힌다 (이전보다 뒤)
  - ☠️ `current_window` 를 안 비우면 끝난 윈도우 다음부터 이어져 VA 를 건너뛴다

### ☐ T5 — 스위퍼가 미는 전환 · 안전망이 소모되지 않는다

라우팅 행이 조용해진 세션에서는 **스위퍼**가 윈도우를 넘긴다. dedupe 키를 윈도우별로 바꾼 수정이 여기서 검증된다.

- **준비**: LP 가 영구 실패(재고 없음)해 라우팅 행이 `Completed` 로 끝난 세션
- **기대**
  - `webhook`… 아니라 `matching_queue` 에 `session-window-closeVA` · `session-window-closeLP` 가 **각각** 생긴다
  - ☠️ 키가 하나뿐이면 첫 전환이 안전망을 소모하고, 그 뒤 세션이 예약을 쥔 채 영구히 남으며 60초마다 ERROR 가 찍힌다
- **조회**
  ```sql
  SELECT event_type, dedupe_key, status FROM matching_queue
   WHERE session_id = ? ORDER BY id;
  ```

### ✅ T6 — 확정된 번들은 전진하지 않는다  **(2026-09-20 통과)**

주문 `pdo_82e55b923964`, 세션 `ba3a6ba3`.

| 확인 | 결과 |
|---|---|
| 창 마감 경과 | 00:40:39 → 00:41:52 (**+73초**) |
| `current_window` | **`VA` 유지** |
| `expires_at` | 안 밀림 |
| `deadline_at` | 00:47:39 그대로 |

☠️ 가드가 없었으면 `BUNDLE_READY` 가 `Kind.MATCHING_WINDOW_OPEN` 이라 전진해 `expires_at` 을
미래로 밀고, 스위퍼 스캔에서도 빠져 **번들을 쥔 채 영구 정지**했다. 리뷰가 찾아준 자리이고,
앞선 커밋에서 「넣었다」고 보고했다가 **치환 실패로 실제로는 없었던** 가드이기도 하다.

---

## 2. 설정을 바꿔야 되는 것

> 둘 다 운영 변경이라 **명령 원문·영향·롤백을 제시하고 승인**받은 뒤 적용한다.

### ☐ T7 — P2P 윈도우 (`p2p_matching_enabled = 1`)

- **필요**: `UPDATE partners SET p2p_matching_enabled = 1 WHERE id = 1;` + 출금 대기자
- 현재 출금 대기 `PENDING` **1건 / 151,360원** — 2만원 매칭이면 걸린다
- **기대**: 첫 윈도우가 `P2P`, 3분 뒤 `VA`, 그 뒤 `LP`
- **이때 함께 보는 것**
  - ☠️ **레그가 전환을 넘어 살아남는지** — P2P 가 일부만 덮은 뒤 전환하면 그 레그가 유지돼야 하고, 레그가 있으면 **VA 는 후보에서 빠져야** 한다(단독 규칙)
  - ☠️ **앵커 리스가 전환 뒤에도 갱신되는지** — 리뷰에서 고친 자리다. 창으로 잘리면 전환 뒤 첫 갱신이 Core CAS 에 걸려 영구 실패한다

### ☐ T8 — PARTNER 윈도우 (수취계좌 등록)

- **필요**: `bank_accounts` 에 `owner_type='PARTNER', owner_id=1, status='ACTIVE'` 1건
- **기대**: 체인 마지막 칸. **진입 = 즉시 성립**이라 1분은 기다림이 아니라 진입 표현
- ☠️ 종전에는 「LP 가 풀 없음이라 답했을 때만」 갔는데, 이제 **LP 구간이 그냥 끝나도** 간다. 의미가 넓어진 것이 의도다

### ☐ T9 — 전체 체인 `P2P → VA → LP → PARTNER`

T7·T8 이 켜진 뒤. 각 윈도우가 **제 길이를 온전히** 받는지, `deadline_at` 이 체인 전체를 덮는지.

---

## 3. 따로 보는 것

### ☐ T10 — VA 과소입금 표기 (MR !112)

2만원 매칭에 **1만원만** 송금.

- 주문 `PARTIALLY_SETTLED` / `PARTIAL_UNDERPAID` / 잔여 **10,000** / `completed_at` **NULL**
- 결제링크 **`USED`** (☠️ `EXPIRED` 면 파트너가 물리 삭제할 수 있다)
- 구매자 화면에 「**10,000원은 입금되지 않았습니다**」
- 부모창에 `P2P_TOPUP_COMPLETED` 가 `partial: true` 로 간다

### ☐ T11 — 늦은 정산이 닫힌 주문을 바로잡는다 (MR !110, 배포됨)

만료 뒤 입금이 확정되면 주문이 되살아나야 한다. 탐지 쿼리가 **항상 0** 이어야 한다.

```sql
SELECT o.id, o.order_code, o.status FROM p2p_deposit_orders o
 WHERE o.status IN ('EXPIRED','CANCELLED')
   AND EXISTS (SELECT 1 FROM p2p_matches m WHERE m.deposit_order_id=o.id AND m.status='SETTLED');
```

---

### ☐ T12 — 밸런싱 한도로 열리지 않을 주문은 <b>견적에서 막힌다</b>  (2026-09-20 신규)

☠️ **운영에서 실제로 났다.** 한도가 바닥난 파트너의 250만원 주문이 「P2P 고정」으로 통과해
주문까지 만들어졌고, 3초 뒤 엔진이 `BalancingLimitExceededException` 으로 세션을 열지 않았다.
엔진은 그 거절에서 선점을 **일부러** 놓지 않는다(놓으면 5초마다 같은 거절이 반복돼 시도 횟수가
임계에 닿고 고객 주문이 취소된다). 그래서 주문은 `MATCHING` 으로 남고, 구매자는 **15분 동안**
「최적의 거래 상대를 찾고 있어요 / 조금만 더 기다려주세요」를 봤다 — **아무도 찾고 있지 않았다.**

#### 왜 캐스케이드가 못 푸나

| | 뜻 | 막히면 |
|---|---|---|
| supply 부족 | 이 파트너와 매칭될 **상대**가 없다 | 다음 윈도우가 받는다 |
| **밸런싱 한도** | 이 파트너가 **더 받을 수 없다** | **세션 자체**가 안 열린다 — P2P·VA·LP·PARTNER 전부 |

`ErrorCodes.P2P_BALANCING_LIMIT_EXCEEDED` 의 주석이 이미 같은 말을 한다 — 「대기가 아니라
실패다. 여력은 이 파트너가 P2P 출금을 성립시켜야만 돌아온다.」

#### 두 `available` 이 다른 것도 여기서 드러났다

라우팅 로그는 `available=0`, 엔진은 `available=100000` 이었다. **서로 다른 것을 재고 있었다** —
앞은 출금 대기자 합계, 뒤는 밸런싱 여력. 둘 다 맞는 값이고, 매칭은 **둘 다** 넘어야 성립한다.

#### 확인 절차

- **준비**: `p2p_partner_balancing_limits` 의 `deposit_capacity − pending_deposit` 확인 (파트너 1 은 10만원)
- **행위**: 여력을 **넘는** 금액으로 견적 요청
- **기대**
  - 견적이 **409 `1118`** 로 거절된다 — 주문이 **만들어지지 않는다**
  - 구매자 화면에 즉시 사유가 뜬다. 「매칭 중」으로 들어가지 않는다
  - 여력 **이하**면 평소대로 통과 (경계는 **같으면 통과**)
- **조회**
  ```sql
  SELECT deposit_capacity_krw - pending_deposit_krw AS 여력
    FROM p2p_partner_balancing_limits WHERE partner_id = ?;
  ```

#### ☠️ 이것으로 충분하지 않다

한도는 **견적과 세션 개시 사이에도 변한다.** 견적에서 통과해도 엔진이 막을 수 있고, 그때는
여전히 「매칭 중」이 뜬다. 그 틈을 좁히려면 위젯이 **엔진 세션의 유무**를 알아야 하는데,
지금 `P2pActiveOrderResponse` 에는 그 필드가 없다 — **별건으로 남긴다.**

당장은 문구에서 **없는 약속을 지웠다**: 「계좌가 준비되는 대로 자동으로 표시됩니다」 →
「상대를 찾는 대로 안내해 드려요. 시간 안에 못 찾으면 자동으로 취소됩니다」.

---

## 4. 알려진 미해결 — 판정에서 제외

| 건 | 상태 |
|---|---|
| VAP 체크아웃 「그만두기」가 세션을 안 닫음 | **K-6 문의 발송 대기**. 고쳐지기 전까지 T1 은 「VA 진입까지」로 판정 |
| `PROVIDER_UNAVAILABLE` | 원인 확인 중 |
| 확정된 VA 레그에 취소가 오면 VAP 세션이 고아 | **우리 쪽 문제.** 엔진이 「결제 관측 레그는 해제하지 않는다」로 의도적 스킵. TTL 30분으로 풀리지만 별도 수정 필요 |
| 5단계 미구현 | `RoutingPolicy` 의 개별 구간 필드를 윈도우 목록으로 대체 + `sessionTtl` 제거 |

---

## 5. 2026-09-20 T1 실거래에서 드러난 것 — 전부 수정 완료

### ☠️ ① 입금에 <b>성공한</b> 구매자에게 「입금 시간이 지났어요」

| 시각 | 사건 |
|---|---|
| 19:17:45 | VAP 세션 종료 — `closeReason=FUNDED`, 수령 20,000원 |
| 19:17:4x | **구매자 복귀 → 실패 화면** |
| 19:18:07 | 우리 정산 완료 (`ALL_SETTLED`) |

**22초짜리 구멍.** `endedScreen` 이 「취소 사유가 아니면 전부 실패」로 갈랐다. `FUNDED` 는
VAP 계약에서 **「요청액이 채워져 입금 접수가 끝났다」**는 <b>성공</b> 사유다(K-7 회신 §2) —
세션 종결은 **입금 접수**의 끝이지 지급의 끝이 아니다. 그 구분을 크레딧 경로에는 넣고
화면 경로에는 넣지 않은 것이 원인.

**수정**: `FUNDED`·`BUYER_FINALIZED` → 「확인 중」. ☠️ 성공 사유는 **서버 값만** 읽는다 —
`?closed=` 는 브라우저가 나르므로 위조로 **진짜 실패를 성공 화면으로 덮을 수** 있다.

### ☠️ ② 14초 만에 죽은 레그가 창을 15분 잡아먹었다

주문 2931 은 18:48:56 에 `AUTH_FAILED`(본인인증 거절, 수령 0원)로 레그가 죽었는데, 세션은
`PAYMENT_IN_PROGRESS` 에 머물러 **LP·PARTNER 창이 한 번도 돌지 않았다.** 구매자는 실패
화면을 본 채 주문 만료(19:03)까지 기다렸다.

### ☠️ ③ 확정된 세션을 끝내 주는 쪽이 없었다

`PAYMENT_IN_PROGRESS` **13건 전부** 기한을 지났고 그중 **12건은 주문이 이미 종결**. 가장
오래된 것이 **6일 전**. 자금은 새지 않는다(밸런싱 점유는 투영이 제때 푼다) — 새는 것은
**상태**이고, 「지금 결제 중인 세션이 몇 건인가」에 13 이라 답하는 것이 그 비용이다.

**②·③ 의 뿌리는 하나** — 2026-09-10 재설계가 실행 인그레스를 없앤 뒤로 확정 이후 레그
결과가 엔진으로 **돌아오는 경로가 없다.** `BalancingSettlementProjection` 이 그 공백을
밸런싱 점유에 대해서만 메웠고, 세션 상태는 메우지 않았다.

**수정**: `CommittedSessionProjection` — 콜백이 아니라 **읽기 투영**이다(콜백을 되살리면
레거시와 나란히 도는 두 번째 실행 축이 부활한다).

```
주문은 살아 있는데 레그가 전부 돈 없이 죽었다 → WINDOW_ABANDON → 창을 버리고 다음 창으로
주문이 이미 종결됐다                        → LEGACY_CLOSE   → 그 결말을 세션에 투영
```

이것이 4단계(`deadline_at` 집행)를 채운다.

#### 실거래 행으로 검증한 판정

| 주문 | 레그 | 판정 |
|---|---|---|
| 2930 · 2931 | `FAILED` (수령 0) | **버린다** |
| 2913 · 2922 · 2932 | `SETTLED` | **손대지 않는다** |

#### ☠️ 창을 옮기려면 <b>주문도 함께</b> 돌아와야 한다

설계 중 발견한 구멍이다. `claimDepositOrderMatched` 는 `status IN ('PENDING','MATCHING')`
에서만 성립하는데, **VA 레그가 죽어도 주문은 `MATCHED` 로 남는다**
(`finalizeDepositOrderIfAllTerminal` 의 `anyFailed` 분기가 분쟁을 위해 주문을 열어 둔다).

그대로 뒀다면 창 전환이 **고치기 전보다 나빴다**: LP 로 넘어가 상대를 찾고 예약까지 한 뒤
`P2P_DEPOSIT_ORDER_NOT_MATCHABLE` 로 영구 거절 — **공급자 재고를 건드린 뒤에** 실패한다.

**수정**: `VapLegCloser.closeAsFailed` 가 `P2pSettlementService.returnOrderForAnotherWindow`
를 부른다. 이것은 종결이 아니라 **보상**이고(`releaseBundle` 의 ⑥과 같은 연산), 정산이 0 이라
분쟁할 대상 자체가 없다. ☠️ **정산된 레그가 하나라도 있으면 하지 않는다** —
`returnDepositOrderFromMatched` 는 `remaining_amount = krw_amount` 로 되돌리므로 이미 받은
돈이 잔여로 되살아난다.

#### ☠️ 종결 투영은 안전망 경과 후 <b>24시간을 더</b> 기다린다

VAP 의 `DEPOSIT.SETTLED` 는 9회 재시도한다(1분~24시간). 실제로 **7번째 시도에서 11시간
만에** 들어온 건이 있었다. 주문이 `EXPIRED` 로 닫힌 **뒤에** 정산이 도착하면 레거시의 늦은
정산 경로가 주문을 되살린다 — 그 전에 세션을 못박으면 되살아난 주문 옆에 **틀린 세션**이
남는다. 핸들러도 같은 이유로 **실행 직전에 결말을 다시 읽는다**(큐 행이 결말을 나르지 않는다).
