# P2P 출금 원장 — 전 표면 영향 매트릭스

> 작성 2026-08-14. 백엔드 코어 · admin · partner · widget · 회원 포털 전수 조사 결과.
> 대체 대상 7개 컬럼: `matched_amount` · `confirmed_amount` · `refunded_amount` · `refund_type` ·
> `remainder_resolution` · `remainder_krw` · `remainder_usdt`
>
> 조사 계기 — "회원 페이지만 영향받는 게 아닐 것" 이라는 지적. **맞았다.**

---

## 0. 결론 3줄

```
① T1-a 에 CHARGE 누락이 있었다 — 운영 주문 36건 전부가 누락된 경로다 (수정 완료)
② confirmed_amount 는 원장으로 재현할 수 없다 — 대체 계획이 아예 없었다
③ 잔여 산식 하드코딩이 5개 파일에 흩어져 있고, 상수 공유는 그룹 게이트뿐이다
```

---

## 1. ☠️ CHARGE 누락 — T1-a 의 실제 결함 (수정 완료)

`P2pWithdrawOrder` 생성 경로가 **둘**인데 지침서가 하나만 지목했다.

```
P2pWithdrawService.createOrder:142       CHARGE 적재 ✓   운영 실적 0건
WithdrawalService.approveAsP2p:1179      적재 없음  ✗   운영 실적 36건 (전부)
```

**내가 CHARGE 를 붙인 경로는 운영에서 한 번도 쓰이지 않았고, 실제로 쓰이는 유일한 경로엔 없었다.**
그대로 배포했으면 모든 신규 주문이 `CHARGE` 없이 `LOCK` 만 쌓여 잔액이 항상 음수가 되고, T1-a 검증 쿼리("원장 == 컬럼")가 100% 실패했을 것이다.

> **리뷰 실패다.** 적재 지점 11곳을 확인하고 합격을 냈지만, **생성 경로가 몇 개인지**를 묻지 않았다.
> 지침서가 지목한 곳만 대조했고 지침서 자체가 틀렸다.
> **교훈: "지침서대로 됐나" 가 아니라 "지침서가 옳았나" 를 함께 봐야 한다.**

수정: `WithdrawalService` 에 `P2pWithdrawLedgerService` 주입 + `charge(orderId, krwAmount)`.

---

## 2. ☠️ `confirmed_amount` — 원장으로 재현 불가

원장 항목은 `CHARGE / LOCK / UNLOCK / RELEASE` 넷이고, 이들의 합은 **"지금 매칭 가능한 잔액"**(≈ `krw − matched`)만 재현한다. `confirmed_amount`(은행 입금 **확인**액)는 매칭 성립과 별개 축이라 **원장 SUM 으로 계산할 수 없다.**

설계 문서는 "`confirmed_amount` 가 필요하면 SETTLED 매칭의 합으로 유도한다"고 했는데, 그것은 **`p2p_matches` 에서 유도**한다는 뜻이지 원장에서 나오는 값이 아니다. 두 정본이 다르다.

**이 값에 의존하는 곳 (전부 금액 표시·판정)**

| 위치 | 용도 |
|---|---|
| `P2pWithdrawService.computeRemainder:591` | `krw − confirmed` = **강제정산·잔여취소·USDT전환의 실지급액** |
| `P2pWithdrawService.requestUsdtConvert:481` / `approveUsdtConvert:549` | `matched != confirmed` → 전환 요청·승인 차단 게이트 |
| `P2pMatchingService.confirmBankTransfer:1052` | 초과 확인 차단 (이중지급 최종 방어선 N2) |
| `P2pMatchingService.confirmBankTransfer:1081` | `confirmed >= krw` → **`SETTLING` 전이** |
| `P2pOrderSearchMapper:153` (admin) | 출금 주문 상세 "잔여 KRW" |
| `P2pMemberSearchMapper:92` (admin) | 회원 상세 "입금확정 KRW" |
| `P2pWithdrawOrderResponse:120` (partner) | `remainingAmount` 서버 계산 |
| `P2pWithdrawPageController.dashboard` (회원) | 히어로 "받음" |
| `P2pMemberDetailView.vue:50` (partner-ui) | "출금 대기 금액" 클라이언트 재계산 |

