# 매칭 윈도우 라우터 — 설계

2026-09-19. **구현 전에 이 문서로 합의한다.**

---

## 0. 왜 바꾸나

지금 창은 **세 구간의 합**이고 순서가 코드에 박혀 있다.

```
window = p2pPhase + lpPhase + vaLandingPhase        ← 고정 합
vaLandingStartsAt = windowStart + p2pPhase + lpPhase ← VA 는 <항상 꼬리>
```

VA 에 **금액 조건**(10만~200만)이 생기면서 이 구조가 안 맞는다. 조건에 맞으면 VA 가 LP 보다
**먼저**여야 하고, 파트너가 그 윈도우를 끄면 **다음으로 넘어가야** 한다.

☠️ 더 근본적으로는, **합으로 창을 정하는 것 자체**가 문제다. 윈도우마다 성격이 다른데
(P2P 는 상대를 찾고, VA 는 페이지를 넘기고, PARTNER 는 즉시 성립) 하나의 마감으로 묶으면
어느 쪽도 자기 길이를 못 가진다.

**윈도우는 자기 길이와 자기 조건을 갖고, 전환은 라우터가 한다.**

---

## 1. 윈도우 목록

| # | 윈도우 | 길이 | 진입 조건 |
|---|---|---:|---|
| 1 | **P2P** | 5분 | `p2p_enabled` |
| 2 | **VA** | 2분 | `vap_enabled` ∧ **`va-min-krw` ≤ 금액 ≤ `va-max-krw`** ∧ **레그 없음** |
| 3 | **LP** | 2분 | `torq_enabled` |
| 4 | **PARTNER** | 1분 | `usePartner` (= `krw_enabled` ∧ 파트너 수취계좌 보유) |

**순서는 이 표 그대로**다. VA 가 LP 보다 앞이다 — 조건에 맞으면 VA 를 먼저 쓴다.

### ☠️ 「부분 레그가 있으면 LP 만」은 <b>별도 규칙이 아니다</b>

VA 조건의 <b>「레그 없음」</b>에서 자동으로 나온다. P2P 가 일부라도 잡았으면 VA 는 후보에서
빠지고 라우터가 LP 로 간다. 규칙을 두 곳에 적지 않는다.

### PARTNER 는 구간이 아니라 <b>즉시 성립</b>이다

진입하는 순간 매칭이 끝난다(파트너가 자기 계좌로 받는다). 1분은 「기다리는 시간」이 아니라
<b>진입을 표현하는 최소 길이</b>다.

> 종전에는 `markLpNoPool` 에서 「LP 가 풀 없음이라 답했을 때만」 `PARTNER_CHECK` 로 갔다.
> 윈도우가 되면 <b>LP 구간이 그냥 끝나도</b> 간다 — 의미가 한 번 넓어지고, 그것이 <b>의도</b>다.
> 「활성화 즉시 매칭 완료」이므로 이 윈도우에 닿는다는 것 자체가 성립을 뜻한다.

---

## 2. 라우터

```
윈도우 진입
   └ 활성인가?
        ├ 아니오 → 다음 윈도우 라우팅   (구간을 소비하지 않는다)
        └ 예    → 그 윈도우의 길이만큼 진행
                    ├ 성립        → 번들 확정
                    └ 미성립·미달 → 다음 윈도우 라우팅

다음 윈도우 = 표 순서로 <남은 것 중> 첫 번째 조건 만족
   없으면  → 매칭 실패 → [다시 시도] · [취소]
```

☠️ **비활성 윈도우는 시간을 먹지 않는다.** 진입 즉시 다음으로 넘어간다 — 그래야
「P2P 를 끈 파트너가 5분을 버리는」 일이 없다.

---

## 3. 두 시계 — <b>역할이 완전히 갈린다</b>

