# T1 구현 지침서 — P2P 출금 원장 (`p2p_withdraw_entries`) + `network_id` 저장

> 작성 2026-08-14. 대상: 구현 서브에이전트.
> 설계 원본 [P2P_WITHDRAW_LIQUIDITY_DESIGN.md](./P2P_WITHDRAW_LIQUIDITY_DESIGN.md) ·
> 순서 [P2P_REDESIGN_IMPLEMENTATION_PLAN.md](./P2P_REDESIGN_IMPLEMENTATION_PLAN.md)
>
> **이 지침서는 T1-a 만 다룬다** — 병행 기록(이중 기록)까지. 과거 재구성(T1-b)·읽기 전환(T1-c)·
> 컬럼 제거(T1-d)는 T1-a 검증 통과 후 별도 지침서로 발행한다.

---

## 0. 이 단계에서 하는 것 / 하지 않는 것

```
한다      원장 테이블에 CHARGE/LOCK/UNLOCK/RELEASE 를 쌓는다
          p2p_matches.network_id 를 매칭 생성 시점에 저장한다
          기존 컬럼(matched_amount 등)도 그대로 갱신한다  ← 이중 기록

하지 않는다  읽기 전환 (잔여 계산은 계속 기존 컬럼을 본다)
            과거 데이터 재구성
            컬럼 제거
            후보 조회 쿼리 변경
```

**배포해도 동작이 하나도 바뀌면 안 된다.** 원장은 쌓이기만 하고 아무도 읽지 않는다. 그 상태에서 원장 값과 기존 컬럼이 일치하는지 확인한 뒤 다음 단계로 간다.

> ⚠️ **선행: DDL v2.10 운영 적용** (`DDL_V2_10_MIGRATION.sql`). 미적용 상태에서 엔티티를 배포하면
> Axim `IXRepository` 가 없는 컬럼을 SELECT 해 **그 테이블을 읽는 모든 쿼리가 실패한다**(2026-08-13 장애).

---

## 1. 신설 파일

```
common/entity/P2pWithdrawEntry.java          @XEntity("p2p_withdraw_entries")
common/repository/P2pWithdrawEntryRepository.java
common/enums/P2pWithdrawEntryType.java       CHARGE / LOCK / UNLOCK / RELEASE
core/p2p/P2pWithdrawLedgerService.java       적재 전담 (아래 §3)
```

### 엔티티 규칙 (CLAUDE.md 준수)

```java
@XEntity("p2p_withdraw_entries")
@Getter @Setter @Builder(toBuilder = true)
@NoArgsConstructor @AllArgsConstructor
public class P2pWithdrawEntry {
    @XColumn(value = "id", isPrimaryKey = true, isAutoIncrement = true)
    private Long id;

    @XColumn("withdraw_order_id") private Long withdrawOrderId;
    @XColumn("type")              private P2pWithdrawEntryType type;
    @XColumn("amount_krw")        private Long amountKrw;   // 부호 있는 값
    @XColumn("match_id")          private Long matchId;     // CHARGE/RELEASE 는 null
    @XColumn("round")             private Integer round;    // 기본 0
    @XColumn("memo")              private String memo;

    @XColumn(value = "created_at", insert = false, update = false)
    private LocalDateTime createdAt;
}
```

`@Data` 금지. DTO 멤버에는 JavaDoc 필수.

---

## 2. 부호 규약 — 틀리면 잔액이 뒤집힌다

| type | 시점 | 부호 | `match_id` |
|---|---|---|---|
| `CHARGE` | 출금 주문 생성 | **+** `krw_amount` 전액 | null |
| `LOCK` | 매칭 생성 · 분쟁 재잠금 | **−** 매칭 `krw_amount` | 매칭 |
| `UNLOCK` | 매칭 실패·취소 | **+** 매칭 `krw_amount` | 매칭 |
| `RELEASE` | 주문 종결 시 잔여 인출 | **−** 잔여 | null |

**잔액 = `SUM(amount_krw)` = 지금 매칭 가능한 금액.** 이 등식이 기존 `krw_amount - matched_amount` 와 같아야 한다(§6 검증).

---

## 3. `P2pWithdrawLedgerService` — 적재 전담

**적재를 서비스 한 곳에 모은다.** 지금 `matched_amount` 갱신이 10곳에 흩어져 각자 `Math.max(0, ...)` 를 재구현하고 있는데, 그것을 그대로 복제하면 안 된다.

