# P2P 부분 성립 (Partial Fill) 구현 지침서

> ## ⛔ 현재 전 파트너 OFF — 켜기 전에 이 항목부터 읽을 것 (2026-09-02)
>
> 구현은 `main` 에 들어가 있으나 **운영 `system_settings` 에 `p2p.partial_fill_partner_ids`
> 행 자체가 없어** 전 파트너에서 동작하지 않는다. 그 상태를 전제로 아래 3가지가 **미해결**이다.
> 설정 행을 넣는 순간 전부 살아난다.
>
> 1. **선점과 전제 충돌** — 부분 성립은 "풀에 보이는 것이 곧 재고"라고 가정하는데, 선점
>    (`P2P_RESERVATION_GUIDE.md`)은 그 가정을 최대 30분간 의도적으로 위반한다. 같은 파트너에
>    둘 다 켜면 15분 뒤 전액 성립할 수 있었던 주문이 지금 보이는 재고만큼 깎여 **되돌릴 수 없게**
>    종결된다(30만 요청 → 3만 성립).
> 2. **하향 비율 상한 없음** — 절대 하한(`partial_fill_min_krw`)만 있어 1,000만원 주문이
>    1만원으로 성립 가능하다.
> 3. **구매자에게 하향이 안 보임** — 비동기 매칭의 상태 폴링 경로는 `requestedKrw = null` 이라
>    `amountAdjustedKrw` 가 항상 null 이다. 근본 원인은 `krw_amount` 를 덮어써 원본이 사라지는 것.
>
> ### 왜 코드를 남겨두는가
> TORQ 를 끄고 P2P 만 쓸 때 재고가 모자라면 주문이 통째로 죽는 문제(2026-09-01 요구)에 대한
> 답이었다. 그 뒤 **선점**이 같은 문제를 *사전 확보* 방식으로 더 낫게 풀면서 흔적기관이 됐다.
> 선점의 원칙("목적이 사라지면 다른 판매자로 대체하지 않고 철회한다")과도 어긋난다 —
> 부분 성립은 요청액을 깎아 성립시키는 반대 방향이다.
>
> 남긴 이유는 TORQ 를 끈 상태에서 **선점을 안 타는 일반 구매자** 경로가 존재하기 때문이다.
> 그 경로에서조차 "깎아서 성립"이 맞는지는 **미결**이다 — 전량 취소 후 재시도가 나을 수 있다.
>
> 관련: `P2pMatchingService.isPartialFillAllowed` javadoc 에 같은 내용이 있다.


**작성**: 2026-09-01
**대상 모듈**: `core` — `P2pMatchingService`
**DDL 변경**: 없음 (`system_settings` 행 추가만)

---

## 1. 배경

BARO매칭(파트너 66)은 TORQ LP 를 끄고 **P2P 만으로 운영**하려 한다. 그런데 지금 구조에서
TORQ 를 끄면, P2P 유동성이 일부밖에 없을 때 **주문 전체가 죽는다.**

```
10만원 구매 요청 · P2P 재고 5만원 · torq_enabled=0 · 파트너 서비스계좌 미등록

  P2P 5만원 레그 생성 (covered=50,000, need=50,000)
    → planTorqLeg() = null          (파트너 TORQ 비활성)
    → fillRemainderWithPartner() = false
    → failUnifiedOrder()
         rollbackP2pLegs()          ← 붙었던 5만원까지 되돌린다
         remaining = 액면 10만원 복원
         CANCELLED (UNFILLED_NO_LIQUIDITY)
```

구매자는 "매칭 실패"만 본다. **P2P 재고가 있는데도 한 건도 못 판다.**

원하는 동작: **채운 만큼(5만원)만 입금하게 하고 성립시킨다.**

## 2. 이미 있는 것 — 새로 만들지 말 것

부분 성립 메커니즘은 **이미 구현돼 있다.** "더스트 조정"이라는 이름으로 좁게 열려 있을 뿐이다.

