# 매칭 엔진 V2 재설계안 — 레거시 실행·정산 축으로 복귀

작성 2026-09-10 · 기준 `main` `cc833390` · **제안서(미승인)**

## 요약

엔진은 **매칭 플로우를 엔진화**하려던 것이지 매칭 전체를 뒤집으려던 게 아니었다.
그런데 "V2 는 `P2pMatch` 를 만들지 않는다"는 전제 때문에 **매칭·확인·정산 축이 통째로 복제**됐고,
미구현 3건은 전부 "레거시에 이미 있는 것을 V2 축에 다시 만드는 일"이 됐다.

**제안: 엔진이 번들 확정 시점에 `p2p_matches` 를 레그 수만큼 원자적으로 생성한다.**
그러면 확인·기한·정산·알림이 전부 기존 레일을 탄다.

---

## 0. 엔진의 모델 — 이 문서의 다른 모든 절보다 우선한다 (2026-09-11 확정)

아래 절들에는 이 모델을 오해한 채 쓰인 정정이 둘 있다(정정 ③, 그리고 정정 ④의 결론 일부).
그 절들은 **이 §0 에 종속된다.** 충돌하면 §0 이 맞다.

### 엔진이 존재하는 이유

레거시 매칭 워커는 **대기가 끝나면 주문을 자동으로 취소·실패 처리한다.** 그 UX 를 없애는 것이
엔진 구현의 출발점이다. 다른 모든 설계 선택은 여기서 나온다.

### 택시 호출 비유 — 이것이 정본이다

| 택시 앱 | 이 시스템 |
|---|---|
| 호출 | 입금 주문(`p2p_deposit_orders`) — **실패해도 죽지 않는다** |
| 배차 시도 | 매칭 세션 — 한 번의 시도, 5분 창 |
| 배차 성공 → 즉시 이용 | 번들 확정 → 레거시 `p2p_matches` → 이체 |
| 실패 → 「재시도」 버튼 | 사용자가 눌러 **새 세션** |

주문은 호출이고 세션은 **한 번의 배차 시도**다. 시도가 실패해도 호출은 살아 있고,
다시 부를지는 **사용자가 정한다.**

### 매칭 창 = 시도하는 시간

```
5분 창 = P2P 3분 → LP 2분
P2P 미사용 파트너(파트너 설정) = LP 2분만
```

대기하며 노는 시간이 아니라 **그동안 매칭을 시도하는** 시간이다.
창이 닫혔는데 안 됐으면 그 시도가 실패한 것이고, 주문은 그대로 살아 있다.

### ☠️ 엔진 모델에서 무효인 것

| 레거시 개념 | 엔진에서 |
|---|---|
| `p2p.match_wait_max_seconds` (`match_wait_until`) | **무효.** 창이 그 역할을 대신한다 |
| `finishAttempt` 의 시도 횟수 임계 → `CANCELLED` | **무효.** 주문당 시도는 창 하나이고, 자동 취소는 없애려는 대상이다 |
| 시스템 자동 재시도 | **없다.** 재시도는 사용자 행위다 |

`finishAttempt` 를 세션 종료 경로에서 부르면 **엔진이 없애려던 자동 취소를 되살린다**
(`P2pAsyncMatchingService:184` — `attempts >= maxAttempts` → `CANCELLED`).

### 구현됨 (2026-09-11)

| 무엇 | 어디에 |
|---|---|
| 구간 창 설정 | `matching.policy.p2p-phase-seconds: 180` + `lp-phase-seconds: 120`. **`session-ttl-seconds` 는 없어졌다** — 창은 두 구간의 합으로 파생된다(`RoutingPolicy.sessionTtl()`) |
| 시간 게이트 | `MatchingRoutingWorkHandler.route()` — `now < session.p2pPhaseEndsAt()` 이면 LP 를 부르지 않고 P2P 재시도로 되돌아간다 |
| P2P 미사용 파트너 | 분기가 아니라 설정에서 나온다. `useP2p=false` → `RoutingPolicy.effectiveP2pPhase() = ZERO` → 창이 꼬리(LP 2분)만 남고 게이트가 처음부터 열려 있다 |
| 자동 취소 제거 | `BundleTerminationService` 의 세션 종료 경로에서 인그레스 `release` 호출을 **지웠다**. `LegacyOrderReturn`/`IntakeLegacyOrderReturn` 도 함께 삭제 |
| 주문 반환 | 이제 인그레스 좀비 회수(`P2pEngineIntakeMapper.reapUnboundEngineClaims`)가 한다 — 그 SQL 은 `match_attempt_count` 를 **건드리지 않으므로** 임계 종결이 일어날 곳이 없다 |
| 앵커 리스 | 앵커를 쥔 세션의 재시도 간격을 리스의 절반으로 깎는다(`MatchingRoutingWorkHandler#holdSafe`). 없으면 LP 최대 백오프(60초)가 30초 리스를 넘겨 앵커가 죽는다 — 구간 창이 만든 새 요구다 |

**3/2 는 UX 결정이다.** 어떤 타임아웃·리스·백오프에서도 유도되지 않는다. 바꿀 수 있게 설정으로
두되, 바꾸는 근거도 기술이 아니라 제품이어야 한다.

☠️ **P2P 구간에는 조기 탈출이 없다.** P2P 재고가 하나도 없는 주문도 3분을 채운다. 제품이 그 대가를
알고 택했다 — 기다리고 싶지 않은 파트너는 P2P 를 끄고 LP 전용 2분 창을 받는다.

### 결정됨 (2026-09-11) — 위 UX 질문은 **①번으로** 닫혔다

창이 미매칭으로 닫히면 세션은 **종결되지 않는다.** 「사용자 결정 대기」로 살아 있고, 다음에 무엇을
할지는 사람이 누른다.

