# P2P 매칭 대기 시간

작성 2026-08-20 · repo `cryptoments` (+ `widget-ui`)
DDL 은 **이미 적용됨** (§1). 이 문서는 코드 구현 지침이다.

> ## ➜ 룰 정본은 `P2P_MATCH_WAIT_RULES.md` 다 (2026-09-02)
>
> 이 문서는 대기 기능을 **만들 때의 작업 지시서**다. 미래형으로 쓰인 서술("…하게 한다",
> "…를 넣는다")은 지금 코드와 일치하지 않을 수 있고, 그 뒤 들어온 **선점(Reservation)**·
> **부분 성립**과 대기의 관계는 여기에 없다.
>
> **현재 룰을 알고 싶으면 `P2P_MATCH_WAIT_RULES.md` 를 봐라.** 이 문서는 판단 이력으로 남긴다.

---

## 무엇을 만드는가

지금은 P2P 유동성이 없으면 **즉시** LP(TORQ) 레그가 붙는다.
파트너가 지정한 **대기 시간 동안 P2P 매칭을 계속 시도**하고, 만료 후에야 LP 로 넘어가게 한다.

```
현재   주문 생성 → P2P 조회 → 부족 → 즉시 TORQ 레그 → MATCHED
이후   주문 생성 → P2P 조회 → 부족 → 대기(5초마다 재시도) → 만료 → TORQ 레그 → MATCHED
                                      ↑ 그 사이 P2P 로 전액 채워지면 그대로 성립
```

**기본값은 대기 없음.** 옵션이 없으면 동작이 지금과 **한 글자도 달라지면 안 된다.**

## 확정된 제품 결정

| 항목 | 결정 |
|---|---|
| 옵션 출처 | **링크 옵션** + **위젯 요청 파라미터**. 파트너 기본 설정은 두지 않는다 |
| 대기 중 "바로 진행" 버튼 | **주지 않는다** — 만료까지 기다린다 |
| 대기 중 부분 P2P | **잡아두고 잔여를 계속 기다린다.** 만료 시 잔여만 LP |
| 재시도 주기 | 5초 |

---

## 1. DDL — 이미 적용 완료 (다시 하지 마라)

```sql
-- 2026-08-20 운영 적용 완료
ALTER TABLE matching_links     ADD COLUMN match_wait_seconds INT NULL AFTER amount;
ALTER TABLE p2p_deposit_links  ADD COLUMN match_wait_seconds INT NULL AFTER match_mode;
ALTER TABLE p2p_deposit_orders ADD COLUMN match_wait_until DATETIME(6) NULL AFTER match_attempt_count;
INSERT INTO system_settings VALUES ('p2p.match_wait_max_seconds','600', …);
```

정본 DDL 파일에도 반영할 것(문자열만 — **DDL 실행 금지**).

운영 `p2p.match_wait_max_seconds` = **1500(25분)** (2026-08-20 상향, DML 적용 완료).
코드 폴백 기본값은 600 그대로다.

### 확정된 시간 모델 (2026-08-20 판정 — 위 제약 인용구를 대체한다)

P2P 구매는 **구간마다 독립된 시계**를 갖는다. 전체를 관통하는 총 시간은 없다.

```
① 링크 유효   매칭 링크 60분 / P2P 입금 링크 30분
              생성 → 사용(주문 생성) 전까지. 사용되면 관여 끝
② 매칭 구간   주문 생성 → 매칭 성립
              시계 = match_wait_until (대기 옵션 없으면 즉시)
③ 이체 구간   매칭 성립 → 구매자 이체 마감
              시계 = 30분
```

따라서 **대기 상한은 주문 만료 30분과 무관하다** — ②가 ③을 잠식하지 않기 때문이다.

```
p2p_deposit_orders.expires_at = (match_wait_until 있으면 그 시각, 없으면 now) + 30분
p2p_matches.expires_at        = 소속 주문의 expires_at 상속 (없으면 now + 30분 폴백)
```

대기가 없으면 주문 만료는 `now + 30분` 이라 종전과 동일하다.