| 이미 있는 것 | 위치 |
|---|---|
| 액면 하향 + 성립 전이 (조건부 UPDATE) | `P2pMatchingMapper.claimDepositOrderMatchedWithFace` |
| 하향 실행 | `P2pMatchingService.adjustOrderDownForDust` |
| 수수료 재계산 | `applyLegFeeSum` — **레그에서 유도**하므로 액면 하향과 자동 정합 |
| 위젯 노출 | `P2pWidgetMatchResponse` 의 `krwAmount` / `requestedKrwAmount` / `amountAdjustedKrw` |

즉 **쓰기 경로·수수료·API 응답이 전부 준비돼 있다.** 막고 있는 건 진입 조건 하나뿐이다:

```java
// p2p.dust_adjust_rate 기본 1(%) — 운영 system_settings 에 행이 없어 기본값 적용 중
if (covered > 0 && isWithinDustAdjustRate(remaining, need)) {   // 임계 = 액면 × 1%
    adjustOrderDownForDust(order, need, covered);
}
```

10만원 주문의 임계는 **1,000원**이다. `need=50,000` 은 50배 초과라 탈락한다.

> ⚠️ **`p2p.dust_adjust_rate` 를 100 으로 올려서 해결하지 말 것.** 전역 설정이라 모든 파트너가
> 부분 성립으로 바뀌고, 이 분기가 `planTorqLeg` **앞**에 있어서 **TORQ 를 쓰는 파트너도
> P2P 가 1원만 붙으면 LP 를 아예 안 타게 된다.** 매출 경로가 조용히 끊긴다.

## 3. 설계 — 실패 직전에만 개입한다

부분 성립을 **`failUnifiedOrder` 바로 앞**에 넣는다. 더스트 분기(TORQ 앞)는 손대지 않는다.

```
P2P 루프 → covered / need
  (a) 더스트 비율 이내      → 액면 하향 + 성립          ← 손대지 않음
  (b) 더스트 바닥 이하      → P2P 폐기 후 전액 LP        ← 손대지 않음
  (c) covered==0 && 대기중  → 대기하며 P2P 재시도        ← 손대지 않음
  (d) planTorqLeg           → LP 레그                    ← 손대지 않음
  (e) fillRemainderWithPartner → 파트너 계좌             ← 손대지 않음
  (f) ★ 부분 성립 (신규)    → 액면을 covered 로 낮추고 성립
  (g) failUnifiedOrder      → CANCELLED
```

이 배치의 이점:

- **TORQ 우선순위가 그대로다.** LP 가 가능하면 LP 가 먼저 간다. 매출 경로가 안 끊긴다.
- **대기(`match_wait_until`)가 그대로다.** (c)가 앞에 있어 대기 시간 동안은 P2P 를 계속 두드리고,
  만료된 뒤에야 부분 성립한다. 즉 **전액 P2P 를 먼저 노리고, 안 되면 부분 성립**한다.
- 바뀌는 건 **`CANCELLED` 이 될 주문뿐**이다. 성공하던 주문의 동작은 하나도 안 변한다.

## 4. 설정 (DDL 없음)

기존 `p2p.unified_matching_partner_ids` 와 **같은 패턴**을 쓴다.

| 키 | 기본값 | 의미 |
|---|---|---|
| `p2p.partial_fill_partner_ids` | `""` (빈 값) | 부분 성립 허용 파트너 CSV. **비면 전체 OFF** — 기존 동작 유지 |
| `p2p.partial_fill_min_krw` | `p2p.min_amount` (10,000) | 하향 후 액면이 이 값 미만이면 부분 성립하지 않고 기존대로 실패 |

기본 OFF 다. 설정 행을 넣기 전까지 운영 동작은 **한 톨도 안 변한다.**

## 5. 구현

### 5-1. 진입 판정 + 하향 실행

`P2pMatchingService` 에 추가한다.