```
창(5분) 닫힘, 미매칭
   → 세션은 살아 있다. 상태 = AWAITING_USER_DECISION (비터미널, 창은 닫힘)
   → 사용자가 고른다:  재시도 → 같은 세션에 새 창
                      취소   → CANCELLED (세션 + 주문)
                      닫기   → CLOSED_BY_USER (세션만)
   → 유예 5분 안에 아무 결정도 없으면 → AUTO_CLOSED (최후 수단)
```

| 무엇 | 어디에 |
|---|---|
| 세 번째 상태 종류 | `MatchSessionStatus.Kind` — 창 열림 / **결정 대기** / 확정 / 종결. `default` 가 없어 상태를 추가하면 컴파일이 깨진다 |
| 「시스템이 대신 결정했다」의 계수 | `AUTO_CLOSED` 상태 + `SESSION_AUTO_CLOSED` 이벤트. `CLOSED_BY_USER` 와 **절대 합치지 말 것** — 합치면 줄여야 할 수를 잴 수 없다 |
| 유예 | `matching.policy.decision-grace-seconds: 300`. 제품 범위(2~5분)의 **위쪽 끝**이다 — 유예 동안 엔진은 아무것도 붙들고 있지 않으므로(예약은 창이 닫힐 때 전부 반환) 길게 잡는 비용이 0 에 가깝다 |
| 기한 | `matching_sessions.expires_at` 이 **움직인다**. 상태가 의미를 정한다(창이 닫히는 시각 / 유예가 끝나는 시각). 재시도가 같은 세션에 새 창을 열기 때문에 파생값일 수 없다 |
| 스윕의 일이 둘 | 기한 지난 **창** → `WINDOW_CLOSE`(→ 결정 대기), 지난 **유예** → `EXPIRE`(→ 자동 종료). 둘 다 큐를 거친다 — 세션 상태를 직접 쓰지 않는다(세션 리스가 소유자를 하나로 만든다) |
| 자동 반복 제거 | 인그레스가 **엔진 세션이 붙은 주문을 다시 집지 않는다**(`BOUND_TO_ANY_SESSION`). 좀비 회수도 「세션이 붙은 적 없는 선점」만 되돌린다. 재시도가 같은 세션에 새 창을 여는 행위가 된 이상, 재선점은 곧 사용자가 누르지 않은 재시도다 |
| 진입점 | 위젯 `GET/POST /widgets/api/p2p/order/{orderCode}/matching[/decision]` → 엔진 내부 경계 `/internal/matching/sessions/*`(loopback 전용) → 큐 |

☠️ **끝난 세션의 주문은 레거시가 닫는다** — 주문 자신의 `expires_at` 과 `P2pDepositLinkExpiryJob`.
엔진은 주문을 죽이지도 되살리지도 않는다. 「자동 취소 없음」과 「자동 재시도 없음」은 같은 규칙의
양면이다.

### 인테이크는 한 번 세션이 붙었던 주문을 **다시 집어도 된다**

재시도가 곧 새 세션이므로, 주문이 다시 잡히는 것 자체는 정상이다.
「링크가 있으면 영구 제외」는 재시도를 막아 버리므로 틀리다.

**금지되는 것은 재클레임이 아니라 자동 취소다.** V1 의 문제는 재시도한 것이 아니라
정해진 횟수를 넘기면 호출 자체를 죽인 것이다. 엔진에는 그 종결이 없다 —
주문은 사용자가 끝낼 때까지 살아 있다.

☠️ 단, **번들을 확정한 세션의 주문은 다시 집지 않는다.** 그 주문은 이미 매칭됐고
(`status = MATCHED`), 다시 집으면 `commitBundle` 이 거절하는 이중 매칭이 된다.

---

## 1. 근거 — 레거시는 이미 번들을 표현한다

| | 레거시 통합 매칭 | 엔진 번들 |
|---|---|---|
| 최대 레그 | `P2pMatchingService.MAX_SPLIT_COUNT = 3` (`:127`) | `matching.policy.max-transfer-legs: 3` |
| 소스 혼합 | 폭포 P2P → TORQ → PARTNER (`:510`) | LP → P2P#1 → P2P#2 |
| 레그 식별 | `P2pLegType` {P2P, TORQ, PARTNER} | `MatchSource` {P2P, LP, PARTNER} |
| 한 주문의 레그 | `P2pMatchRepository.findByDepositOrderId` → `List<P2pMatch>` | `legs_json` |
| 전액 커버 | 필수 — 부분 잔여 금지 (`:383`) | 필수 |
| 레그별 확인 | `legType` 별 레일 (CODEF / TORQ) | `MatchSource` 별 (미배선) |

**같은 구조다.** `P2pLegType` 과 `MatchSource` 는 값까지 1:1 이다.

### 기존 근거의 검증

코드에 적힌 유일한 근거는 `P2pLiquidityReservationService.commitExact` JavaDoc (`:619-624`):

> *"레거시 주문은 MATCHED 전이가 하나뿐이고 정산 흐름이 매치들이 견적 전체를 덮는다고 가정한다.
> 엔진 소유 LP 레그 옆에 부분 P2P 레그를 커밋하면 한 고객 결제의 소유권이 갈린다."*

**절반만 맞다.**
- "매치들이 견적 전체를 덮는다" — 맞다. 레거시 불변식이다
- 그러나 **엔진 번들도 전액을 덮는다**(LP + P2P 잔여 = 주문액). 불변식을 위반하지 않는다

실제 문제는 **`commitExact` 가 예약을 하나씩 커밋**해 중간에 부분 커버 상태가 생기는 것이었다.
이건 "레거시가 번들을 표현 못 한다"가 아니라 **"커밋 단위가 틀렸다"** 는 뜻이다.
엔진은 이미 `BundleConfirmationService` 에서 번들 전체를 한 번에 확정한다 — 그 시점에 레그를
원자적으로 만들면 불변식이 유지된다.

### 원래 의도와의 정합

