# P2P 밸런싱 포함 범위 분석 핸드오프

- 검증일: 2026-09-21
- 작업 소유자: M
- 성격: 읽기 전용 코드·스키마·테스트 분석
- 변경 사항: 없음
- 테스트 실행: 없음. 기존 테스트 코드와 호출 경로만 검토함

## 1. 다음 세션에서 할 일

이 문서는 파트너별 P2P 입금·출금 밸런싱 한도의 현재 동작과 VA 입금 및 원화 출금의 포함 여부를 조사한 결과다.

다음 세션에서는 아래 제품 결정을 먼저 확정한다.

1. 밸런싱의 범위를 `P2P 매칭 재고`로 한정할지, 모든 원화 입출금 채널로 확장할지 결정한다.
2. 모든 원화 입출금이 대상이라면 원화 출금을 기존 `D/W` 회전에 포함하는 설계를 확정한다.
3. VA 경로에서 발견된 세 가지 정합성·원자성 위험을 별도 결함으로 처리할지 결정한다.

## 2. 한 줄 결론

- **VA 입금은 입금 측 밸런싱에 포함된다.** VA leg가 `pending_deposit_krw`를 예약하고 정산 완료 시 `D -> W`로 이동한다.
- **원화 출금은 현재 밸런싱에 포함되지 않는다.** `pending_withdraw_krw` 또는 `withdraw_capacity_krw`를 예약·소비하지 않으며 별도의 체인별 USDT 원장과 원화 정산 흐름을 사용한다.
- 기존 스키마가 `W`를 P2P 출금 용량으로 정의하므로 현재 구현은 원래 정의와 일치한다. 다만 제품 정의가 모든 원화 입출금의 밸런싱이라면 기능 공백이다.

## 3. 현재 회계 모델

파트너별 값은 다음과 같다.

- `C = total_limit_krw`
- `D = deposit_capacity_krw`
- `W = withdraw_capacity_krw`
- `PD = pending_deposit_krw`
- `PW = pending_withdraw_krw`
- 입금 가용량: `max(0, D - PD)`
- 출금 가용량: `max(0, W - PW)`
- 정산 완료 기준 불변식: `D + W = C`

한도 행이 없거나 `C <= 0`이면 무제한으로 취급한다.

초기 설정 및 증액 시 증가분이 `C`와 `D`에 더해진다. `W`는 보통 0에서 시작하며 감액은 지원하지 않는다.

금액 `A`에 대한 변화는 다음과 같다.

| 동작 | 변화 |
|---|---|
| 입금 측 예약 | `PD += A` |
| 출금 측 예약 | `PW += A` |
| 예약 해제 | 해당 pending만 `A`만큼 감소 |
| 입금 측 소비 | `PD -= A; D -= A; W += A` |
| 출금 측 소비 | `PW -= A; W -= A; D += A` |

근거:

- `matching-engine/src/main/java/com/cryptoments/matchingengine/port/BalancingLimitPort.java`
- `matching-engine/src/main/java/com/cryptoments/matchingengine/adapter/JdbcBalancingLimitAdapter.java`
- `core/src/main/java/com/cryptoments/core/p2p/P2pBalancingLimitService.java`
- `common/src/main/java/com/cryptoments/common/mapper/P2pBalancingLimitMapper.java`
- `v2-docs/migrations/2026-09-10-p2p-balancing-limit.sql`

## 4. 잠금·멱등성

`capacity()`는 잠금 없는 사전 검사라 최신 값이 아닐 수 있다. 최종 판정은 `reserve()`가 수행한다.

`reserve()`의 핵심 순서는 다음과 같다.

1. P2P leg일 때만 `p2p_withdraw_orders`에서 출금 파트너를 해석한다.
2. `leg_id` 기준 예약 행을 잠근다.
3. 영향을 받는 파트너 행을 `partner_id` 오름차순으로 잠근다.
4. 잠금 상태에서 가용량을 다시 검사한다.
5. 예약을 생성하거나 `RELEASED` 예약을 다시 `HELD`로 전환한다.
6. 해당 pending 카운터를 증가시킨다.

`p2p_balancing_reservations.leg_id`는 유일 키다. 예약 상태는 `HELD`, `CONSUMED`, `RELEASED`이며 재시도 시 `RELEASED -> HELD`가 가능하다.

중복 hold를 막는 결정적 leg ID 입력은 다음과 같다.

- P2P: `(sessionId, reservationId)`
- LP: `(sessionId, residual, 정렬된 anchor IDs)`
- VA: `(sessionId, vapSessionId)`

## 5. 흐름별 포함 범위