```java
/**
 * 부분 성립 허용 파트너인지 — CSV allowlist.
 *
 * <p>기본 OFF(빈 값). {@code p2p.unified_matching_partner_ids} 와 같은 패턴이다.
 */
private boolean isPartialFillAllowed(Long partnerId) {
    String csv = getStringSetting("p2p.partial_fill_partner_ids", "");
    if (csv == null || csv.isBlank()) return false;
    for (String token : csv.split(",")) {
        if (token.trim().equals(String.valueOf(partnerId))) return true;
    }
    return false;
}

/** 부분 성립 후 남을 액면의 하한. 이보다 작아지면 성립시키지 않는다. */
private int getPartialFillMinKrw() {
    return getIntSetting("p2p.partial_fill_min_krw", getMinAmount());
}

/**
 * 부분 성립 시도 — 채우지 못한 잔여만큼 주문 액면을 낮추고 성립시킨다.
 *
 * <p>{@code failUnifiedOrder} 직전에만 호출한다. 여기 도달했다는 것은 LP·파트너 레그가
 * 모두 불가하고 대기도 끝났다는 뜻이므로, 남은 선택지는 <b>부분 성립</b> 아니면 <b>전량 취소</b>다.
 *
 * @return 성립시켰으면 true. false 면 호출부가 기존대로 {@code failUnifiedOrder} 로 간다
 */
private boolean tryPartialFill(P2pDepositOrder order, long need) {
    if (!isPartialFillAllowed(order.getPartnerId())) return false;

    long after = order.getKrwAmount() - need;
    int floor = getPartialFillMinKrw();
    if (after < floor) {
        log.info("부분 성립 불가 — 하향 후 액면이 하한 미만: order={}, 액면={} → {}, 하한={}",
                order.getOrderCode(), order.getKrwAmount(), after, floor);
        return false;
    }
    return adjustOrderFaceDown(order, need, "부분 성립(LP·파트너 불가)");
}
```

### 5-2. 하향 쓰기 경로 통합

`adjustOrderDownForDust` 를 **사유만 다른 공용 메서드**로 바꾼다. 조건부 UPDATE 를 복제하지 않기
위해서다 — 쓰기 경로가 둘이 되면 한쪽만 고쳐지는 사고가 난다.

```java
/**
 * 주문 액면 하향 + 성립 전이. 확보한 P2P 레그 합이 곧 주문 전액이 되도록 낮춘다.
 *
 * <p><b>하향만 한다.</b> 상향은 구매자가 더 내야 하므로 금지다. 레그는 손대지 않으므로 구매자가
 * 실제 송금할 금액(레그별 카드 합)과 주문 액면이 정확히 일치한다.
 *
 * <p>수수료({@code fee_amount})는 여기서 만지지 않는다 — {@link #applyLegFeeSum} 이 레그에서
 * 유도하므로(호출부가 매칭 직후 호출) 하향 후 액면과 자동 정합한다.
 *
 * @param need   하향 폭 = 채우지 못한 잔여 KRW
 * @param reason 로그용 사유 ("더스트 잔여" / "부분 성립(LP·파트너 불가)")
 * @return 전이 성공 여부 — false 면 그 사이 주문이 종결된 것
 */
private boolean adjustOrderFaceDown(P2pDepositOrder order, long need, String reason) {
    long before = order.getKrwAmount();
    long after = before - need;
    if (matchingMapper.claimDepositOrderMatchedWithFace(order.getId(), after) == 0) {
        log.error("액면 하향 실패 — 이미 PENDING/MATCHING 이 아니다: order={}, status={}, 사유={}",
                order.getOrderCode(), order.getStatus(), reason);
        return false;
    }
    order.setKrwAmount(after);
    order.setRemainingAmount(0L);
    order.setStatus(P2pDepositStatus.MATCHED);
    log.info("주문 액면 하향 — {}: order={}, {}원 → {}원 (조정 {}원)",
            reason, order.getOrderCode(), before, after, need);
    return true;
}
```

기존 더스트 호출부는 이렇게 바꾼다(로그 문구에 비율은 유지):

```java
adjustOrderFaceDown(order, need,
        "더스트 잔여(환율 시차, 허용비율=" + getDustAdjustRate() + "%)");
```

### 5-3. 삽입 지점 — 두 곳

**같은 모양이 두 군데** 있다. 한쪽만 고치면 절반의 주문이 계속 죽는다.

**(1) `matchPhase1` 통합 경로 — `plan == null` 블록**

```java
if (fillRemainderWithPartner(order, need)) {
    markOrderMatched(order);
} else if (isWaitingForMatch(order)) {
    ...기존 대기 보류...
} else if (tryPartialFill(order, need)) {      // ★ 추가
    // 부분 성립으로 살렸다 — 로그는 tryPartialFill 안에서 남긴다
} else {
    failUnifiedOrder(order);
}
```