| | 도달하면 | 누가 보나 | 언제 움직이나 |
|---|---|---|---|
| **`windowEndsAt`** | **라우팅** (세션을 죽이지 않는다) | **구매자** — 「이번 매칭 02:00」 | 윈도우 전환마다 |
| **`deadlineAt`** | **세션 종결** (좀비 방지) | 아무도. 순수 안전망 | 생성·재시도 때만 |

### ☠️ `deadlineAt` 을 상수로 두지 않는다

```
deadlineAt = windowStart + (이 세션이 탈 수 있는 윈도우들의 합) + 여유
```

상수(예: 10분)로 두면 <b>마지막 윈도우가 잘린다</b> — 2분짜리 LP 가 30초만 돌고 죽는다.
구매자에겐 「01:30 남음」이라 해놓고서다. 금액과 파트너 설정이 **생성 시점에 확정**되므로
그 합도 그때 계산된다.

> 설정 실수 방어가 필요하면 `session-hard-cap` 을 따로 둔다 — 「합이 아무리 커도 이건 못 넘는다」.

### ☠️ `deadlineAt` 은 <b>매칭 구간만</b> 묶는다

레그가 확정되면(`BUNDLE_READY`·`PAYMENT_IN_PROGRESS`) 세션은 매칭을 떠났고, 그 뒤로는
**각자의 시계**가 맡는다 — VA 세션 TTL 30분, P2P 이체 기한 등. 여기서 자르면
<b>인증 중인 구매자를 끊는다</b>. 2026-09-19 에 착수 기한 5분으로 정확히 그 사고가 났다.

### 재시도는 <b>리셋</b>이다

「재시도 = 매칭의 시작으로」이므로 체인을 처음부터 다시 탄다. `windowEndsAt` 도 `deadlineAt` 도
**지금 기준으로 새로** 계산한다(현행 `reopenWindow` 와 같은 규약).

> **무한 재시도는 허용한다** — 사람이 매번 눌러야 생기는 일이고, 사용자의 의사다.

---

## 4. ☠️ 이 설계가 <b>기존 금지를 뒤집는다</b>

`RoutingPolicy` 에 이렇게 적혀 있다:

> 미리 `sessionTtl` 에 들어가야 한다 — 나중에 `deadlineAt` 을 늘리면 **구매자에게 보이는
> 마감이 움직인다**. 이 코드베이스가 일관되게 금지하는 것이다.

**그 금지의 이유는 살리고, 대상만 바꾼다.** 구매자에게 보이는 것은 이제 `windowEndsAt` 이고,
그것은 **윈도우가 바뀔 때만** 바뀐다 — 그때는 **화면도 함께 바뀌므로**(매칭 중 → 랜딩)
구매자가 전환을 인지한다. 조용히 늘어나는 것이 아니다.

`deadlineAt` 은 생성·재시도 때만 정해지고 **화면에 나오지 않는다**.

> 위젯 문구가 이미 「**이번** 매칭 03:47」이다. 「이번」이라 말하면서 총합을 세고 있었으니,
> 윈도우별 마감이 오히려 그 문구에 맞는다.

---

## 5. 영향 받는 곳

`expiresAt` 을 「창의 끝」으로 읽던 모든 자리가 **「현재 윈도우의 끝」**으로 의미가 바뀐다.

| 위치 | 바뀐 뒤 |
|---|---|
| `MatchingRoutingWorkHandler` 창 닫힘 판정 | 현재 윈도우 종료 → **라우팅** |
| `P2pAnchorLeasePolicy` 리스 상한 | 현재 윈도우 끝으로 클램프 |
| `BundleConfirmationService.SESSION_EXPIRED` | 〃 |
| **`OverdueSessionExpirySweeper`** | ☠️ **`deadlineAt`** 을 봐야 한다 — 윈도우가 계속 늘면 좀비가 산다 |
| 위젯 `deadlineAt` 필드 | **이번 윈도우** 마감 |
| `RoutingPolicy.sessionTtl()` | 폐기 — 윈도우 목록이 대신한다 |
| `VaRoutingRule.landingOpen` | 「현재 윈도우 == VA」로 단순해진다 |