| 흐름 | 입금 측 | 출금 측 | 예약 | 해제 | 소비 |
|---|---:|---:|---|---|---|
| P2P 매칭 leg | 포함 | 포함 | 판매자 유동성 예약 후 로컬 leg 부착 전 | 부착 실패, 확인 보상, 종료, 레거시 `CANCELLED`/`FAILED` | 레거시 매치 `SETTLED`; 양쪽 파트너를 반대 방향으로 회전 |
| LP leg | 포함 | 미포함 | LP 용량·예약 호출 전 | 미예약 결과, 예외, 저장 실패, 보상, 종료 | LP 매치 `SETTLED`; 입금 파트너 `D -> W` |
| VA 입금 leg | 포함 | 미포함 | 발급된 VA 세션을 등록하면서 로컬 leg 부착 전 | 부착 거부, 확인 보상, VAP 해제 후 종료 | VAP 매치 `SETTLED`; 입금 파트너 `D -> W` |
| 원화 출금 | 미포함 | 미포함 | 밸런싱 예약 없음 | 밸런싱 해제 없음 | 밸런싱 소비 없음. 별도 USDT 원장·`krw_settlements` 사용 |

예약 진입점:

- P2P: `matching-engine/.../application/P2pMatchingPhase.java`
- LP: `matching-engine/.../application/LpReservationCoordinator.java`
- VA: `matching-engine/.../application/MatchingSessionCommandService.java`

## 6. 종료 상태 처리

번들 확인 단계가 `HELD` 예약의 `leg_id`를 Core가 반환한 레거시 `p2p_matches.id`와 연결한다.

정기 프로젝션은 다음처럼 처리한다.

- `SETTLED` -> `consume`
- `CANCELLED` 또는 `FAILED` -> `release`
- `DISPUTED`와 진행 중 상태 -> 계속 hold
- `p2p_match_id`가 연결되지 않은 예약 -> 프로젝션 대상이 되지 않음

이미 `RELEASED`였던 leg가 늦게 `SETTLED`로 살아나면 `consumeAfterRelease()`가 pending을 다시 빼지 않고 `D/W`만 이동한다.

근거:

- `matching-engine/src/main/java/com/cryptoments/matchingengine/application/BundleConfirmationService.java`
- `matching-engine/src/main/java/com/cryptoments/matchingengine/application/BalancingSettlementProjection.java`
- `matching-engine/src/main/java/com/cryptoments/matchingengine/adapter/JdbcBalancingLimitAdapter.java`

## 7. VA 입금 포함 증명

VA 경로는 다음과 같이 연결된다.

1. 세션 생성·라우팅 사전 검사에서 입금 파트너의 입금 가용량을 확인한다.
2. VA 등록 시 결정적 leg ID를 만들고 `reserve(... MatchSource.VAP ..., depositPartnerId, null)`를 호출한다.
3. JDBC 어댑터는 `MatchSource.P2P`일 때만 출금 파트너를 계산하므로 VAP는 입금 측만 예약한다.
4. Core가 `VAP`를 레거시 leg 유형으로 매핑하고 `p2p_matches` 행을 만든 뒤 leg-to-match ID 매핑을 반환한다.
5. VAP 정산 완료 시 레거시 매치를 `SETTLED`로 닫는다.
6. 공통 밸런싱 프로젝션이 VA의 입금 hold를 소비해 `D -> W`로 이동한다.

주요 근거 파일:

- `matching-engine/src/main/java/com/cryptoments/matchingengine/application/MatchingSessionCommandService.java`
- `matching-engine/src/main/java/com/cryptoments/matchingengine/application/MatchingRoutingWorkHandler.java`
- `common/src/main/java/com/cryptoments/common/enums/P2pLegType.java`
- `core/src/main/java/com/cryptoments/core/p2p/P2pLiquidityReservationService.java`
- `core/src/main/java/com/cryptoments/core/vap/VapLegCloser.java`

테스트 근거:

- `matching-engine/src/test/java/com/cryptoments/matchingengine/application/VaBalancingLimitTest.java`
- `core/src/test/java/com/cryptoments/core/vap/VapLegTypeMirrorTest.java`
- `core/src/test/java/com/cryptoments/core/vap/VapLegCloserTest.java`
- `matching-engine/src/test/java/com/cryptoments/matchingengine/application/BalancingSettlementProjectionTest.java`
- `matching-engine/src/test/java/com/cryptoments/matchingengine/adapter/JdbcBalancingLimitAdapterTest.java`

## 8. 원화 출금 제외 증명

요청 경로는 다음과 같다.

```text
KrwWithdrawalV1Controller.create
  -> OpenApiKrwFacade.create
  -> KrwWithdrawService.request
  -> KrwWithdrawTxService.debitAndCreate
```

이 경로는 다음을 수행한다.

