# P2P 매칭 링크 선점(Reservation) 구현 지침서

**작성**: 2026-09-01
**대상**: `common`(DDL·Mapper) · `core`(`P2pMatchingService`, `P2pWithdrawLedgerService`, `MatchingLinkService`) · `partner-api`(선점 엔드포인트 신규) · `scheduler`(만료 잡) · `p2p-waiting-notifier`(매크로)
**DDL 변경**: 있음 — `matching_links` 컬럼 2개

---

## 1. 문제

매크로가 "판매자 소재홍 / 47만원" 을 광고하는 링크를 방에 뿌린다. 구매자가 누르는 순간
**링크는 풀 전체를 다시 뒤진다** — 링크에 판매자가 안 묶여 있기 때문이다.

```java
// matching_links 에 withdraw_order_id 가 없다. 링크가 들고 가는 건 amount 뿐.
1순위  findExactMatchesForUpdate            WHERE 원장잔액 = #{금액}  ORDER BY created_at ASC
2순위  findMatchableWithdrawOrdersForUpdate                         ORDER BY 원장잔액 DESC, created_at ASC
```

매크로가 링크 금액을 **그 판매자의 원장 잔액과 정확히 같게** 만들기 때문에 1순위에 걸려
지금까지 우연히 맞았다. 그 사이 누가 일부라도 사가면 잔액이 바뀌어 1순위가 깨지고,
2순위(**잔액 큰 순**)로 떨어져 **광고한 사람이 아닌 다른 판매자**가 잡힌다.

즉 경합만의 문제가 아니라 **대기자가 둘 이상이면 구조적으로 어긋난다.**

## 2. 결정 (2026-09-01 오너)

| 항목 | 결정 |
|---|---|
| 방식 | **선점** — 링크 발송 전에 출금 주문을 잠근다 |
| 잠금 범위 | **전액** (원장 잔액 전부) |
| 동시 거래 | **1건.** 매크로가 만든 거래(`abc01`)가 진행 중이면 다음 링크를 만들지 않는다 (§5) |
| 다음 타깃 | 거래가 끝나면 그때 찾는다. 오래된 순(FIFO). 문턱 미달이면 대기 |
| 대기 문턱 | **10분** (`watch.wait_seconds: 600`) |
| 선점 유효시간 | **15분** — 링크 만료와 동일 |

### 왜 전액이 안전한가

운영 데이터상 동시 진행 레그가 2개 이상인 출금 주문은 **0건**이다(레그 16개짜리 주문도
전부 순차였다). 실질적으로 한 번에 한 거래만 돈다. 다만 **강제되고 있진 않다** —
`MATCHABLE_WHERE` 에 그런 조건이 없다. 직렬 처리(§5)가 그 제약을 매크로 쪽에서 명시한다.

## 3. 시계 — 분쟁 예산을 갉아먹지 않는다

```
 0분  출금 주문 등록                          ← 자동 마감 30분 시계 시작
10분  매크로 문턱 → ★선점 LOCK → 메시지 발송
      │  (선점 15분 = 링크 유효)
25분  미사용 시 자동 UNLOCK — 판매자에게 5분 남음
      └ 클릭 시 ↓
             주문 생성 + 매칭 성립   ← ③ 이체 15분 (p2p.transfer_deadline_minutes)
             "입금완료" 클릭
                                     ← ④ 확인 10분 (p2p.confirm_deadline_minutes)
             입금 확인 / 초과 시 분쟁
```

**③·④는 매칭 성립 후에 시작한다.** 선점 구간은 그 앞에 통째로 있으므로 아무리 길어도
이체·확인 예산은 온전하다.

**자동 마감(30분)과의 충돌 없음** — `P2pTradingAutoPauseService` javadoc:
> ☠️ 진행 중 레그(CREATED · BANK_PENDING)는 어느 경우에도 건드리지 않는다.
> 마감은 회원 상태만 바꾸므로 이미 성립한 매칭은 그대로 굴러간다.