> ~~**제약**: `p2p.match_wait_max_seconds` 는 주문 만료(30분)보다 **충분히 작아야** 한다.~~
> **폐기(2026-08-20)** — 주문 만료가 대기 종료 기준으로 산정되므로 이 제약은 성립하지 않는다.
> 25분 대기여도 구매자 이체 시간은 온전히 30분이다.

---

## 2. 뼈대는 이미 있다 — 새로 만들지 마라

`matching-worker` 가 이 루프를 이미 갖고 있다. **배포돼 있고 꺼져 있을 뿐이다.**

```
P2pMatchingWorker.poll()               @Scheduled(fixedDelay = poll-interval-ms)
  ① claimPendingOrders(batch)          tx A — PENDING → MATCHING 선점
  ② runMatchAttempt(order)             tx B — tryMatchDeposit (동기 경로와 동일)
  ③ finishAttempt(orderId, maxAttempts) tx C — 성립/미체결 판정, 카운터
P2pMatchingZombieReaper                 claimed_at 오래된 것 회수
```

운영 설정: `P2P_ASYNC_MATCHING_ENABLED=false` (`.env.matching-worker`, `.env.open-api`)

**새 워커·새 스케줄러를 만들지 마라.** 위 루프에 대기 개념을 얹는다.

---

## 3. 대기 만료 산정 — 주문 생성 시 1회

```
waitSeconds =
    링크 경유면  link.match_wait_seconds
    아니면       요청 파라미터 matchWaitSeconds
  → 둘 다 없으면 대기 없음 (match_wait_until = NULL)

⚠️ 위젯 파라미터는 클라이언트가 정한다 — 반드시 서버에서 자른다
   waitSeconds = min(waitSeconds, getIntSetting("p2p.match_wait_max_seconds", 600))
   음수·0 은 대기 없음으로 취급  (운영 상한 1500 = 25분)

match_wait_until = now + waitSeconds
```

**링크가 우선**이다. 링크에 값이 있으면 요청 파라미터는 무시한다 —
파트너가 링크에 건 정책을 클라이언트가 덮을 수 없어야 한다.

### 연동 잠금 — 워커가 꺼져 있으면 대기를 걸지 않는다 (2026-08-20 판정)

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

```
resolveMatchWaitUntil:
  waitSeconds 없음/0 이하           → null (기존)
  p2p.async-matching.enabled=false → null + WARN "대기 옵션이 있으나 워커가 꺼져 무시함"
  그 외                             → min(waitSeconds, p2p.match_wait_max_seconds)
```

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

주문 생성 경로 전부에 적용:
`P2pWidgetController` 직접 매칭 · `MatchingLinkWidgetController` · `P2pDepositLinkWidgetController`

---

## 4. 캐스케이드에 대기 게이트 — 이 작업의 핵심

`P2pMatchingService` 의 Phase 1, **`planTorqLeg` 호출 직전**이다.

```java
// 현재 (개략)
long need = remaining - covered;
… 더스트 분기 …
TorqLegPlan plan = planTorqLeg(order, need);
if (plan == null) {
    if (fillRemainderWithPartner(order, need)) { … MATCHED … }
    else { failUnifiedOrder(order); }
    return MatchPhase1Result.done();
}
… Phase 2 (LP 레그) …
```

여기에 게이트를 넣는다.

```
잔여 need > 0 이고 match_wait_until 이 미래라면
  ① LP 레그를 만들지 않는다        (planTorqLeg 호출 자체를 하지 않는다)
  ② 파트너 레그도 만들지 않는다     (fillRemainderWithPartner 도 아니다)
  ③ failUnifiedOrder 를 부르지 않는다
  ④ 확보한 P2P 레그는 그대로 둔다   (결정: 잡아두고 기다린다)
  ⑤ 주문을 PENDING 으로 되돌린다    → 다음 폴에서 워커가 다시 집는다
```

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

`claimPendingOrders` 는 **PENDING** 만 집는다. `MATCHING` 으로 남기면
좀비 회수 잡이 돌 때까지(기본 5분) 아무도 재시도하지 않는다.
기존 미체결 경로가 쓰는 `releaseDepositOrderToPending` 을 재사용하라.