핸드오프 원문:
> *"Engine is the matching owner; **legacy transaction records/services may remain
> execution/settlement infrastructure**."*

`P2pMatch` 를 안 만들면 그 인프라를 **쓸 수 없다** — 전부 `P2pMatch.id` 를 키로 돌기 때문이다.
현재 상태는 이 문장과 모순이고, 재설계는 이 문장으로 돌아가는 것이다.

---

## 2. 제안 구조

```
[엔진이 소유]  세션 · 후보 탐색 · 번들 계획 · 예약 리스 · 라우팅 재시도 · 세션 TTL
     │
     │  번들 확정 = p2p_matches N건 원자 생성 (legType 매핑)
     ▼
[레거시가 소유]  입금확인(CODEF/TORQ) · 이체 기한 · 분쟁 · 정산 · 파트너 알림
```

- 엔진은 **매칭까지**. 확정 이후는 레거시가 그대로 가져간다
- `MatchSource.LP` → `P2pLegType.TORQ` 매핑. P2P·PARTNER 는 동명
- 엔진의 세션·이벤트 테이블은 **관측·감사용으로 유지**한다(라우팅 결정 로그). 실행 축이 아니다

## 3. 미구현 3건이 어떻게 되는가

| | 현행 계획 | 재설계 후 |
|---|---|---|
| ① 입금확인 인그레스 | 두 레일에 V2 분기 추가 | **불필요** — CODEF·TORQ 레일이 그대로 동작 |
| ② 실행 계획 영속화 | 새 영속 상태 + 순차 활성 구현 | **불필요, 그리고 애초에 틀린 설계였다** — 아래 참조 |
| ③ terminal webhook publisher | claim·재시도·배달 신규 구현 | **대폭 축소** — 기존 알림 경로 재사용. 세션 단위 집계만 얹으면 됨 |

가장 큰 항목 ②가 통째로 사라진다.

### ②는 구현 누락이 아니라 레거시가 이미 기각한 설계였다

엔진은 "레그별 10 분 순차 활성" 을 만들려 했다. 레거시는 그 설계를 검토하고 버렸다 —
`P2pDepositService:182-196` 이 이유를 적어 뒀다:

> 레그 만료가 주문을 상속하므로 카드마다 다른 시계가 생기지 않는다
> ⚠️ 기준은 실제 성립 시각이 아니라 대기 만료 예정 시각이다
>   · 구매자에게 보이는 마감이 도중에 바뀌지 않는다(줄어드는 마감이 가장 나쁘다)

즉 **기한은 입금 주문 하나에 하나이고 레그가 상속한다**(`p2p_deposit_orders.expires_at`).
엔진이 할 일은 구현이 아니라 그 값을 읽는 것이다. 주문 생성 시점에 이미 정해지므로
커밋 전에 읽을 수 있고, "기한이 커밋의 결과가 되는" 순서 반전도 없다.

## 4. 재설계로 새로 생기는 일

정직하게 적는다. 공짜가 아니다.

1. **번들 → `p2p_matches` 원자 생성** — `commitExact` 가 미완성 형태로 이미 있다.
   레그 단위가 아니라 **번들 단위 커밋**으로 바꿔야 한다
2. **입금 주문 연결** — 엔진 세션이 어느 `p2p_deposit_orders` 행에 대응하는지.
   `matching_session_legacy_orders` 테이블이 이미 그 용도로 있다
3. **예약 원장 귀속** — `ENGINE_RESERVE:` LOCK 을 생성된 match 에 귀속(`attachReservationToMatch`).
   MR !44 에서 memo 계약을 통일해 뒀으므로 **그대로 동작한다**
4. **세션 종결 판정** — 레그 상태를 레거시에서 읽어 세션을 `COMPLETED`/`PARTIALLY_COMPLETED`/
   `DISPUTED` 로 마감. 엔진이 사실을 만드는 게 아니라 **읽어서 집계**한다
5. **V1 matching-worker 와의 소유권** — 아래 §4-b 참조. **컷오버 전제에서 해소됐다.**

## 4-c. ☠️ 재설계가 잃어버린 **Phase 3** — 그리고 되찾은 방법 (2026-09-11)

§4 는 "번들 → `p2p_matches` 원자 생성" 을 새로 생기는 일 ①로 적었다. 그것은 레거시 **Phase 2**
의 대체물이다. **Phase 3 은 목록에 없었고, 그래서 구현되지 않았다.**

```
레거시 통합 폭포
  Phase 2  reserveTorqLeg  → p2p_matches INSERT (torq_escrow_id = NULL) · 짧은 tx, 즉시 커밋
           createTrade     → LP 에스크로 (외부, 되돌릴 수 없다)
  Phase 3  ① escrow 를 그 행에 채우고 주문을 MATCHED 로
           ② 실패면 예약 레그를 CANCELLED 로 보상 → 폴백 또는 failUnifiedOrder
```

엔진은 ①②를 **둘 다** 잃었고, 두 결함이 그대로 나왔다(Codex 리뷰 2026-09-11).

| 잃은 것 | 증상 |
|---|---|
| ① escrow 기록 | LP 가 준 escrow 가 어느 레그에도 적히지 않는다 → 구매자가 송금해도 `submitTransferDone` 이 LP 에 착수를 통지하지 않고(`torq_escrow_id != null` 게이트), 만료 백스톱은 그 레그를 「거래 미개설」로 FAILED 시킨다. **실제 송금과 내부 원장이 갈린다** |
| ② 실패 보상 | LP 가 거절해도 이미 `commitBundle` 이 끝나 `committedAnyLeg = true` → `partiallyCommitted` → 큐가 **완료** 처리. 주문은 `MATCHED`, 판매자 재고는 팔린 채, 세션은 `BUNDLE_READY` 인 채로 갇힌다 |

### 되찾은 방법 — 원자성을 깨지 않는다