25분에 클릭해 30분에 마감이 걸려도 그 거래는 끝까지 간다. 마감은 신규 매칭만 막는다.

⚠️ **링크 만료를 60분 → 15분으로 줄여야 한다.** 선점보다 링크가 오래 살면 "링크는 유효한데
잠금은 풀린" 구간이 45분 생기고, 그게 정확히 지금 고치려는 문제다.

## 4. DDL

```sql
ALTER TABLE matching_links
  ADD COLUMN withdraw_order_id BIGINT NULL
      COMMENT '선점한 p2p_withdraw_orders.id (2026-09-01). NULL이면 선점 없는 일반 링크 — 기존 풀 매칭'
      AFTER partner_reference,
  ADD COLUMN reserved_until DATETIME(6) NULL
      COMMENT '선점 만료 시각. 이 시각이 지나면 만료 잡이 예약 LOCK 을 해제하고 링크를 EXPIRED 로 만든다',
  ADD KEY idx_ml_reserved (withdraw_order_id, reserved_until);
```

`v2-docs/CRYPTOMENTS_V2_DDL.sql` 의 `matching_links` 정의에도 같은 컬럼을 반영할 것.

> `p2p_withdraw_entries` 는 **변경 없다.** `match_id` 가 이미 nullable 이라 예약 LOCK 을
> 담을 수 있다.

## 5. 직렬 처리 — 한 번에 한 거래 (2026-09-01 오너 확정)

**대기열을 만들지 않는다.** 매크로는 **동시에 살아 있는 링크를 하나만** 유지한다.
거래가 끝나면 그때 다음 타깃을 찾는다.

```
 IDLE ──(가장 오래된 대기 주문)── 선점 + 링크 발송 ──▶ BUSY
                                                        │
 IDLE ◀── 거래 완료 / 취소 / 링크 15분 만료 ────────────┘
   │
   └ 다음 타깃 있으면 즉시 이어서, 없으면 다음 틱까지 대기
```

### 판정 기준은 `abc01`

매크로가 만드는 모든 링크는 `partnerUserId = abc01`(설정 `matching_link.partner_user_id`)
로 생성된다. 따라서 "매크로가 지금 거래 중인가"는 그 신원 하나로 판정된다.

```sql
-- BUSY 판정 ① 사용 안 된 링크가 살아 있다(선점 유지 중)
SELECT 1 FROM matching_links
 WHERE partner_id = :macroPartnerId AND partner_user_id = 'abc01'
   AND status = 'ACTIVE'

-- BUSY 판정 ② 그 링크로 생긴 거래가 진행 중이다
--   상태 집합은 P2pActiveOrderGuard 와 동일하게 유지할 것 (아래 참조)
SELECT 1 FROM p2p_deposit_orders
 WHERE partner_id = :macroPartnerId AND partner_user_id = 'abc01'
   AND status IN ('PENDING','MATCHING','MATCHED')
```

둘 중 하나라도 있으면 **선점하지 않고 그 틱을 넘긴다.**

### ☠️ 이건 새 제약이 아니다 — 이미 강제되고 있다

`P2pActiveOrderGuard.assertNoActiveOrder(partnerId, partnerUserId)` 가 **같은 신원의 두 번째
주문 생성을 이미 차단**한다:

```java
SELECT o.* FROM p2p_deposit_orders o
 WHERE o.partner_id = #{partnerId} AND o.partner_user_id = #{partnerUserId}
   AND o.status IN ('PENDING','MATCHING','MATCHED')
 ORDER BY o.id DESC LIMIT 1
→ 있으면 ConflictException(1035, P2P_ACTIVE_ORDER_EXISTS)
```

지금 매크로는 이걸 모르고 링크를 계속 뿌린다. 그래서 충돌이 **구매자 화면에서** 터진다.
게다가 재개 경로에는 이미 알려진 사고가 있다 (`P2pActiveOrderGuard` 주석):