**결정 필요 — T1-c 착수 전.**

```
A. confirmed_amount 컬럼을 남긴다 (원장은 잔액만 담당)
B. 원장에 CONFIRM 항목 타입을 추가한다 (잔액 축과 확인 축을 한 원장에)
C. p2p_matches SETTLED 합으로 유도한다 (매번 조인)
```

> 설계 문서 §5 는 이 셋 중 무엇인지 명시하지 않았다. **A 가 가장 안전해 보인다** — 원장은 "매칭 가능액" 한 가지만 책임지게 두고, 확인액은 별도 축으로 남기는 것. 다만 그러면 "7개 컬럼 전부 제거"라는 T1-e 목표가 6개로 줄어든다.

---

## 3. 잔여 산식 하드코딩 — 5개 파일, 상수 공유 없음

`(krw_amount - matched_amount)` 가 **각 파일에 따로 박혀 있다.**

| 파일 | 지점 수 | 상수 공유 |
|---|---|---|
| `common/mapper/P2pMatchingMapper` | 3 (89 · 119 · 148) | — (원본) |
| `partner-api/mapper/PartnerP2pPoolMapper` | 8 | **그룹 게이트만** 공유. 잔여 산식은 하드코딩 |
| `admin-api/mapper/P2pWithdrawPoolMapper` | 8 | **아무것도 공유 안 함** (게이트 6개 누락 상태) |
| `admin-api/mapper/P2pMemberSearchMapper` | 2 | — |
| `core/P2pMatchingService` (Java) | 4 (314 · 397 · 1208 · 상태전이 6곳) | — |

`GROUP_SCOPE_JOIN/WHERE` 는 상수 참조로 구조적으로 보장되는데, **정작 금액 산식은 사람이 5곳을 동시에 고쳐야 한다.**

> ⚠️ `P2pMatchingService:1208` (`checkRoute` 의 Java 스트림 합산)은 **매퍼가 아니라 서비스 레이어**라
> "게이트 4곳" 체크리스트에서 빠지기 쉽다. 이 값이 위젯의 **P2P/TORQ 라우팅 분기**를 결정한다.

---

## 3-1. ☠️ 매칭 후보 검색 — SQL 만 바꾸면 조용히 어긋난다

**후보 조회는 루프 밖에서 한 번만 호출되고, 루프는 그 스냅샷을 쓴다.**

```java
for (P2pWithdrawOrder wo : matchingMapper.findMatchableWithdrawOrdersForUpdate(...)) {
    ...
    long available = wo.getKrwAmount() - wo.getMatchedAmount();   // ← 엔티티 컬럼에서 재계산
    long legAmount = Math.min(available, remaining - covered);
    long leftoverAfter = available - legAmount;
    if (leftoverAfter > 0 && leftoverAfter < minAmount) { ... }   // 더스트 방지 판정
```

SQL 을 원장 SUM 으로 바꿔도 **이 계산은 여전히 컬럼을 읽는다.** 그리고 이 값이 **레그 금액과 더스트 판정**을 결정한다.

### 같은 패턴 3곳 — 전부 컴파일이 통과한다

| 위치 | 무엇을 결정하는가 |
|---|---|
| `P2pMatchingService:314` | 레거시 경로 `tryMatchDeposit` 의 available |
| `P2pMatchingService:397` | **현행 통합 경로** — `legAmount` · 더스트 방지 |
| `P2pMatchingService:1208` | `checkRoute` 합산 → **위젯의 P2P/TORQ 분기** |

엔티티에 `matchedAmount` 가 살아 있는 한 **컴파일 에러가 안 난다.** T1-e(컬럼 제거)에 가서야 잡힌다. 그 사이 구간이 위험하다 — 필터는 원장, 계산은 컬럼.