| 무엇 | 어디에 |
|---|---|
| ① escrow 기록 | `POST /internal/matching/p2p-liquidity/sessions/{id}/lp-escrow` → `P2pLiquidityReservationService.recordSessionLpEscrow`. 세션↔주문↔레그 소유권을 대조한 뒤 조건부 UPDATE(같은 escrow 재전달=성공, 다른 escrow 덮어쓰기=거절) |
| ①-b `torq_trades` 미러 | `TorqService.recordEngineLpEscrowTrade`. ☠️ **레그 UPDATE 만으로는 레일이 이어지지 않는다** — `submitTransferUnified`·완료 웹훅·만료 백스톱이 전부 그 행을 찾는다. 엔진은 V2 LP 예약 API 로 에스크로를 직접 만들므로 그 행이 생기지 않았다 |
| ② 번들 단위 보상 | `POST .../sessions/{id}/bundle-release` → `P2pLiquidityReservationService.releaseBundle`. 한 트랜잭션에서 레그 전부 `CANCELLED` + 판매자 원장 UNLOCK + 예약 `RELEASED` + 입금 주문 `MATCHED → MATCHING` |
| ②-b 제공자 예약 | Core 는 LP/PARTNER 예약을 모른다. `BundleConfirmationService` 가 Core 보상 **뒤에** 그것들을 푼다(멱등키는 종결 경로와 같은 값) |
| ③ 정직한 큐 결과 | `BundleConfirmationService.Result` 가 다섯 결과로 갈렸다 — `CONFIRMED`/`BLOCKED`/`COMPENSATED`/`ESCALATED`/`COMPENSATION_FAILED`·`ESCROW_RECORD_FAILED`. 보상된 번들은 **완료가 아니라 재시도**이고, 보상 실패·기록 실패는 dead-letter 로 **멈춘다** |

### ☠️ `commitBundle` 은 클레임을 **계속 같은 트랜잭션에** 둔다

레거시는 클레임을 Phase 3 으로 미뤄 보상 여지를 남긴다. 엔진은 그 창을 **되살리지 않는다**:

- 레거시가 그 창을 감당할 수 있는 것은 그 시점의 레그가 `withdraw_order_id = NULL` 인 LP 예약
  **하나**뿐이라 고아가 되어도 판매자 재고를 물지 않기 때문이다
- 엔진 번들은 판매자 원장 LOCK 을 **N 건** 물고 있다. 레그 INSERT 와 클레임 사이에 구매자 취소가
  이기면 그 LOCK 들이 주인 없이 남는다(`commitBundle` javadoc 「클레임이 삽입보다 먼저다」)

그래서 창을 만드는 대신 **역연산**을 만들었다. 커밋이 한 일(레그·원장 귀속·세션 링크·클레임)을
`releaseBundle` 이 전부 되돌리고, 그것도 한 트랜잭션이다. 보상이 가능한 조건은 **되돌릴 수 없는
것이 아직 없을 때**뿐이다 — 레그가 `CREATED` 를 지났거나 LP 에스크로가 살아 있으면 거절하고
운영자에게 넘긴다.

---

### ☠️ 보상의 순서 — 제공자 먼저, Core 나중 (2026-09-11, Codex 재리뷰)

기준은 「어느 쪽이 덜 아픈가」가 아니라 **「재시도가 이어받을 수 있는가」**다.

```
Core 먼저 : Core 성공 → 제공자 실패  ⇒ 재시도는 confirm() 처음으로 돌아간다.
                                       예약이 RELEASED 라 commitBundle 이 거절되고,
                                       남은 일(제공자 해제)에 ☠️ 영영 닿지 못한다
제공자 먼저: 제공자 성공 → Core 실패  ⇒ 재시도는 ALREADY_COMMITTED 를 지나 다시 실패하고
                                       보상으로 돌아온다. 제공자 해제는 멱등이라 통과,
                                       Core 해제가 ✅ 다시 시도된다
```

둘 다 중간 상태를 만든다. **이쪽 중간 상태만 다음 패스가 이어받는다.** 처음 순서는 「LP 재고는
돌려줬는데 레거시 번들은 그대로」를 피하려다, 그보다 나쁜 「Core 는 돌려줬는데 LP 재고는 제공자가
쥔 채 아무도 풀지 않는」 상태를 만들었다 — 만료 스윕이 창 종료 뒤에 치우기 전까지.

제공자를 먼저 푸는 값도 공짜가 아니다. Core 해제가 실패하면 판매자 원장 LOCK 이 남는다. 그것이
LP 재고보다 가벼워서가 아니라, **그쪽은 재시도가 닿기 때문**이다.

### ☠️ 보상이 **성공해도** 세션은 스스로 다시 계획하지 못했다 (2026-09-11)

보상은 레그를 비우고 세션을 `MATCHING` 으로 되돌린다(`discardBundle`). 그런데 그 뒤 큐에 돌려준
답이 **같은 `CONFIRM` 행의 재시도**였다. 비어 있는 세션에 확정을 재배달하면 preflight 가
`BUNDLE_NOT_READY` 로 막고, 그 사유는 **영구**라 행이 `Completed` 로 닫힌다 — 세션은 창이 열린 채
**아무 큐 행도 없이** 남아 만료 스윕까지 아무 일도 일어나지 않았다. 코드 주석의 「다음 패스가 새
번들을 세울 수 있다」는, 그 다음 패스를 아무도 만들지 않아 성립하지 않았다.

계획은 라우팅 행(`START`)이 한다. 그래서 그 행을 되살린다 — **백오프 뒤에.** 즉시 되살리면 번들
커밋과 보상이 한 쌍의 자금 경로 쓰기(출금 주문 `FOR UPDATE` + 원장 LOCK/UNLOCK)를 창이 닫힐 때까지
제한 없이 반복한다. 「제공자가 거절하는 동안에도 계속 재시도한다」는 제품 규칙은 지키되, 간격은
LP 백오프와 같은 값이다.