> 사용자가 누른 금액과 재개될 주문의 금액이 다를 수 있다 — 운영 실측 **겹침 21쌍 중
> 4쌍(19%)이 불일치**였다. 150,000원을 눌렀는데 안내 없이 50,000원짜리 옛 주문의 계좌
> 화면에 도착하면 얼마를 보내야 하는지 알 수 없다.

**"입금 금액이 달라진다"는 신고의 상당 부분이 이 경로다.** 풀 재조회(§1)만의 문제가 아니다.
직렬 처리는 그 충돌을 **구매자 화면이 아니라 매크로 쪽에서** 흡수한다.

> ⚠️ 상태 집합 `('PENDING','MATCHING','MATCHED')` 은 `P2pMatchingMapper.findActiveOrder` 에서
> 그대로 옮긴 것이다. 한쪽이 바뀌면 다른 쪽도 바꿔야 매크로가 가드보다 느슨해지지 않는다.

### 처리량 25분/건은 비용이 아니라 선택이다 (2026-09-01 오너)

한 거래가 최대 **이체 15분 + 확인 10분 = 25분**, 링크 미사용 시 15분이다. 즉 매크로 경유
거래는 **시간당 2~3건**이 상한이다.

**이 낮은 처리량은 의도된 것이다.** 성능 문제로 보고 나중에 "최적화"하지 말 것 — 오너 판정:

> 마구 매칭되어 놓치거나 정신 없는 경우가 많다. 경합되는 게 더 문제이고, 빠르게 판단하려면
> 차라리 이 방식이 낫다. 어차피 다른 입금 경합들이 들어온다.

근거는 셋이다.

1. **입금 확인은 사람이 한다.** ④ 확인 구간 10분은 회원이 은행 앱을 보고 누르는 시간이다.
   동시에 여러 건이 걸리면 사람이 놓치고, 놓치면 그대로 분쟁이다. 직렬이면 한 번에 하나만
   판단하면 된다.
2. **경합 자체가 더 큰 손해다.** 처리량을 올려 봐야 §1·§5 의 어긋남(다른 판매자·다른 금액,
   재개 19% 금액 불일치)이 같이 늘어난다. 빨리 여러 건 처리하는 것보다 한 건을 확실히
   끝내는 쪽이 낫다.
3. **매크로가 유일한 입구가 아니다.** 위젯 직접 진입 등 다른 경로의 입금이 계속 들어오므로,
   전체 거래량이 매크로 처리량에 묶이지 않는다. 매크로는 텔레그램 채널 하나를 담당할 뿐이다.

FIFO(`ORDER BY created_at ASC`)라 오래 기다린 사람이 먼저 나간다.

### 그래도 늘려야 한다면

`abc01` 을 여러 개로 쪼개면(`abc01`, `abc02`, …) 병렬도가 올라간다 — 가드가 신원 단위라
신원을 늘리면 그만큼 동시 거래가 가능하다.

☠️ **다만 그 순간 직렬 보장이 통째로 깨진다.** 위 세 근거가 전부 무효가 되므로, 늘리기
전에 판매자 단위 충돌 방지(원래 검토했던 회원별 대기열)를 먼저 넣어야 한다. 신원만 늘리고
끝내면 §1 의 어긋남이 그대로 돌아온다.

## 6. 예약 원장 — 이중 차감을 만들지 마라

### 6-1. 적재

`P2pWithdrawLedgerService` 에 추가한다. 기존 `lock()` 은 `matchId == null` 이면 예외를 던지므로
그대로 못 쓴다.