### ⚠️ 더스트 분기 두 개를 구분한다 (2026-08-20 판정)

게이트는 더스트 분기 **뒤**에 있다. 두 분기의 대기 중 취급이 다르다.

| 분기 | 동작 | 대기 중 |
|---|---|---|
| (a) `isWithinDustAdjustRate` → `adjustOrderDownForDust` | 주문 액면을 잔여만큼 하향 | **그대로 돈다** — 확보한 P2P 레그로 전액 성립. 대기의 목적 그 자체 |
| (b) `need <= getDustFloorKrw()` → `rollbackP2pLegs` | P2P 레그 전부 되돌리고 전액 LP | **건너뛴다** — 대기가 막으려는 동작. 돌면 5초마다 유동성 스래싱 |

(b) 조건에 `!isWaitingForMatch(order)` 를 붙인다. 만료 후에는 (b)도 기존대로 동작한다.

### ⚠️ 예약된 LP 레그 정리

기존 `plan == null` 분기는 `cancelReservedTorqLeg(...)` 를 부른다.
대기 분기에서도 **재개 중 예약 LP 레그가 있으면 정리**해야 고아가 남지 않는다.
다만 대기 중 매 사이클 취소/재예약이 반복되지 않는지 확인하고, 반복되면 보고하라.

---

## 5. 종결 판정 — 대기 중에는 카운터로 죽이지 마라

`P2pAsyncMatchingService.finishAttempt` 는 `attempts >= maxAttempts` 면
`closeDepositOrder(CANCELLED, MATCH_FAILED)` 한다. 기본 `max-attempts: 5`.

```
5초 폴 × 60초 대기 = 12회 시도  →  5회째(25초)에 주문이 취소된다
```

**대기 중(`match_wait_until` 이 미래)이면 임계 종결을 건너뛴다.**

**카운터도 올리지 않는다** (2026-08-20 판정 — 초안의 "카운터는 증가시켜도 된다"를 뒤집는다).
대기 중 폴링은 실패가 아니다. 올리면 대기 사이클당 **2씩**(게이트 release + tx C) 증가해
만료 시점에 재시도 예산이 0 이 되고, LP 가 한 번만 흔들려도 즉시 CANCELLED 된다.

```
게이트 경로   releaseWaitingDepositOrderToPending   카운터 미증가 (신규 매퍼 메서드)
tx C          waiting 이면 attempts 동결 + 위 메서드로 반환
              ⚠️ releaseDepositOrderToPending(카운터 +1)의 기존 계약은 건드리지 않는다
```

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

만료 후에는 기존 규칙 그대로다 — LP 레그가 붙어 성립하거나,
LP 불가(파트너 `torq_enabled=0` 등)면 파트너 레그 → 그것도 안 되면 임계 종결.

## 6. 폴 주기

`p2p.async-matching.poll-interval-ms` 를 **5000** 으로. (현재 1000)
`matching-worker/src/main/resources/application.yml` 기본값을 바꾼다.

---

## 7. 위젯 — 대기 화면

`createMatch` 응답이 **대기 상태**임을 표현해야 한다.
지금은 레그가 붙은 매칭을 전제로 화면을 그리므로, 레그 0개 + 대기 만료 시각을 받으면
"매칭 대기 중 · 남은 시간" 을 보여주고 **5초마다 상태를 폴링**한다.

```
대기 중        매칭 대기 중 · mm:ss 남음        ← '바로 진행' 버튼 없음(결정)
성립           기존 매칭 화면 그대로
만료 후 LP     기존 TORQ 레그 화면 그대로
```

- **응답 필드는 추가만 하라.** 기존 필드 이름·의미를 바꾸면 배포 순서가 얽힌다
- 남은 시간은 **서버가 준 만료 시각**으로 계산한다. 위젯이 자체 타이머로 세지 마라
  (탭 백그라운드·시계 오차)
- 만료 시각이 지났는데도 대기 응답이 오면 그냥 계속 폴링한다 — 다음 사이클에 붙는다

---

## 8. ⚠️ 워커 활성화는 배포와 별개다