### ☠️ LP 경계 — 예약은 V2, 확정 이후는 레거시. `torq_trades` 미러는 우회가 아니다 (2026-09-11)

Phase 3 을 되찾으며 「엔진 LP 레그에 `torq_trades` 미러를 쓴다」가 들어왔다. 코드만 보면
임시방편처럼 보이므로 근거를 남긴다. **다시 논의하기 전에 이 절을 읽을 것.**

#### 왜 두 API 가 갈리나

| | API | 성격 |
|---|---|---|
| 레거시 | `TorqService.createTrade(...)` | **일회성.** 부르는 순간 에스크로 + `torq_trades` 행 |
| 엔진 | `TorqLpReservationClient` — `checkCapacity / reserve / renew / confirm / release` | **리스 기반 예약** |

엔진이 예약 API 를 쓰는 이유는 엔진의 모델 자체가 *예약 후 확정* 이기 때문이다.
단계 창의 LP 구간(2분)에서 LP 가 BUSY 면 재시도하는 동안 **용량을 붙잡아야** 하고,
번들이 전부-아니면-전무라 P2P 가 깨지면 LP 예약을 **되돌려야** 한다. 일회성 `createTrade` 는
이미 에스크로를 만든 뒤라 되돌림이 취소가 된다. `p2p_liquidity_reservations` 를 엔진 고유
기능으로 남긴 것과 같은 논리다(§5).

#### 그런데 확정 이후는 갈리지 않는다 — TORQ 스펙이 그렇게 정의한다

`TorqLpConfirmResponse` javadoc:

> `tradeId` 는 레거시 `createTrade` 응답의 `tradeId` 와 **같은 의미**다 —
> 이 시점 이후의 흐름(입금 → 검증 → 릴리스 → 분쟁)은 **레거시와 완전히 같다.**

응답이 `escrowId` 와 `tradeId` 를 함께 돌려주는 것 자체가 「여기서부터 레거시」라는 신호다.
TORQ 쪽에 V2 전용 사후 흐름은 없다.

#### 그래서 미러는 인계 지점이다

```
예약 단계   V2 API (reserve/renew/confirm/release)   ← 엔진 고유
확정 이후   레거시 (torq_trades 기반)                 ← TORQ 스펙이 「완전히 같다」
경계        confirm 응답의 escrowId·tradeId 를 기록    ← 미러 = 매칭→실행 인계
```

미러는 신원·금액·파트너 MASTER 수취 주소만 담고 나머지(판매자 계좌·입금수단·최종 USDT)는
기존 `getTradeByEscrow` 동기화가 지연 채운다 — 판매자 PII 를 엔진 경계 밖으로 내보내지 않기
위해서다. 그리고 **엔진 경로에서만** 쓴다. 레거시 `createTrade` 는 자기 미러를 따로 쓰므로
한 행에 생산자가 둘이 되지 않는다.

#### ☠️ `torq_trades` 를 배제하자는 제안이 나오면

그 선택은 **같은 에스크로에 대해 사후 파이프를 두 벌** 만드는 것이다. 아래 셋을 V2 축에
재구현해야 한다.

| 레거시가 이미 하는 것 | 배제 시 |
|---|---|
| `submitTransferUnified` → LP 에 markPaid | V2 축에 재구현 |
| `handleWebhook` → `applyTorqCompletion` → 정산 | V2 축에 재구현 |
| `P2pMatchExpiryJob` 의 LP 재조회 백스톱 | V2 축에 재구현 |

특히 **웹훅은 TORQ 가 한 번 보낸다.** 수신 핸들러가 둘이면 어느 쪽이 처리할지를 정해야 하고,
둘 다 처리하면 이중 정산이다.

이것은 이번 재설계가 걷어낸 구조 그 자체다 — 실행 reg·provider 인박스·실행 계획을 지운 이유가
「레거시에 이미 있는 것을 V2 축에 다시 만든다」였다(§3, §5). LP 사후 흐름을 재구축하면 그 축을
LP 쪽에 다시 세우게 된다.

**판단이 달라질 조건**: TORQ 가 V2 전용 사후 API 를 내놓거나, `torq_trades` 자체를 폐기하기로
결정되면 그때 다시 본다. 그 전에는 현행을 유지한다.

---

### ☠️ 멱등키는 **본문의 함수가 아니다** — 시도 번호가 들어간다 (2026-09-13, BARO 회신 부록)

제공자가 V2 예약 API 를 2026-09-14 배포하며 `Idempotency-Key` 를 **영구히** 유니크하게
구현했다. 규격 §5 의 「보존 24시간」을 재사용 창으로 만들지 **않았다** — 그 유니크 제약이
동시 요청을 직렬화하는 장치라, 창을 두면 정작 보호가 사라진다는 판단이다.

엔진의 키는 그 전제 위에 있지 않았다. `IdempotencyKeys` 주석이 *"never of a retry counter"*
라고 못박고 있었고, 그것이 **규격 안에서는 옳았다**: 같은 본문 = 같은 키 = 최초 응답 재생.
제공자가 재사용 창을 없애는 순간 같은 규칙이 정반대로 작동한다.

```
① 예약 (key=K)                       → RESERVED
② lease 만료 — 그 예약이 죽는다
③ 재계획 — 잔여액·앵커가 같으니 키도 K   → 409 330 "만료됨"
④ 다시 ③ … 창이 닫힐 때까지 영원히
```

그 세션은 LP 를 **다시는** 잡지 못한다. 우리가 `330` 을 영구 거절로 분류하고 있었으므로
(`PROVIDER_REJECTED`, `retryable=false`) 실제로는 한 번 만에 LP 축이 끝났다.

**결론 — 키의 재료는 「본문 + 시도 번호」다.**