```java
/**
 * RESERVE — 매칭 링크 선점. {@code match_id = NULL} 인 LOCK 이다.
 *
 * <p>정식 LOCK 과 같은 타입을 쓰는 이유: 원장 잔액 계산({@code SUM(amount_krw)})이 하나뿐이라
 * 새 타입을 만들면 잔액을 읽는 모든 곳을 고쳐야 한다. 구분은 {@code memo} 로 한다.
 */
public void reserve(Long orderId, long krwAmount, String linkCode) {
    if (orderId == null || linkCode == null) throw new IllegalStateException(...);
    append(orderId, P2pWithdrawEntryType.LOCK, -krwAmount, null, "RESERVE:" + linkCode);
}

/** 선점 해제 — 만료·취소 시. 적재한 예약분을 그대로 되돌린다. */
public void unreserve(Long orderId, long krwAmount, String linkCode) {
    append(orderId, P2pWithdrawEntryType.UNLOCK, krwAmount, null, "RESERVE_RELEASE:" + linkCode);
}
```

#### ☠️ 중복 예약은 DB 가 막아주지 않는다

`p2p_withdraw_entries` 의 유니크 키는 이렇다:

```sql
UNIQUE KEY uk_order_match_type (withdraw_order_id, match_id, type)
```

DDL 주석이 직접 경고한다:

> CHARGE/RELEASE 는 `match_id` 가 NULL 이고 MySQL 은 NULL 을 유니크 대상에서 제외하므로
> **주문당 1행 보장이 안 된다** — 앱에서 보장하고, 재발행 필요 시 별도 가드를 둔다.

**매칭 경로는 이미 안전하다** — 오해하지 말 것. `createMatch` 는 `matchRepo.save()` 로 받은
`matchId` 를 달아 `lock(orderId, matchId, krw)` 하므로 `match_id` 가 NOT NULL 이고, 후보 선정
자체가 `FOR UPDATE OF o` 안에서 돈다. 이중 잠금이 구조적으로 불가능하다.

문제는 **예약이 매칭이 만드는 행이 아니라는 것**이다. 예약 LOCK 은 §7-1 의 신규 엔드포인트
(`/matching-links/reserve`)가 만들고, `match_id = NULL` 이다.

```
매칭 경로:  FOR UPDATE ✅  +  UNIQUE(match_id NOT NULL) ✅
예약 경로:  FOR UPDATE ❌  +  UNIQUE(match_id = NULL → 무효) ❌   ← 신규, 스스로 가드를 들고 와야 한다
```

같은 출금 주문에 선점 요청이 두 번 오면 **둘 다 통과**한다. 이중 잠금이 되어 원장 잔액이
음수로 간다.

#### 도달 경로 — 검증한 것과 아닌 것

| 경로 | 판정 |
|---|---|
| **매크로 타임아웃 후 재시도** | **실재. 가장 현실적이다** (아래) |
| 매크로 인스턴스 겹침 | 가능. systemd 재시작·수동 실행이 겹치는 경우 |
| ~~파트너 콘솔 이중 클릭~~ | **아니다.** `partner-ui/src/api/services/p2p.service.ts` 는 `POST /api/partner/matching-links` 만 호출한다. 선점 엔드포인트는 UI 를 새로 만들지 않는 한 콘솔에서 도달 불가 |

매크로는 HTTP 예외(타임아웃 포함)를 실패로 보고 그 주문을 `mark_handled` 하지 않는다.
그러면 **다음 틱에 같은 주문으로 다시 요청**한다.

```python
except requests.RequestException as exc:
    raise PartnerApiError(...)   # 타임아웃도 여기 — 상태 기록 없이 실패 처리
```

그런데 **서버는 커밋에 성공했을 수 있다.** 운영 실측으로 로그인+링크 생성이 40초 걸린 적이
있다(22:00:29 → 22:01:09). 클라이언트 타임아웃 20초를 넘는다. 응답만 못 받고 예약은 걸린
상태에서 다음 틱이 또 요청하는 것이 **가장 흔한 중복 경로**다.

#### ☠️ 409 로 막지 마라 — 멱등이 답이다

`countLiveReservations > 0 → 409` 로 거절하면 재시도가 계속 409 를 받는다. 예약은 걸려
있는데 매크로는 링크 코드를 모르니 **메시지를 못 보내고 판매자 돈만 15분 묶인다.**
막으려던 것보다 나쁜 결과다.