**(2) `matchPhase3` — LP escrow 미확보 후**

```java
if (fillRemainderWithPartner(fresh, need)) {
    markOrderMatched(fresh);
} else if (fill != null && fill.retryable()) {
    ...기존 일시 실패 보류...
} else if (tryPartialFill(fresh, need)) {      // ★ 추가
    // 부분 성립
} else {
    failUnifiedOrder(fresh);
}
```

> ⚠️ **순서를 지킬 것.** `isWaitingForMatch` / `retryable` 보다 **뒤**에 둔다. 앞에 두면
> 대기 중이거나 일시 실패인 주문까지 조기에 부분 성립시켜, 전액 P2P 로 갈 수 있었던 주문이
> 반토막 난다.

## 6. 완료 기준

- [ ] `./gradlew :core:compileJava` 통과
- [ ] 설정이 비어 있으면(`p2p.partial_fill_partner_ids` 미설정) **기존 동작과 100% 동일** —
      `isPartialFillAllowed` 가 즉시 false 를 반환하는지 확인
- [ ] 액면 하향 쓰기 경로가 **하나뿐**인지 (`claimDepositOrderMatchedWithFace` 호출 1곳)
- [ ] 삽입 지점 2곳 모두 반영
- [ ] `tryPartialFill` 이 `isWaitingForMatch` / `retryable` **뒤**에 있는지
- [ ] 하향 후 액면 < `partial_fill_min_krw` 이면 `false` 반환 후 기존 실패 경로로 가는지

## 7. 배포 후 적용 절차

```sql
-- 1) 하한 먼저 (기본값과 같지만 명시적으로 남긴다)
INSERT INTO system_settings (setting_key, setting_value, description)
VALUES ('p2p.partial_fill_min_krw', '10000', '부분 성립 후 남을 액면의 하한 KRW')
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value);

-- 2) BARO매칭(66)만 켠다
INSERT INTO system_settings (setting_key, setting_value, description)
VALUES ('p2p.partial_fill_partner_ids', '66', '부분 성립 허용 파트너 CSV (빈 값=전체 OFF)')
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value);
```

**되돌리기**: `setting_value = ''` 로 비우면 즉시 기존 동작. 재배포 불필요.

## 8. 검증 후에 TORQ 를 끈다 — 순서 주의

```
1. 구현 → 배포
2. p2p.partial_fill_partner_ids = 66
3. 실제 거래로 부분 성립 확인 (액면 하향 · 레그 합 일치 · 수수료 정합)
4. 위젯 안내 문구 확인 — API 는 requestedKrwAmount / amountAdjustedKrw 를 이미 내려준다.
   widget-ui 가 이걸 실제로 표시하는지 확인하고, 없으면 "요청 10만원 중 5만원 매칭" 안내 추가
5. ★ 그 다음에 UPDATE partners SET torq_enabled = 0 WHERE id = 66;
```

> ☠️ **순서를 뒤집지 말 것.** 부분 성립이 열리기 전에 TORQ 를 끄면, P2P 가 모자란 주문이
> 전부 `failUnifiedOrder` 로 직행한다 — **P2P 유동성이 있는데도 취소된다.**

## 9. 남는 논점 (이번 범위 밖)

- **구매자 경험**: 10만원 결제하러 왔는데 5만원만 매칭되는 것이 받아들일 만한지. 취소하고
  다시 시도할 선택지를 줄 것인지. 지금 구현은 자동 성립이다.
- **매칭 링크와의 정합**: 링크 액면 10만원이 5만원 결제로 소진(`USED`)된다. 남은 5만원을
  다시 팔려면 새 링크가 필요하다 — `p2p-waiting-notifier` 의 "주문당 1회" 정책과 함께 볼 것.
- **부분 성립 통계**: 얼마나 자주, 평균 몇 % 로 하향되는지. `close_reason` 이 아니라 성립이라
  지금은 로그로만 남는다. 지표가 필요하면 별도 컬럼/이벤트를 검토.