이 기능은 `P2P_ASYNC_MATCHING_ENABLED=true` 가 있어야 동작한다.
**코드에서 기본값을 true 로 바꾸지 마라** — env 로 오케스트레이터가 켠다.
꺼져 있으면 대기 옵션은 **조용히 무시**된다(위 §3 연동 잠금) — 켜기 전까지 기능은 사실상 OFF 다.

지금 PENDING 으로 남는 주문이 없으므로(동기 경로가 즉시 해소) 워커를 켜도
**대기 주문 외에는 집을 것이 없다.** 이 성질이 깨지지 않게 하라.

---

## 9. 파트너 콘솔 — 대기 프리셋 (2026-08-20 후속)

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

```
partner-ui/src/views/partner/p2p/MatchingLinksView.vue
partner-ui/src/views/partner/p2p/P2pDepositLinksView.vue
partner-ui/src/api/services/p2p.service.ts        (요청 타입에 matchWaitSeconds 추가)
```

- 서버는 프리셋 집합을 강제하지 않는다 — 기존 클램프(`min(요청, 상한)`)만 유지
- 폼 옆 안내에 **이체 30분은 대기와 별개로 매칭이 끝난 뒤부터 센다**는 점을 명시
- 파트너 API(`CreateMatchingLinkRequest` / `CreateP2pDepositLinkRequest`)는 직전 커밋에서 이미
  `matchWaitSeconds` 를 받는다 — 백엔드 추가 작업 없음

---

## 코딩 규칙

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

## 완료 기준

```
 1  대기 옵션 없음 → 기존 동작 그대로 (코드 경로로 증명: match_wait_until NULL 이면
    게이트를 통과해 planTorqLeg 로 간다)
 2  링크 옵션 > 요청 파라미터 우선순위
 3  ★ 요청 파라미터가 p2p.match_wait_max_seconds 로 잘린다
 4  ★ 대기 중 LP·파트너 레그를 만들지 않고 failUnifiedOrder 도 부르지 않는다
 5  ★ 대기 중 주문이 PENDING 으로 돌아간다 (MATCHING 아님)
 6  ★ 대기 중 maxAttempts 임계 종결을 건너뛴다
 7  확보한 P2P 레그가 대기 중 풀리지 않는다
 8  만료 후 첫 시도에서 LP 레그가 붙는다
 9  poll-interval-ms 기본값 5000
10  워커 enabled 기본값은 false 그대로
11  위젯이 대기 화면 + 5초 폴링. '바로 진행' 버튼 없음
12  ./gradlew compileJava · widget-ui npm run build:prod 통과
13  git commit·push 하지 않았다
```

## 보고 형식

- 수정 파일:라인 + 한 줄
- 1·4·5·6 을 **코드 경로로 증명**
- 대기 사이클 1회의 상태 전이를 순서대로 서술 (PENDING → … → PENDING)
- 지침이 실제 코드와 어긋난 지점 — **고치지 말고 먼저 보고**
- 빌드 결과

## 착수 전 필수 확인

```
core/.../p2p/P2pMatchingService.java              Phase 1 캐스케이드 · planTorqLeg
                                                   · fillRemainderWithPartner · failUnifiedOrder
core/.../p2p/P2pAsyncMatchingService.java         claimPendingOrders · finishAttempt
matching-worker/.../worker/P2pMatchingWorker.java
matching-worker/src/main/resources/application.yml
common/.../mapper/P2pMatchingMapper.java          claimPendingOrdersForUpdate
                                                   · releaseDepositOrderToPending
common/.../entity/P2pDepositOrder.java            matchAttemptCount · claimedAt
core/.../p2p/P2pDepositService.java               createAndMatch (동기 경로)
open-api/.../widget/P2pWidgetController.java      createMatch
open-api/.../widget/MatchingLinkWidgetController.java
open-api/.../widget/P2pDepositLinkWidgetController.java
widget-ui/src/views/p2p.vue                       runRouting · startRouteMatch
```

지침과 다르면 **멈추고 보고하라.**
특히 §4 의 게이트를 캐스케이드 어디에 두어야 하는지가 코드와 다르면 진행하지 말 것.