- 파트너·통화·네트워크 단위 원장 파티션 잠금
- 원금 USDT와 수수료 계산
- 체인별 원장 가용액 검사
- `withdrawals`에 `KRW_WITHDRAW` 유형 생성
- `KRW_WITHDRAW` 원금 및 `KRW_WITHDRAW_FEE` 차감
- 별도 `krw_withdraw_orders` 생성
- 반려, 회수, TTL 만료, 지급 실패 시 USDT 원장 환불
- 원화 지급 완료 후 `krw_settlements` 생성

저장소 전체 참조 검색 결과:

- KRW 서비스, facade, controller, mapper, order entity에서 밸런싱 타입·테이블·카운터 참조가 없다.
- matching-engine 운영 코드와 테스트에 `KrwWithdraw`, `KRW_WITHDRAW`, `krw_withdraw` 참조가 없다.
- 원화 출금의 DB 모델은 `v2-docs/KRW_OFFRAMP_DDL.sql`에 정의된 별도 모델이다.

주요 근거 파일:

- `open-api/src/main/java/com/cryptoments/openapi/controller/v1/KrwWithdrawalV1Controller.java`
- `open-api/src/main/java/com/cryptoments/openapi/service/OpenApiKrwFacade.java`
- `core/src/main/java/com/cryptoments/core/krw/KrwWithdrawService.java`
- `core/src/main/java/com/cryptoments/core/krw/KrwWithdrawTxService.java`
- `common/src/main/java/com/cryptoments/common/entity/KrwWithdrawOrder.java`
- `common/src/main/java/com/cryptoments/common/entity/KrwSettlement.java`
- `v2-docs/KRW_OFFRAMP_DDL.sql`

## 9. 혼동하면 안 되는 네 가지 잔액

1. **P2P 밸런싱 카운터**: KRW, 파트너 단위, 체인 독립. 매칭 라우팅을 제한한다.
2. **USDT 원장 가용액**: 파트너·통화·네트워크 단위. 원장 잔액에서 일반 출금 pending과 수금 pending을 차감한다.
3. **MASTER 지갑 온체인 스냅샷**: 실제 지갑의 캐시된 물리 잔액. 원화 출금 요청 경로는 MASTER 지갑 존재를 확인하지만 조사한 신청 경로에서는 `wallet_balances`를 읽지 않는다.
4. **Provider/VAP 유동성**: VA 세션 발급 또는 원화 지급이 가능한 외부 유동성. Provider 승인만으로 P2P 밸런싱 카운터가 바뀌지는 않는다.

따라서 USDT 원장 검사를 통과했다는 사실은 P2P 출금 밸런싱 한도를 검사했다는 뜻이 아니다.

## 10. 확인된 설계·구현 위험

### 10.1 원화 출금이 `W`를 우회한다

밸런싱을 모든 원화 입출금으로 정의한다면 다음 문제가 생긴다.

- `W = 0`이어도 원화 출금을 신청할 수 있다. USDT 원장과 provider 조건만 적용된다.
- VA 입금은 계속 `D -> W`로 이동하지만 원화 출금은 `W -> D`로 되돌리지 않는다.
- VA 입금 비중이 큰 파트너는 동등한 원화 출금을 수행해도 `D`가 복원되지 않아 이후 입금이 막힐 수 있다.
- 누적된 `W`는 원화 오프램프를 제한하지 않는다.

반대로 밸런싱을 P2P 매칭 재고로 한정한다면 현재 동작은 의도와 일치한다.

### 10.2 VA의 match ID 연결이 fail-closed가 아니다

Core가 leg-to-match 매핑을 누락하면:

- P2P는 확인을 실패시킨다.
- LP는 escrow guard가 잡는다.
- VAP는 경고만 남기고 계속 진행한다.

VAP도 밸런싱 hold를 사용하므로 `p2p_match_id = NULL`로 남으면 프로젝션이 해제하거나 소비할 수 없는 hold가 된다.

### 10.3 VA 실제 입금액과 예약액이 다를 수 있다

VA 과소·과입금 시 `p2p_matches.krw_amount`는 실제 수령액으로 바뀐다. 그러나 밸런싱 정산은 원래 `p2p_balancing_reservations.amount_krw`를 사용한다.

- 과소입금: 실제보다 많은 금액을 `D -> W`로 이동
- 과입금: 실제보다 적은 금액만 이동
- 지연 정산 후 부활 경로에도 같은 문제가 존재

올바른 모델은 pending 해제에 사용할 `held amount`와 실제 `D/W` 이동에 사용할 `settled amount`를 분리해야 한다.

### 10.4 `consumeAfterRelease()`에 트랜잭션 경계가 없다