| | 오르는가 | 왜 |
|---|---|---|
| `330` 수신 (예약이 죽었다) | **오른다** | 그 키는 영영 죽은 키다 |
| `BUSY` / `NO_POOL` | 오르지 않는다 | 예약이 **생기지도 않았다** — 같은 키가 그대로 유효하고, 여기서 바꾸면 중복 방지가 풀린다 |
| 전송 실패 · 응답 유실 | 오르지 않는다 | 재생받아야 하는 바로 그 경우다 |

`matching_queue.attempts` 로 대신할 수 없다. 그 값은 `BUSY` 에도 오른다.

시도 번호는 **행에 있다** (`matching_sessions.lp_reserve_attempt`). 메모리에 두면 워커 교체나
세션 재적재마다 0 으로 돌아가고, 죽은 키가 되살아나 `330` 이 반복된다.

### ☠️ `reserve` 가 `COMMITTED` 를 돌려주는 경우 — 조용히 버리지 않는다

응답이 유실된 확정이 있었다는 뜻이고, 그 escrow 는 `:release` 로 사라지지 않는다
(409 `336` — 거래 취소 API 를 써야 한다). 엔진이 자동으로 이어붙일 수 있는 경로가 아직 없으므로
**`LP_ALREADY_COMMITTED` 로 끝내고 `log.error` 에 예약 id 를 남긴다.** 일반 실패로 묻으면
LP 자금이 묶인 채 아무도 모른다 — 제공자 부록이 *"이걸 오류로 처리하면 중복 거래가 납니다"*
라고 굵게 적은 응답이 이것이다.

---

## 4-b. 소유권 — 공존이 아니라 컷오버 (2026-09-10 확정)

워커와 엔진을 동시에 돌릴 계획이 없다. 그러면 "두 생산자" 는 위험이 아니라 **전환 절차**다.

V1 워커는 3 파일짜리 폴링 껍데기이고 매칭 로직은 전부 `core/P2pAsyncMatchingService` 에 있다:

```java
@Value("${p2p.async-matching.enabled:false}") boolean enabled;   // 기본값 false — 이미 스위치가 있다
@Scheduled(1초)  poll() → claimPendingOrders(batch) → runMatchAttempt(order)
@Scheduled(60초) reap() → reapZombies(timeoutMinutes)
```

컷오버 = `p2p.async-matching.enabled=false` 로 워커를 세우고 엔진을 켠다. 런타임 게이트를
새로 설계할 필요가 없다.

**게다가 데이터 레벨 방어선이 이미 들어갔다.** `commitBundle` 은 살아 있는 레그가 이미 있는
입금 주문을 거절한다(`P2P_DEPOSIT_ORDER_ALREADY_HAS_LEGS`) — 실수로 둘이 동시에 돌아도
주문 액면의 2 배가 매칭되지 않는다. 설정 플래그보다 강한 보장이다.

### 대신 컷오버 조건이 생긴다

엔진이 워커를 **대체**하려면 워커가 하던 둘을 해야 한다.

| V1 | 엔진 현재 | 필요한 것 |
|---|---|---|
| `claimPendingOrders` → `runMatchAttempt` (1 초) | 없음 — 세션 진입점 미배선 | 대기 입금 주문을 집어 세션을 만드는 드라이버 |
| `reapZombies` (60 초) | 예약 리스 리퍼만 있음 | 좀비 주문(선점됐는데 멈춘 것) 회수 등가물 |

### 구현됨 (2026-09-10)

| | 어디에 |
|---|---|
| 인그레스 드라이버 | `matching-engine` `LegacyOrderIntakeDriver` (5 초) |
| 좀비 회수 | `matching-engine` `LegacyOrderIntakeZombieReaper` (60 초) |
| Core 쪽 절반 | `core` `P2pEngineIntakeService` + `common` `P2pEngineIntakeMapper` |
| HTTP 경계 | 기존 컨트롤러에 `intake/claims`·`intake/claims/{id}/release`·`intake/reap` 추가 (새 경계 없음) |
| 만료 스윕 (2026-09-11) | `matching-engine` `OverdueSessionExpirySweeper` (60초) — 아래 정정 ④ |

### ☠️ 컷오버 전제 정정 — 「V1 을 끈다」는 한 값이 아니다 (2026-09-10 구현 중 발견)

§4-b 는 컷오버를 `p2p.async-matching.enabled=false` 하나로 적었다. **그대로 하면 엔진은 한 건도
집지 못한다.** 그 키를 `core` 가 함께 읽고, 서비스마다 뜻이 다르다.

| 읽는 곳 | `true` 일 때 | `false` 일 때 |
|---|---|---|
| `matching-worker` `P2pMatchingWorker` | V1 이 PENDING 을 집는다 | 유휴 |
| `open-api`·`partner-api` `P2pDepositService:235` | 주문을 **PENDING 으로 큐잉** | **요청 스레드에서 동기 매칭** — PENDING 이 생기지 않는다 |
| `open-api`·`partner-api` `P2pMatchingService:2012` | 매칭 대기(`match_wait_until`)를 건다 | 대기 옵션을 **무시**한다 |

즉 전역으로 내리면 P2P 매칭은 멈추지 않는다 — **레거시 동기 경로로 계속 돌고 엔진만 유휴가
된다.** 조용한 무효 컷오버라 지표로도 잘 안 보인다.

**컷오버 스위치** — 키는 **하나**(`p2p.async-matching.enabled`, env `P2P_ASYNC_MATCHING_ENABLED`)이고
**값이 서비스마다 다르다**. 롤백은 역순.

```
① open-api · partner-api  P2P_ASYNC_MATCHING_ENABLED=true    주문을 PENDING 으로 큐잉
② matching-worker         P2P_ASYNC_MATCHING_ENABLED=false   V1 소비 정지
③ matching-engine         P2P_ASYNC_MATCHING_ENABLED=true    엔진 인그레스 시작 (기본 false)
```