### `findExactMatchesForUpdate` 는 더 조용하다

```sql
AND (o.krw_amount - o.matched_amount) = #{amount}    -- 등식
```

값이 조금만 어긋나도 **1:1 완전매칭이 0건이 되고 전량 분할 경로로 샌다.** 에러도, 로그도 없다. 매칭은 계속 성립하니 아무도 모른다.

### 권고 — SQL 이 잔여를 계산해 돌려준다

Java 가 재계산하지 않게 만드는 것이 유일한 구조적 해결이다.

```
findMatchableWithdrawOrdersForUpdate → P2pWithdrawOrder 대신
                                        available_krw 를 포함한 별도 데이터 클래스 반환
```

> CLAUDE.md 규칙상 **Entity 에 `@XIgnoreColumn` 으로 파생값을 붙일 수 없다** — JOIN·파생 결과는 별도 클래스로 분리해야 한다. 후보 조회 3종이 각각 대응 클래스를 갖는 편이 안전하다.

이렇게 하면 "SQL 이 정본, Java 는 소비"가 되어 §3 의 하드코딩 5곳 문제도 후보 조회 범위에서는 해소된다.

### SQL 자체의 주의점

```
ORDER BY (SELECT SUM ...) DESC     인덱스를 못 탄다 → filesort
                                    지금 35주문·주문당 4~5행이라 무시할 만하나 FOR UPDATE 하의 뜨거운 경로
FOR UPDATE OF o                    유지할 것 — 없으면 서브쿼리가 읽는 원장 행까지 잠긴다
LIMIT 10                           잔여 조건은 반드시 SQL 안에. Java 후처리로 거르면
                                    상위 10건이 전부 걸러져 후보 0건(기아) — 주석에 이미 경고돼 있다
&lt; 금지                          <script> 안에서 `<` 는 XML 마크업 → 기동 시 전 서비스 다운
```

---

## 4. 표면별 영향 요약

| 표면 | 읽는 컬럼 | 클라이언트 재계산 | T1-c 영향 | T1-e 영향 |
|---|---|---|---|---|
| **매칭 엔진** | matched | — | **후보 조회 4곳 + Java 4곳 전환** | — |
| **정산** | 없음 | — | 없음 (`p2p_matches` 정본) | 없음 |
| **분쟁** | matched (취소 복원 1곳) | — | 낮음 | 낮음 |
| **스케줄러** | 없음 (서비스 위임) | — | 없음 | 없음 |
| **USDT 전환** | matched · confirmed · remainder_* | — | **`computeRemainder` 공식 재정의 필요** | 실지급액 직결 |
| **회원 포털 (p2p-ui)** | matched · confirmed · remainder_* | **있음** (대시보드 서버 / 상세 클라이언트) | **값 흔들림** | 히어로 3값 + 지난거래 깨짐 |
| **admin-ui** | matched · confirmed · refunded · refund_type | **있음** (`progressPct`, `withdrawRemaining`) | 진행률바 · 풀 현황 | 상세·목록·회원 집계 깨짐 |
| **partner-ui** | matched · confirmed | **있음** (`remainKrw`, `matchPercent`) | 회원 상세 히어로 · 목록 진행률 | 동일 |
| **widget-ui** | 없음 (직접) | — | **간접** — `availableKrw` 로 P2P/TORQ 분기 | 없음 |

### 새로 발견 — partner-ui 도 클라이언트 재계산을 한다

지금까지 회원 포털만 지적했는데, **파트너 콘솔에도 같은 패턴이 있다.**

```js
// P2pMemberDetailView.vue:50-52  — 주석에 "P2pWithdrawPageController와 동일 공식" 명시
const remainKrw = (o) => Math.max(0, o.krwAmount - (o.confirmedAmount ?? 0))

// P2pWithdrawOrdersView.vue:35-38
matchPercent = round(row.matchedAmount / row.krwAmount * 100)
```