**살아 있는 예약이 있으면 그 링크를 그대로 돌려준다** (200, 새로 만들지 않음):

```java
// 선점 트랜잭션 안에서 — 출금 주문 FOR UPDATE 이후
MatchingLink existing = linkRepository.findLiveReservation(withdrawOrderId);
if (existing != null) {
    // 멱등 — 타임아웃 재시도가 여기로 온다. 두 번째 예약을 만들지 않고 첫 결과를 돌려준다.
    log.info("선점 멱등 반환 — 이미 살아 있는 예약: order={}, link={}",
            withdrawOrderId, existing.getLinkCode());
    return MatchingLinkResponse.from(existing, widgetBaseUrl);
}
```

```java
/** 이 출금 주문의 살아 있는 선점 링크. 없으면 null. */
@Select("SELECT * FROM matching_links "
      + " WHERE withdraw_order_id = #{withdrawOrderId} "
      + "   AND status = 'ACTIVE' AND reserved_until > NOW() "
      + " ORDER BY id DESC LIMIT 1")
MatchingLink findLiveReservation(@Param("withdrawOrderId") Long withdrawOrderId);
```

⚠️ **반드시 출금 주문 `FOR UPDATE` 를 잡은 뒤에 조회한다.** 잠금 없이 보면 두 요청이 동시에
`null` 을 읽고 둘 다 예약을 넣는다 — 정확히 막으려던 상황이다.

원장 쪽 방어선도 같이 둔다. 링크는 없는데 예약 엔트리만 남은 불일치(예: 링크 저장 실패)를
잡기 위해서다:

```java
/** 이 주문에 살아 있는 예약(매칭에 귀속되지 않은 RESERVE LOCK) 수. */
@Select("SELECT COUNT(*) FROM p2p_withdraw_entries "
      + " WHERE withdraw_order_id = #{orderId} AND type = 'LOCK' "
      + "   AND match_id IS NULL AND memo LIKE 'RESERVE:%'")
int countLiveReservations(@Param("orderId") Long orderId);
```

여기서 0 이 아닌데 위 링크 조회가 `null` 이면 **데이터가 어긋난 것**이다. 새 예약을 만들지
말고 오류로 올려 사람이 보게 한다 — 조용히 하나 더 넣으면 잠금이 두 배가 된다.

`p2p_partner_locks` 쓰기를 폐기하며(2026-08-21) 잠금 계열이 정리됐는데, 예약은 그 흐름을
거슬러 다시 만드는 것이라 가드를 스스로 들고 와야 한다.

### 6-2. 매칭 성립 시 — **새 LOCK 을 추가하지 마라**

☠️ **이 작업에서 가장 깨지기 쉬운 지점이다.** 예약으로 이미 −R 이 잡혀 있는데 매칭이
정식 LOCK −X 를 또 넣으면 **이중 차감**이다.

전환은 **기존 예약 행에 `match_id` 를 채우는 UPDATE 하나**로 한다.

```java
/**
 * 예약 LOCK 을 매칭에 귀속시킨다. 새 LOCK 을 넣지 않는다(이중 차감 방지).
 * @return 귀속된 행 수 — 0 이면 예약이 이미 해제된 것(호출부가 실패 처리)
 */
@Update("UPDATE p2p_withdraw_entries SET match_id = #{matchId}, memo = CONCAT(memo, ' → MATCH') "
      + " WHERE withdraw_order_id = #{orderId} AND type = 'LOCK' AND match_id IS NULL "
      + "   AND memo = CONCAT('RESERVE:', #{linkCode})")
int attachReservationToMatch(@Param("orderId") Long orderId,
                             @Param("matchId") Long matchId,
                             @Param("linkCode") String linkCode);
```

매칭액 `X` 가 예약액 `R` 보다 작으면(부분 성립 등) 차액은 기존 오버로드로 되돌린다:

```java
if (X < R) ledger.unlock(orderId, matchId, R - X, R - X);
```