```java
@Service
public class P2pWithdrawLedgerService {
    void charge(Long orderId, long krwAmount);
    void lock(Long orderId, Long matchId, long krwAmount);     // round 자동 산정
    void unlock(Long orderId, Long matchId, long krwAmount);   // 직전 LOCK 의 round 사용
    void release(Long orderId, long remainderKrw, String memo);
    long balance(Long orderId);                                 // SUM — T1-a 에선 검증용만
}
```

### `round` 산정 — 이 단계의 핵심 함정

같은 매칭이 여러 번 잠긴다.

```
LOCK(r0) → UNLOCK(r0, 만료·실패) → LOCK(r1, 분쟁 재잠금) → UNLOCK(r1, 판정 CANCEL)
```

```
lock()    round = (해당 match_id 의 기존 LOCK 행 수)
unlock()  round = (해당 match_id 의 기존 LOCK 행 수) - 1   ← 짝을 맞춘다
```

`UNIQUE(withdraw_order_id, match_id, type, round)` 가 같은 회차의 중복만 막고 정당한 재잠금은 통과시킨다.

> 원 설계 문서의 `UNIQUE(order, match, type)` 는 **재잠금에서 깨진다.** 2026-08-14 에 정정했다.

### 예외 정책

**적재 실패가 비즈니스 흐름을 막으면 안 된다** — T1-a 는 이중 기록 단계이고, 정본은 아직 기존 컬럼이다.

```java
try { ledger.lock(...); }
catch (Exception e) { log.error("출금 원장 적재 실패(무시): ...", e); }
```

단 **`log.error` 로 남긴다.** T1-c(읽기 전환) 전에 이 로그가 0이어야 넘어간다.

> ⚠️ 이번 세션에 같은 형태의 사고가 있었다 — 조기 재전송이 실패해도 로그를 안 남겨 관측 사각이 됐다.
> 삼키더라도 **반드시 흔적을 남긴다.**

---

## 4. 적재 지점 — 기존 코드에 끼워 넣는다

기존 `matched_amount` 갱신 **바로 옆**에 원장 적재를 추가한다. 기존 로직은 **건드리지 않는다.**

### 4-1. CHARGE

| 위치 | 처리 |
|---|---|
| `P2pWithdrawService.createOrder` (~147, `.matchedAmount(0L)` 근처) | 저장 후 `charge(orderId, krwAmount)` |

주문당 1행. `save()` 가 PK 를 반환하므로 그 ID 로 적재한다(Axim `save()` 는 엔티티가 아니라 **Long** 반환).

### 4-2. LOCK

| 위치 | 비고 |
|---|---|
| `P2pMatchingService.createMatch` (~954, `wo.setMatchedAmount(+krw)`) | P2P 레그 정상 경로 |
| `P2pMatchingService.submitDispute` (~1533) | **분쟁 재잠금** — `relocked == true` 일 때만 |
| `P2pMatchingService.submitWithdrawerDispute` (~1649) | 동일 |

> `relocked == false`(재잠금 실패, `relock_failed=1`)면 **적재하지 않는다.** 잠금이 실제로 안 걸렸다.

### 4-3. UNLOCK

`matched_amount` 를 되돌리는 곳 전부. 실측 위치:

```
failUnifiedOrder            ~601
failMatch                   ~723-725
doCancelMatch               ~1382-1384
forceCancelDisputedLeg      ~1807-1808
cancelActiveMatchesForWithdrawOrder ~1924-1925
failExpiredMatch            ~2010-2012
```

**각 지점에서 실제로 되돌린 금액**을 적는다. 기존 코드가 `Math.max(0, matched - krw)` 로 클램프하므로, **클램프로 깎인 경우 실제 차이만큼만** 적재해야 원장과 컬럼이 일치한다.

```java
long before = wo.getMatchedAmount();
long after  = Math.max(0L, before - match.getKrwAmount());
wo.setMatchedAmount(after);
// ...
ledger.unlock(wo.getId(), match.getId(), before - after);   // ← 클램프 반영된 실제 값
```

> 클램프가 발동했다는 것 자체가 이상 신호다. `before - after != match.getKrwAmount()` 면 **WARN 로그**를 남긴다 — T1-b(재구성)에서 규명 대상이 된다.

### 4-4. RELEASE