이 메서드는 행 잠금, 용량 이동, 예약 상태 갱신을 수행하지만 하나의 트랜잭션으로 묶여 있지 않다.

- 두 스케줄러가 모두 `RELEASED`를 보고 각각 `D/W`를 이동할 수 있다.
- 용량 이동 후 상태 갱신 전에 프로세스가 중단되면 다음 tick에서 다시 이동할 수 있다.

현재 순차 단위 테스트는 프로세스 내 멱등성만 증명하며 DB 원자성을 증명하지 않는다.

### 10.5 테스트·문서 공백

- VAP hold 생성부터 Core 매핑, bind, `SETTLED`, 최종 `D/W` 이동까지 한 번에 검증하는 통합 테스트가 없다.
- 예약 DDL 주석의 source 목록은 `P2P | LP`만 적혀 있지만 운영 코드는 `VAP`도 저장한다.

## 11. 선택지

### 선택지 A: P2P 전용으로 유지

- `W`를 `P2P 매칭 출금 용량`으로 명확히 이름 붙이고 관리자 화면과 문서에 범위를 명시한다.
- 원화 출금은 현재처럼 USDT 원장, MASTER 유동성, VAP/provider 통제로 관리한다.
- 구현 위험은 가장 낮지만 모든 원화 입출금을 밸런싱한다는 제품 정의는 충족하지 않는다.

### 선택지 B: 원화 출금을 동일한 `D/W` 회전에 포함

모든 원화 입출금이 대상이라는 제품 정의가 맞다면 권장한다.

- 결정적 원화 출금 주문 ID로 `PW`를 예약한다.
- 반려, 회수, TTL 만료, 지급 실패 등 전액 환불 경로에서 예약을 해제한다.
- provider가 원화 지급 완료를 보고한 시점에 정확히 한 번 `W -> D`로 소비한다.
- 밸런싱 금액은 USDT 수수료가 아니라 `krw_amount`를 사용한다.
- 요청 시점이나 온체인 정산 완료 시점에 중복 소비하면 안 된다.

### 선택지 C: 원화 오프램프 한도를 별도로 둠

- P2P 밸런싱은 그대로 유지한다.
- 원화 지급의 outstanding 또는 일일 한도를 별도 모델로 관리한다.
- 목적이 매칭 재고 복원이 아니라 provider·treasury 위험 제한이라면 이쪽이 더 적합하다.

## 12. 구현 전 제품 결정

1. `W`가 매칭된 P2P 매도만 의미하는지, 모든 원화 지급을 의미하는지.
2. 원화 출금 소비 시점을 provider 승인, 실제 원화 지급 완료, 온체인 완료 중 어디로 볼지. 현금 유출 사건과 맞추려면 실제 원화 지급 완료가 가장 자연스럽다.
3. 밸런싱 카운터를 계속 체인 독립으로 유지할지.
4. 기존 원화 출금을 백필할지, 특정 cutover 이후 건부터 포함할지.
5. 원장 차감, 밸런싱 예약, 지급 완료, 온체인 정산 중 어느 컴포넌트가 단 하나의 멱등 소비를 소유할지.

기존 원화 출금을 반영하지 않고 새 정의를 적용하면 과거 원화 출금만큼 `W`가 높고 `D`가 낮은 상태에서 시작할 수 있으므로 cutover 또는 대사가 필요하다.

## 13. 권장 다음 작업 순서

1. 제품 정의를 A/B/C 중 하나로 확정한다.
2. 선택지 B라면 상태 전이표와 멱등 키, 트랜잭션 경계를 먼저 설계한다.
3. VA의 match ID fail-open, 실제액 불일치, `consumeAfterRelease()` 원자성 문제는 원화 출금 포함 여부와 분리해 결함으로 처리한다.
4. 구현 전 현재 운영 카운터와 원화 출금 이력을 읽기 전용으로 대사해 cutover 영향을 계산한다.
5. 단위 테스트 외에 VA와 원화 출금 각각의 end-to-end 밸런싱 통합 테스트를 추가한다.

## 14. 조사 제약과 주의사항

- 이 분석은 코드, DDL, 기존 테스트를 읽어 수행했다.
- 테스트를 실행하지 않았으므로 테스트 코드 존재와 실제 실행 성공은 서로 다른 증거다.
- CodeGraph의 Java 인터페이스 디스패치 엣지가 일부 불완전해 호출 그래프 결과를 저장소 전체 문자열·참조 검색으로 교차 검증했다.
- `v2-docs/KRW_WITHDRAWAL_API.html`은 기존 미추적 파일이며 이 조사에서 수정하지 않았다.
- 시크릿, API 키, 운영 접속 정보, 파트너 PII는 이 문서에 포함하지 않았다.