### 구형 `policy_json` 호환 — <b>버린다</b> (2026-09-19 결정)

매칭이 아직 실사용 중이 아니다. 호환 생성자·환산 규칙을 두지 않고 **새 모델만** 남긴다.

☠️ 그래서 `RoutingPolicy` 의 옛 필드(`p2pPhase`·`lpPhase`·`vaLandingPhase`·`sessionTtl`)와
호환 생성자를 **지운다**. 남겨 두면 「어느 쪽이 진짜인가」가 생기고, 그것이 이 리팩터링이
없애려는 바로 그 문제다.

> 배포 전에 남아 있는 세션은 종결시킨다. 구형 `policy_json` 행이 새 코드에서 기본 윈도우
> 목록으로 읽히더라도 그것에 기대지 않는다.

---

## 6. 설정

```yaml
matching:
  policy:
    windows:                    # 순서가 곧 라우팅 순서
      - { type: P2P,     seconds: 300 }
      - { type: VA,      seconds: 120, minKrw: 100000, maxKrw: 2000000 }
      - { type: LP,      seconds: 120 }
      - { type: PARTNER, seconds: 60 }
    deadline-grace-seconds: 60
```

금액 범위는 **설정**이다(요구사항). 지금 값은 VAP 의 1회 한도(200만)와 같아 **항상 1회 입금**이
되지만, 상한을 올리면 분할이 다시 생긴다 — 위젯의 분할 진행률 표시는 **그대로 둔다**.

---

## 7. 단계 — ☠️ <b>한 번에 갈아엎지 않는다</b>

규모를 재보니 `new RoutingPolicy(` <b>43곳</b>, 구간 필드 참조 <b>85곳</b>, 엔진 테스트
<b>48개</b>다. 한 번에 바꾸면 컴파일만 맞추다 끝나고 <b>검증이 흐려진다</b>.

### 요령 — 윈도우 목록을 <b>파생값으로 먼저</b> 넣는다

`RoutingPolicy` 의 필드를 지우는 대신, 목록을 계산해 주는 메서드를 먼저 붙인다:

```java
public List<MatchWindow> windows() {
    // 당분간은 기존 필드에서 만든다:
    //   P2P(p2pPhase) · VA(vaLandingPhase) · LP(lpPhase) · PARTNER(고정)
}
```

이러면 **기존 43곳을 하나도 안 건드리고** 라우터·두 시계·테스트를 전부 세울 수 있다.
마지막에 필드를 목록으로 교체할 때는 <b>생성자만</b> 고치면 된다.

| 단계 | 내용 | 건드리는 곳 |
|---|---|---|
| **1** | `windows()` 파생 + 라우터 + 테스트 | **추가만** |
| **2** | 두 시계 분리 (`windowEndsAt` / `deadlineAt`) | `MatchSession` |
| **3** | 라우터 핸들러가 윈도우 전환을 하게 | `MatchingRoutingWorkHandler` |
| **4** | 스위퍼 · 위젯 | 각 1곳 |
| **5** | 필드 → 목록 교체, 옛 것 제거 | 43곳 (기계적) |

☠️ **각 단계가 끝날 때마다 전체 테스트가 초록**이어야 한다. 중간에 멈춰도 운영에 낼 수 있게.
특히 2단계는 `expiresAt` 을 읽는 <b>11곳</b>의 의미를 바꾸므로 <b>그것만 따로</b> 통과시킨다.

### 이미 만들어 둔 것 (2026-09-19)

- `domain/MatchWindowType.java` — P2P · VA · LP · PARTNER
- `domain/MatchWindow.java` — 종류 · 길이 · 금액 범위 + `acceptsAmount`
- `domain/MatchWindowRouter.java` — `first` · `next` · `enterable` · `reachableLength`

**1단계 완료** (main). `RoutingPolicy.windows()` 파생 + `MatchWindowRouter` + 테스트.

### 2단계 — 두 시계 분리 (2026-09-19)