> ☠️ **2026-09-10 정정 ②** — 초안에는 ③ 이 엔진 전용 키 `MATCHING_INTAKE_ENABLED` 였다. **지웠다.**
> 스위치가 둘이면 「어느 쪽이 진짜인가」가 생기지만, 값이 서비스마다 다른 하나의 키는 그 질문을
> 만들지 않는다 — `.env.<서비스>` 파일이 이미 분리돼 있다. `matching.intake.*` 에는 스위치가 아닌
> 동작 설정(배치 크기·폴 주기·임계 시도·좀비 타임아웃)만 남았다. 기본값 `false` 는 세 서비스 모두
> **코드에** 박혀 있다(`@Value("${p2p.async-matching.enabled:false}")`) — yml 이 빠진 배포에서도
> 유휴여야 한다.

⚠️ 인그레스는 `MATCHING_CORE_LIQUIDITY_BASE_URL` 이 있어야 동작한다(P2P 유동성 어댑터와 같은
스위치로 배선된다). URL 없이 `③` 만 켜면 폴마다 `CORE_LIQUIDITY_NOT_CONFIGURED` 를 ERROR 로 남긴다.

### ☠️ 정정 ③ — **폐기됨** (2026-09-11, §0 이 대체한다)

여기에는 *「대기 시간 동안 5분 세션을 반복해 재시도한다」* 는 설계가 적혀 있었다. **틀렸다.**

그 글은 `match_wait_until`(운영 1500초)을 살아 있는 축으로 보고, V1 이 대기 내내 재시도하던
것을 엔진이 흉내 내야 한다고 가정했다. 엔진 모델에서 `match_wait_until` 은 무효이고
(§0), 재시도는 시스템이 아니라 **사용자**가 한다.

그 가정으로 실제로 구현된 것과, 되돌려야 하는 것:

| 구현된 것 | 왜 틀렸나 |
|---|---|
| 세션 종료 시 `finishAttempt` 호출 | **엔진이 없애려던 자동 취소를 되살린다.** 5회 임계에서 `CANCELLED` |
| 주문을 `PENDING` 으로 반환 → 인테이크 재클레임 | **시스템 자동 재시도.** 재시도는 사용자 행위여야 한다 |
| 세션 종료 후 주문을 자동으로 다시 태우는 것 | 재클레임 자체는 괜찮다(§0). 틀린 것은 그 경로가 `finishAttempt` 를 거쳐 **자동 취소**로 이어진다는 점이다 |

같은 커밋에서 함께 들어온 것 중 **살릴 것**: 죽은 세션 링크 정리(`purgeDeadSessionLinks`)는
`commitBundle` 게이트가 `ORDER BY session_id LIMIT 1` 로 링크를 읽어 비결정적으로 거부하던
실제 결함을 고친 것이라 모델과 무관하게 유효하다. 다만 재클레임 경로가 사라지면 호출 지점이
달라진다 — 사용자 재시도가 새 세션을 열 때가 그 자리다.

### ☠️ 정정 ④ — 「세션이 끝나면 주문을 돌려준다」에는 **세션을 끝내 주는 쪽**이 없었다 (2026-09-11)

정정 ③ 으로 **세션이 끝나면** 주문이 돌아오게 됐다. 남은 구멍은 그 앞이다 — `MatchingQueue.EventType.EXPIRE`
에는 **운영 생산자가 없었다.** 세션을 만료시키는 경로는 하나뿐이었다: 라우팅 패스가 큐에서 깨어나
`now >= expiresAt` 을 보는 것. 그래서 큐 행이 **재시도 불가**로 끝난 세션은 영원히 비종결로 남았다.

| 끝나는 방식 | 큐 행 | 세션 |
|---|---|---|
| 영구 거절된 `CONFIRM` (`isPermanent`) | `COMPLETED` | `BUNDLE_READY` 그대로 |
| `OPERATOR_FAULT` (해제가 배선 결함으로 영구 실패) | `DEAD_LETTER` | **일부러** 손대지 않음 |

둘 다 「그 세션을 다시 깨울 행이 없다」로 끝난다. 그리고 정정 ③ 의 liveness 규칙(상태로 판정)
때문에 그 세션은 자기 입금 주문을 **계속 소유**한다 → 주문은 `MATCHING` 에 갇히고, 좀비 리퍼는
「살아 있는 세션이 소유 중」이라 회수하지 않은 채 60초마다 ERROR 만 올린다.

**고친 것** — `matching-engine` `OverdueSessionExpirySweeper`(60초, 기본 OFF).

```
스윕 → EXPIRE 적재 → MatchingQueueWorker → BundleTerminationService → LegacyOrderReturn → 주문 PENDING
```

| 결정 | 이유 |
|---|---|
| 세션 상태를 **직접 바꾸지 않는다**. 큐에 넣는다 | 세션 리스·dedupe 키·클레임을 그대로 쓴다. 직접 만료시키면 진행 중인 만료 패스와 동시에 예약을 되돌리게 된다 |
| **유예 120초** | 살아 있는 라우팅 행이 가장 늦게 깨어나는 시점(LP 최대 백오프 60s + 클레임 리스 20s + 회수 주기 10s = 90s)에 여유. 스스로 만료될 세션과 다투지 않는다. ☠️ `matching.lp.max-backoff-ms` 를 올리면 함께 올릴 것 |
| 후보는 「비종결」이 아니라 **「매칭 창이 열림」** (`MatchSessionStatus.isMatchingWindowOpen()`) | `PAYMENT_IN_PROGRESS` 는 기한을 **정상적으로** 넘긴다 — 확정된 레그가 이체 중이다. 그것까지 만료 대상으로 보면 진행 중인 이체 밑에서 수령 계좌를 빼는 셈이다 |
| **EXPIRE 행이 이미 있으면 넣지 않는다** | dedupe 키만으로는 부족하다 — `enqueue` 는 COMPLETED 행을 **되살린다**(`RELEASE_INVALIDATED_P2P` 를 위한 의도된 동작). 만료는 세션당 한 번이고, 되살리면 이미 끝난 종결이 재실행돼 대기열로 돌아간 주문을 한 번 더 반환한다 |
| 스위치는 `p2p.async-matching.enabled` | 인그레스와 같은 키. 세션을 만들지 않는 배포에는 만료시킬 세션도 없다 |
| 조회는 `IN (열린 상태)`, `NOT IN (종결)` 이 아니다 | 모르는 상태에서 **안전한 쪽으로** 틀린다(스윕 안 함 = 예전처럼 갇힘, 그 반대는 살아 있는 세션의 예약을 푼다). 인덱스 `idx_matching_sessions_status_expiry` 와도 맞는다 |