### 6-3. 후보 조회 — 예약분을 되살려 본다

예약으로 원장 잔액이 0 이 되므로 **기존 후보 쿼리가 이 주문을 못 찾는다.** 링크 경유
매칭 전용 조회를 새로 둔다.

```java
/**
 * 선점된 출금 주문 조회 (비관적 잠금) — 링크 경유 매칭 전용.
 *
 * <p>잔액 조건을 쓰지 않는다. 대신 <b>이 링크의 살아 있는 예약 LOCK</b> 을 확인하고
 * 그 금액을 가용액으로 본다. 자격 게이트({@code MATCHABLE_JOIN}/{@code MATCHABLE_WHERE})는
 * 그대로 통과해야 한다 — 선점 후 파트너 토글이 꺼졌거나 회원이 거래중지됐을 수 있다.
 */
@Select("SELECT o.* FROM p2p_withdraw_orders o "
      + MATCHABLE_JOIN
      + " WHERE o.id = #{withdrawOrderId} "
      + "   AND EXISTS (SELECT 1 FROM p2p_withdraw_entries e "
      + "                WHERE e.withdraw_order_id = o.id AND e.type = 'LOCK' "
      + "                  AND e.match_id IS NULL AND e.memo = CONCAT('RESERVE:', #{linkCode})) "
      + MATCHABLE_WHERE
      + " FOR UPDATE OF o")
P2pWithdrawOrder findReservedForUpdate(@Param("withdrawOrderId") Long withdrawOrderId,
                                       @Param("linkCode") String linkCode,
                                       @Param("depositRootId") Long depositRootId,
                                       @Param("depositCrossAllowed") int depositCrossAllowed);
```

## 7. 흐름

### 7-1. 선점 — **별도 오퍼레이션이다**

```
POST /api/partner/matching-links/reserve      ← 신규
Body: { withdrawOrderId, expiresMinutes?, matchWaitSeconds?, title?, partnerReference? }
```

☠️ **기존 `POST /api/partner/matching-links` 에 파라미터를 얹지 마라.** 두 작업은 이름만
비슷하지 하는 일이 다르다:

| | 일반 링크 생성 | 선점 링크 생성 |
|---|---|---|
| 하는 일 | 링크 행 INSERT | **판매자 유동성을 얼린다** + 링크 행 INSERT |
| 트랜잭션 | 단건 INSERT | 출금 주문 `FOR UPDATE` ~ 링크 저장까지 한 덩어리 |
| 중복 가드 | 불필요 | **필수** (§6-1) |
| 실패 | 파트너 게이트 위반 정도 | 잔액 0 · 이미 선점됨 · 자격 상실 등 |
| 되돌리기 | 링크만 죽이면 끝 | 예약 해제까지 해야 잠금이 안 샌다 |

한 엔드포인트에 얹으면 파라미터 하나 차이로 **판매자 돈을 얼리는 경로**에 들어가게 된다.
분리하면 기존 경로는 **한 줄도 안 건드려도 되고**(파트너 콘솔 회귀 위험 0), 트랜잭션·가드·
에러 코드가 이 오퍼레이션 안에 갇힌다.

절차 — **전부 한 트랜잭션**:

1. 출금 주문 `SELECT ... FOR UPDATE` — 자격 게이트 재확인 (요청 시점과 달라졌을 수 있다)
2. **멱등 확인** — 살아 있는 선점 링크가 있으면 **그것을 그대로 반환**하고 종료 (§6-1).
   새 예약을 만들지 않는다. 매크로 타임아웃 재시도가 여기로 온다
3. 원장 잔액 `R` 조회. `R <= 0` 이면 409
4. `ledger.reserve(orderId, R, linkCode)` — 전액
5. 링크 저장 — `withdraw_order_id`, `amount = R`, `reserved_until = now + 15분`,
   `expires_at = reserved_until`