DDL 은 **개발 적용·검증 완료**. 운영은 코드 검증 뒤.

```sql
ALTER TABLE matching_sessions
  ADD COLUMN deadline_at datetime(6) NULL AFTER expires_at,
  ADD KEY idx_matching_sessions_status_deadline (status, deadline_at);
UPDATE matching_sessions SET deadline_at = expires_at WHERE deadline_at IS NULL;
```

`v2-docs/migrations/2026-09-19-matching-session-deadline.sql`

**어느 쪽을 새로 냈나** — `expires_at` 의 의미를 「현재 윈도우의 끝」으로 **좁히고**, 안전망을
새 컬럼으로 뺐다. 읽는 자리를 가려보니 대부분이 「창의 끝」이라(리스 상한·SESSION_EXPIRED·
LP 예약 만료·구매자 화면) 반대로 하면 10곳을 손대야 한다. 이쪽은 스위퍼 1곳뿐이다.

| 바꾼 것 | 내용 |
|---|---|
| `MatchSession` | 기존 `deadlineAt` → `windowEndsAt` 개명. 안전망 `deadlineAt` 신설 + `deadlineAt()` |
| 〃 | 생성·`reopenWindow` 에서 둘 다 잡는다 |
| `rehydrate` | 안전망 복원 오버로드 (`persistedDeadlineAt`) |
| `JdbcMatchSessionStore` | insert·select·update 에 `deadline_at` |
| `MatchingQueueSchemaCheck` | 탐침에 `deadline_at` 추가 — 없으면 세션이 **죽지 않는다** |
| H2 스키마 | `deadline_at TIMESTAMP NULL` |

#### ☠️ 여기서 구멍이 하나 나왔다

`sessionSpan` 을 `reachableLength + 여유` 로만 두면 **안전망이 창보다 짧아지는 조합**이 있다:

`useLp`·`useVap` 이 **둘 다 false** 면 `effectiveLpPhase()` 는 「꼬리는 마지막 공급원이
소유한다」는 규칙 때문에 `lpPhase` 를 그대로 돌려주는데, `reachableLength` 는
`enterable(useLp=false)` 에서 LP 를 뺀다. 그러면 창이 열려 있는 세션을 스위퍼가 죽인다 —
구매자 화면엔 「01:30 남음」인데 거래가 사라진다.

**둘 중 긴 쪽**을 쓴다. 5단계에서 필드가 목록으로 바뀌면 `sessionTtl` 이 사라지고
`reachableLength` 만 남는다. `MatchSessionDeadlineTest` 가 8개 설정 조합으로 이 불변식을
잡는다(보정을 빼면 그 조합이 실패하는 것을 확인했다).

**스위퍼는 건드리지 않았다** — 창 닫힘 스캔은 라우팅 트리거이지 안전망이 아니다. 4단계다.

---

## 8. 두지 않기로 한 것

- **`session-hard-cap`** — 두지 않는다(2026-09-19 결정). 설정 실수를 막자고 시계를 하나 더
  두면, 나중에 「왜 여기서 끊겼는지」를 찾을 곳이 하나 더 늘어난다. **혼란이 방어보다 크다.**
  `deadlineAt` 이 윈도우 합에서 파생되므로, 설정이 이상하면 그 합이 곧 드러난다.
- **구형 `policy_json` 호환** — §5 참조


---

## 3단계 완료 (2026-09-19)

DDL 은 개발 적용·검증 완료(`deadline_at`, `current_window`). **운영 미적용** — 배포 직전에 올린다.

### 들어간 것