| 위치 | memo |
|---|---|
| `P2pWithdrawService.forceSettle` (~320) | `FORCE_SETTLED` |
| `P2pWithdrawService.cancelRemaining` (~362) | `CANCELLED` |
| `P2pWithdrawService.completeDirectWithdrawal` (~438) | `CONVERTED` |

셋 다 `applyRemainderResolution` 을 호출하므로 **그 헬퍼 안에서 한 번만** 적재하는 것이 안전하다(3곳 중복 방지).

---

## 5. `network_id` 저장 (같은 트랜잭션에서 함께)

`p2p_matches.network_id` 를 매칭 생성 시점에 고정한다. **저장만 한다 — 읽기 전환은 T4.**

| 생성부 | 값 |
|---|---|
| `createMatch` (~930-945 빌더) | `wo.getNetworkId()` |
| `createPartnerLeg` (~672 빌더) | 호출부가 계산한 `networkId` — **지금 인자로 받고 버리고 있다** |
| `fillRemainderWithTorq` (~543 빌더) | `lpNetworkOf(lpProvider, ...)` 결과 (이미 지역변수에 있음) |

`createPartnerLeg` 는 `fillRemainderWithPartner` 가 `order.getNetworkId() ?: DEFAULT_NETWORK_ID` 로 계산해 넘기는데 빌더에 안 넣는다. **인자를 실제로 쓰면 된다.**

---

## 6. 완료 기준

```
① compileJava 그린 — :common :core :partner-api :open-api :scheduler
② 기존 동작 무변경 — 잔여 계산·매칭 성립·정산 경로 전부 기존 컬럼을 그대로 읽는다
③ 신규 매칭의 network_id 가 NOT NULL
④ 원장 잔액 == 기존 컬럼 (신규 주문 기준)
```

### 검증 쿼리 (배포 후, 신규 거래 발생 시)

```sql
-- 원장 잔액 vs 기존 컬럼 — 신규 주문에서 차이가 0 이어야 한다
SELECT o.id, o.order_code,
       (o.krw_amount - o.matched_amount)              AS by_column,
       (SELECT COALESCE(SUM(e.amount_krw),0)
          FROM p2p_withdraw_entries e WHERE e.withdraw_order_id = o.id) AS by_ledger
  FROM p2p_withdraw_orders o
 WHERE o.created_at >= '<배포시각>'
HAVING by_column != by_ledger;
-- 0행이어야 한다

-- 재잠금이 정상 적재되는지 (분쟁 발생 시)
SELECT withdraw_order_id, match_id, type, round, amount_krw, created_at
  FROM p2p_withdraw_entries WHERE match_id IS NOT NULL ORDER BY match_id, id;

-- network_id 미채움 (배포 후 생성분 0 이어야 함)
SELECT COUNT(*) FROM p2p_matches WHERE network_id IS NULL AND created_at >= '<배포시각>';
```

---

## 7. 금지 사항 (위반 시 즉시 반려)

```
MyBatis @Select <script> 안의 <  ·  <=  ·  <>
    → 기동 시점 SAXParseException 으로 전 서비스 다운 (2026-06-11 실장애)
    → 미만/이하는 &lt; / &lt;= 또는 <![CDATA[ ]]>

ORDER BY / LIMIT / COUNT 직접 작성 (XResultInterceptor 가 자동 처리)
XML 매퍼 (interface + @Select 만)
Entity 에 @XIgnoreColumn 으로 JOIN 데이터 추가
@Data (개별 Lombok 어노테이션 사용)
기존 잔여 계산 로직 변경 (T1-c 범위다)
IXRepository.save() 반환을 엔티티로 취급 (Long PK 를 반환한다)
```

---

## 8. 참고 — 왜 이 단계를 쪼갰나

원장은 최종적으로 `matched_amount` 등 7개 컬럼을 대체한다. 그런데 그 컬럼들을 읽는 곳이 후보 조회 쿼리 4개를 포함해 광범위하고, **후보 조회는 매칭 성립 여부를 직접 좌우한다.**

쓰기(T1-a)와 읽기(T1-c)를 같이 바꾸면, 문제가 생겼을 때 원장 적재가 틀린 것인지 읽기가 틀린 것인지 구분할 수 없다. 이중 기록 구간을 두고 **두 값이 일치하는 것을 실거래로 확인한 뒤** 읽기를 넘긴다.