**예약만 되고 링크가 안 생기면 잠금이 샌다.** 1~5 가 한 트랜잭션이어야 하는 이유다.
반대로 링크만 생기고 예약이 없으면 §1 의 어긋남이 그대로 돌아온다.

### 7-2. 사용 (구매자 클릭)

`MatchingLinkService.useLink` 에서 `link.withdrawOrderId != null` 이면:

1. `reserved_until` 경과 → `MATCHING_LINK_EXPIRED`
2. `findReservedForUpdate` 로 대상 확보. 못 찾으면(자격 상실·예약 해제됨) → **기존 풀 매칭으로 폴백**
   하고 그 사실을 로그에 남긴다. 거래는 성사시키되 선점은 포기한다
3. 주문 생성 → 그 주문에만 `createMatch`
4. `attachReservationToMatch` 로 예약을 매칭에 귀속. **0 이면 롤백하고 실패 처리**
5. 링크 `USED`

### 7-3. 만료 해제 (scheduler)

`MatchingLinkReservationExpiryJob` — 매 1분.

```
SELECT * FROM matching_links
 WHERE status = 'ACTIVE' AND withdraw_order_id IS NOT NULL
   AND reserved_until <= NOW()
→ ledger.unreserve(withdraw_order_id, 예약액, link_code)
→ link.status = 'EXPIRED'
```

**매크로가 죽어도 해제돼야 하므로 반드시 백엔드 잡이다.** 예약액은 해당 RESERVE 엔트리의
`-amount_krw` 를 읽어 쓴다(링크 `amount` 를 믿지 말 것 — 어긋나면 잠금이 샌다).

## 8. 매크로 변경

| 항목 | 변경 |
|---|---|
| BUSY 판정 | §5 — `abc01` 의 살아 있는 링크·진행 중 주문이 있으면 그 틱을 넘긴다 |
| 대상 선택 | 문턱(10분) 통과분 중 `created_at ASC` 첫 건 하나만 |
| 링크 생성 | `POST /api/partner/matching-links/reserve` 로 변경, `withdrawOrderId` 전달 |
| 링크 만료 | `expires_minutes: 60 → 15` |
| 메시지 | 선점됐으므로 판매자·금액이 **보장된다** — 문구를 그렇게 바꿔도 된다 |
| 실패 처리 | 선점 실패(409)는 오류가 아니다. 로그만 남기고 다음 틱에 재시도 |

## 9. 완료 기준

- [ ] `./gradlew :common:compileJava :core:compileJava :scheduler:compileJava` 통과
- [ ] 기존 `POST /api/partner/matching-links` 가 **한 줄도 안 바뀌었는지** (§7-1)
- [ ] `withdraw_order_id IS NULL` 인 기존 링크의 동작이 **100% 동일**
- [ ] 예약 → 매칭 경로에서 `p2p_withdraw_entries` 에 LOCK 이 **1행만** 생기는지 (이중 차감 없음)
- [ ] 예약 후 매칭 없이 15분 경과 → 원장 잔액이 **정확히 복원**되는지
- [ ] **같은 요청을 두 번 보내면 같은 링크가 돌아오는지** (멱등) — 409 가 아니라 200 이어야
      한다. DB UNIQUE 는 예약을 안 막는다(§6-1)
- [ ] 링크는 없는데 예약 엔트리만 있는 불일치에서 **새 예약을 만들지 않고 오류로 올리는지**
- [ ] 예약 중인 주문이 일반 위젯 구매자에게 **안 보이는지** (잔액 0)
- [ ] `abc01` 의 거래가 진행 중일 때 매크로가 **새 링크를 만들지 않는지**
- [ ] 거래 종결 직후 다음 타깃으로 **즉시 이어지는지** (다음 틱)
- [ ] 선점 대상이 자격을 잃은 경우(파트너 토글 OFF·거래중지) 폴백하고 링크가 죽지 않는지

## 10. 취소 시 재진입 — 기존 동작을 그대로 둔다