| 대상 | 내용 |
|---|---|
| `MatchSession.currentWindow` | 행에 있다. **파생 불가** — VA 는 `legCount == 0` 일 때만 진입하므로 「+6분이 VA 였나 LP 였나」를 현재 상태로 복원할 수 없다 |
| `enterFirstWindow` / `advanceWindow` | 첫 윈도우 진입, 전환. 새 윈도우는 **지금부터** 제 길이를 받는다 |
| `reopenWindow` | `currentWindow` 를 비우고 체인을 처음부터 |
| `inP2pWindow()` · `inVaWindow()` | 정본. `VaRoutingRule.landingOpen` 과 `attachVaReservation` 이 **같은 근거**를 쓴다 |
| `closeOrAdvanceWindow` | 전환 판정 **한 곳**. 스위퍼발 `WINDOW_CLOSE` 와 `route` 가 함께 탄다 |
| 영속 | insert·select·update + `rehydrate` 오버로드 2개 |

☠️ **스위퍼발 `WINDOW_CLOSE` 는 `route()` 를 타지 않는다.** 거기에도 같은 판정을 두지 않으면
P2P 가 끝나는 순간 스위퍼가 세션을 결정 대기로 밀어 VA·LP·PARTNER 가 한 번도 안 돈다.

### ☠️ 창이 아니라 <b>세션</b>으로 옮긴 것 — 네 곳

레그는 윈도우 전환을 넘어 **살아남는다**(그래서 VA 가 후보에서 빠진다). 그 레그를 붙드는
것들을 창에 묶으면 전환 직후 전부 터진다.

| 위치 | 창으로 두면 |
|---|---|
| `P2pAnchorLeasePolicy` | 창 끝에 앵커를 **놓아버린다** — 그 클래스 주석이 스스로 금지한 「3분 기다려 잡은 P2P 를 한 번에 잃는」 결과. Core 갱신 CAS 가 `lease_expires_at > NOW(6)` 라 **되돌릴 수 없다** |
| `MatchSession` 리스 검증 | 부착·갱신 상한 — 전환 직후 확정이 `LEASE_EXPIRED` |
| `BundleConfirmationService` | 전액 덮은 번들이 경계 하나로 확정 불가 |
| `requireActive` | 경계 직후 도착한 부착이 터진다 |

원래 코드는 `expiresAt()` 이 **곧 세션 TTL 이던 시절**에 쓰였다 — 창을 윈도우로 쪼개면서
뜻이 조용히 좁아진 것이지 설계 의도가 아니다. §5 의 「현재 윈도우 끝으로 클램프」는 **오판**이라
정정한다.

### 테스트 — 62건에서 0 으로

**기대값만 고치지 않았다.** 그러면 「전환이 실제로 일어나는가」를 아무도 안 보게 된다.

- **큐 펌프**(`route`) — 경계에 닿은 패스는 **전환만 하고 끝낸다**. 실제 공급자 호출은 그다음
  패스다. 운영에서는 큐가 그 바퀴를 돌리지만 테스트는 핸들러를 직접 부른다.
  ☠️ 신호는 **「창 마감이 움직였는가」**다. 「MATCHING 이고 창이 열려 있는가」로 보면 전환이
  없는 평범한 `RetryAfter` 도 참이라 패스를 한 번 더 돌려 **재고를 두 번 잡는다**.
- **따라잡기**(`catchUpWindows`) — `start()` 가 세션을 `now - p2pPhase` 에 만드는 관용구는
  정확히 전환 지점이다. ☠️ **저장하지 않는다** — `save` 하면 리비전과 `saves()` 가 올라가
  낙관적 잠금을 보는 테스트가 터진다. 인메모리 스토어는 참조를 들고 있다.
- **진짜 의미 변화** — 「VA 는 꼬리」를 전제한 테스트들을 새 순서(VA 가 LP 앞)로 다시 썼고,
  「VA 가 끝났다고 창을 닫으면 LP 를 한 번도 안 돈다」를 단언으로 못박았다.
- **상수 제거** — 「5분 세션」 기대값을 세션이 아는 값으로. 세션의 끝은 윈도우 합에서 나오므로
  설정이 바뀌면 함께 움직인다.

### 남은 단계

- **4단계** 스위퍼가 `deadline_at` 을 보게 + 위젯
- **5단계** `RoutingPolicy` 필드 → 목록 교체, `sessionTtl` 제거