같은 공식을 **서버·회원 포털·파트너 콘솔 세 곳이 각자 복제**하고 있다. 한 곳만 고치면 화면마다 숫자가 갈린다.

### `admin-api P2pWithdrawPoolMapper` — 이중 위험

게이트 6개(`krw_enabled`·`p2p_withdraw_enabled`·`status=ACTIVE`·`trading_paused`·`usdt_convert_status`·GROUP_SCOPE)가 **전부 빠진 독립 SQL 사본**이다. 원장 전환 시 상수 공유가 없어 자동으로 안 따라오고, 누락되면 **기존 게이트 결함 + 신규 원장 불일치가 동시에** 생긴다.

### `refund_type` — 원장 `memo` 와 값 집합이 다르다

```
refund_type            CANCEL · FORCE_SETTLE · USDT_WITHDRAW
RELEASE.memo           CANCELLED · FORCE_SETTLED · CONVERTED   (P2pRemainderResolution)
```

`admin-ui` 가 `refund_type` 을 **버튼 노출 조건**(이미 처리됐는지 판정)에도 쓴다. 매핑을 놓치면 **이미 처리된 주문에 강제정산 버튼이 다시 뜬다.**

---

## 5. 계획 수정

```
T1-a  ✅ + CHARGE 누락 수정 (approveAsP2p)
T1-b  과거 35건 재구성 — CHARGE 누락 수정 후에야 의미 있음
      ⚠️ 착수 전 §2 결정 필요 (confirmed_amount 를 어떻게 할 것인가)
T1-c  읽기 전환 — 잔여 산식 5개 파일 + checkRoute Java 스트림 동시 전환
T1-d  회원 포털 + **파트너 콘솔** + admin 콘솔 (셋 다 클라이언트 재계산 보유)
T1-e  컬럼 제거 — confirmed_amount 는 §2 결정에 따라 제외될 수 있음
```

**T1-d 범위가 늘었다** — 회원 포털만이 아니라 파트너 콘솔·관리자 콘솔까지 세 프론트다.

---

## 6. 착수 전 결정 필요

| # | 항목 | 왜 지금 |
|---|---|---|
| 1 | **`confirmed_amount` 귀착** (§2 A/B/C) | T1-b 재구성 설계가 여기 걸린다 |
| 2 | **후보 조회가 잔여를 돌려줄 것인가** (§3-1) | Java 재계산 3곳을 구조로 없앨 유일한 방법. 별도 데이터 클래스 3개 신설 여부 |
| 3 | 잔여 산식을 상수로 공유할 것인가 | 5개 파일 동시 수정을 구조로 막을지 |
| 4 | `refund_type` ↔ `RELEASE.memo` 매핑 | admin 버튼 노출 판정이 걸려 있다 |
| 5 | `admin P2pWithdrawPoolMapper` 게이트 정상화를 T1-c 에 포함할지 | 별건으로 두면 계속 어긋난 채 남는다 |

---

## 7. 요약 — 원장 전환이 조용히 틀릴 수 있는 지점

에러도 로그도 없이 결과만 달라지는 것들이다. **전부 T1-c 에서 한 번에 다뤄야 한다.**

```
후보 루프의 available 재계산 3곳     레그 금액·더스트 판정·라우팅 분기가 바뀐다
findExactMatchesForUpdate 등식       1:1 매칭이 0건이 되고 전량 분할로 샌다
ORDER BY 잔여 DESC                   정렬이 바뀌면 매칭 상대가 바뀐다
partner/admin/회원 3개 프론트 재계산  화면마다 숫자가 갈린다
admin P2pWithdrawPoolMapper          독립 사본이라 자동으로 안 따라온다
```

공통 원인은 하나다 — **같은 사실을 여러 곳이 각자 계산한다.** 재설계 6종이 지목한 그 결함이 원장 전환 과정에서 그대로 재현될 수 있다.