초안에서는 "선점 매칭의 취소 = 거래 종료, 재진입 없음" 으로 잡았다. **철회한다.**
코드를 확인한 결과 그 방어가 필요 없고, 필요한 것보다 비싸다.

### 현재 취소 경로는 셋이고 전부 복원한다

| 경로 | 트리거 | 처리 | 출금 주문 |
|---|---|---|---|
| ③ 이체 초과 | 구매자가 "입금완료" 를 안 누름 | `P2pMatchExpiryJob` → 매칭 취소 | **복원 → 재진입** |
| ④ 확인 초과 | 이체했다고 눌렀으나 확인 안 됨 | **분쟁** → 관리자 CANCEL → `forceCancelDisputedLeg` | **복원 → 재진입** |
| 명시적 취소 | 이체 화면의 취소 버튼 | `doCancelMatch` | **복원 → 재진입** |

```java
// forceCancelDisputedLeg / doCancelMatch 공통
wo.setStatus(newMatched == 0 ? P2pWithdrawStatus.PENDING : P2pWithdrawStatus.PARTIALLY_MATCHED);
withdrawRepo.modify(wo);
withdrawLedger.unlock(...);
```

> ③ 은 분쟁으로 가지 않는다. 이체를 아예 안 하면 바로 매칭 취소다 — 분쟁은 ④ 에서만 열린다
> (만료 매칭을 분쟁으로 되살리는 경로는 2026-08-14 에 폐기됐다). 결과는 같다.

### 왜 그대로 둬도 되나 — §5 직렬 처리가 이미 막는다

"재진입 없음" 이 필요했던 이유는 **낡은 링크와 되살아난 주문이 공존**하는 상황 때문이었다.
직렬 처리에서는 그 상황이 생기지 않는다.

```
선점 → 링크 발송 → 클릭 → 매칭 → 취소
                                  ↓ 출금 주문 복원 (기존 동작)
                        매크로 BUSY 해제 → 다음 타깃 탐색
                        → 그 주문이 다시 최우선 (제일 오래 기다렸으므로 FIFO 선두)
                        → 새로 선점 + 새 링크
```

옛 링크는 클릭 시점에 이미 `USED` 로 소진됐다. 떠도는 링크가 없다.
링크가 미사용으로 15분 만료된 경우도 같다 — 예약이 풀리고 주문이 풀에 남으며, 매크로가
다음 틱에 다시 선점한다.

### 그래서 이 지침서는 취소 경로를 건드리지 않는다

세 경로를 모두 뜯어 "선점 출신 레그만 복원 안 함" 분기를 넣는 변경은 **하지 않는다.**
회원에게 "재등록해야 매칭됩니다" 안내를 추가할 필요도 없다 — 자동으로 다시 팔린다.

> 판매자 자금은 어느 경로에서든 UNLOCK 으로 돌아온다. 취소로 손실이 나지 않는다.

## 11. 남은 논점

- **부분 성립과의 관계**: `P2P_PARTIAL_FILL_GUIDE.md` 참조. 선점이 전액이라 발동 여지가
  크게 준다. 다만 사라지지는 않는다 — 선점을 안 타는 일반 위젯 구매자, 선점 15분 만료 후
  들어온 요청, 취소 후 재등록된 주문의 일반 매칭에는 여전히 필요하다.
- **DDL 적용 시점**: `matching_links` 컬럼 2개는 **구현 착수 시 적용**한다(2026-09-01 결정).
  설계 승인 전에 운영 스키마를 바꾸지 않는다. `CRYPTOMENTS_V2_DDL.sql` 에는 이미 반영돼 있다.
- **선점 통계**: 뿌린 링크 중 몇 %가 실제로 사용되는지. 사용률이 낮으면 유동성을 헛되이
  묶는 것이므로 선점 시간(15분)을 줄여야 한다. 취소율도 함께 봐야 한다 — §10 정책상
  취소는 판매자에게 재등록 부담을 주므로, 취소가 잦으면 정책 재검토가 필요하다.