**남아 있는 것** — 큐의 at-least-once. 워커가 종결을 마치고 `complete()` 전에 죽으면 같은 `EXPIRE`
가 재배달되고, 그 두 번째 실행은 이미 돌아간 주문을 한 번 더 반환할 수 있다(V1 `finishAttempt` 가
`PENDING` 도 받는다). 스윕이 만든 성질이 아니라 `CANCEL` 이 이미 갖고 있던 성질이다. 근본 해결은
Core 의 인그레스 `release` 가 **「이 주문이 아직 이 세션의 것인가」**를 보게 하는 것 — 계약 변경이라
별건으로 남긴다.

### ☠️ 컷오버는 폴백이 없다

공존이면 엔진이 조용히 실패해도 V1 이 받쳤다. 컷오버는 그게 없다 — **엔진이 안 되면 P2P
매칭이 멈춘다.** 롤백은 `enabled` 를 되돌리는 것이라 빠르지만, 그동안 엔진이 만든
`p2p_matches` 와 예약은 남는다. 배포 런북에 "되돌릴 때 무엇이 남는가" 를 반드시 넣을 것.

### 정정 ④ 의 결론 보정 (2026-09-11)

스위퍼 자체는 유효하다 — 「아무도 끝내지 않은 세션을 끝낸다」는 모델과 무관하게 필요하다.
다만 **끝낸 뒤 무엇을 하는가**가 §0 에 따라 달라진다. 주문을 `PENDING` 으로 돌려
자동 재클레임시키는 것이 아니라, 주문은 살아 있는 채로 두고 **사용자가 재시도할 수 있는
상태**가 되어야 한다.

---

## 5. 버려지는 것 / 남는 것

**버린다** — `matching_execution_regs`, `matching_leg_inbound_events`,
`matching_session_terminal_outbox`, `ExecutionRegInboundBridge`,
`/internal/matching/execution-reg-events`, `execution-allocation-plan`

**남긴다** — `matching_sessions`, `matching_queue`, `matching_events`,
`p2p_liquidity_reservations`(예약 리스는 엔진 고유 기능), `matching_session_legacy_orders`,
`matching_session_customers`/`targets`(PII 분리)

MR !44·!45 의 자금 결함 수정은 **전부 유지된다** — 원장 memo 계약, 리스 상한, loopback 인증,
타임존, 갭락 제거는 예약 메커니즘 자체의 정확성이라 축과 무관하다.

---

# 6. main 재정비 — 다른 배포를 막지 않게

## 문제

`main`(`cc833390`)에는 **59 files, +6,079** 의 엔진 관련 변경이 미배포 상태로 쌓여 있다.
`spring:deploy-production` 은 서비스를 골라 올릴 수 없다:

```yaml
for SVC in open-api scheduler matching-worker matching-engine; do
```

즉 **누가 open-api 핫픽스를 배포하려 해도 엔진이 함께 나간다.** 그리고 지금 엔진은
재설계 대상이라 올릴 이유가 없다.

추가로 운영에서 새로 도는 것:
- `P2pEngineReservationLeaseReaperJob` (60초)
- `P2pEngineAbandonedAllocationMonitorJob` (300초)

둘 다 `p2p_liquidity_reservations`(운영 0행)만 보므로 **무해하지만**, 재설계로 예약 의미가 바뀌면
같이 재검토해야 한다.

## 제안 — 3단계

### ① 엔진을 배포 루프에서 분리 (즉시, 작음)

`.gitlab-ci.yml` 의 배포 루프에서 `matching-engine` 을 뺀다. **빌드는 유지**한다
(컴파일·컨텍스트 테스트 게이트는 계속 돌아야 한다).

```
build:        open-api scheduler matching-worker matching-engine   ← 유지
deploy:       open-api scheduler matching-worker                    ← 엔진 제외
```

이러면 **다른 배포 작업이 즉시 풀린다.** 엔진은 재설계가 끝나고 되돌리면 된다.

### ② 스케줄러 잡 2개를 게이트 (즉시, 작음)

`system_settings` 플래그나 프로파일로 OFF 기본. 운영 0행이라 지금은 무해하지만,
"돌고 있지만 아무 의미 없는 잡"을 남겨두면 재설계 때 판단을 흐린다.

### ③ 재설계 구현 (별도 MR)

위 2~5 항목. 완료 후 엔진을 배포 루프에 되돌린다.

## 배포 판단

①②를 적용하면 **main 을 언제든 배포할 수 있다.** 나가는 것은:
- core/common 의 자금 결함 수정 (레거시 경로 불변 — MR !44 리뷰가 확인)
- open-api 내부 컨트롤러 (loopback 가드. 호출자가 없으므로 무동작)
- 스케줄러 잡 2개 (게이트 OFF)

엔진은 나가지 않는다.

---

# 7. 결정이 필요한 것

1. **이 재설계 방향을 채택하는가** — 채택하면 미구현 3건의 계획이 바뀐다
2. **V1 matching-worker 와의 소유권 게이트** (4-5항) — 개통 전 필수
3. **main 재정비 ①②를 먼저 할 것인가** — 다른 배포가 급하면 이게 먼저다

이 문서는 제안이다. 승인 전에는 구현하지 않는다.
