# 출금 정책 리팩토링 지침서

> **작성일**: 2026-05-25  
> **대상 모듈**: `core`, `open-api`  
> **우선순위**: P0 (보안/자금 관련)

---

## 1. 현재 상태 분석

### 1.1 발견된 문제

| # | 문제 | 심각도 | 설명 |
|---|------|--------|------|
| **G1** | UI 기본값 ≠ 실제 검증값 | **P0** | UI는 policy NULL일 때 daily=500K, single=100K 표시하지만, `validatePolicy()`는 NULL이면 **무제한 통과** |
| **G2** | 최소 출금액 미검증 | **P1** | UI에 `minAmount=10.0` 표시하지만, 검증 로직 없음 |
| **G3** | system_settings 사용 안 됨 | 정리 | `withdrawal.global_daily_limit_usd`, `withdrawal.cooldown_default_sec`가 DB에 존재하지만 코드에서 미참조. 불필요 데이터 |

### 1.2 현재 코드 흐름

```
WithdrawalService.requestWithdrawal(withdrawal)
  → policyRepository.findByPartnerId()
  → validatePolicy(withdrawal, policy)
      ├── policy == null → return (무검증 통과!)
      ├── singleLimit 검증 (NULL이면 스킵)
      ├── dailyLimit 검증 (NULL이면 스킵)
      └── whitelist 검증
  → determineInitialStatus() — 자동승인 판단
  → settlementService.freeze()
  → save + 상태이력 + 알림

WithdrawalController.getWithdrawalLimits()
  → policy NULL이면 하드코딩 기본값 표시 (daily=500K, single=100K, min=10)
  ※ 표시 전용, 검증과 무관
```

### 1.3 DB 현황

```sql
-- partner_withdrawal_policies: 31행 중 29개 파트너가 daily_limit=NULL, single_limit=NULL
-- Partner 34: daily_limit=2000, single_limit=1000
-- Partner 13: auto_approve_threshold=1000000

-- system_settings (제거 대상):
-- withdrawal.global_daily_limit_usd = 100000  ← 코드에서 미사용
-- withdrawal.cooldown_default_sec = 60         ← 코드에서 미사용
```

---

## 2. 정책 설계 (To-Be)

### 2.1 원칙

- **`partner_withdrawal_policies`가 유일한 출금 정책 소스** — system_settings 미사용
- 모든 파트너에 policy 행이 존재해야 함 (기본값으로 INSERT)
- 최소 출금액(10 USD)은 코드 상수로 처리
- 쿨다운 제거 (불필요)
- 글로벌 시스템 한도 제거

### 2.2 정책 컬럼 (변경 없음)

| 컬럼 | 타입 | 용도 | NULL 의미 |
|------|------|------|-----------|
| `daily_limit` | DECIMAL(20,4) | 일일 출금 한도 (USD) | NULL/0 = 무제한 |
| `single_limit` | DECIMAL(20,4) | 건당 출금 한도 (USD) | NULL/0 = 무제한 |
| `auto_approve_threshold` | DECIMAL(20,4) | 자동 승인 임계값 (USD) | NULL = 모두 수동 |
| `address_whitelist_enabled` | BOOLEAN | 화이트리스트 사용 | FALSE = 미사용 |

### 2.3 검증 순서

```
출금 요청 수신
    │
    ▼
[1] 최소 출금액 ≥ 10 USD?  ──NO──▶ MIN_AMOUNT_NOT_MET
    │ YES
    ▼
[2] 파트너 policy 조회
    │
    ▼
[3] single_limit 설정됨?
    │ YES → 요청액 ≤ single_limit?  ──NO──▶ SINGLE_LIMIT_EXCEEDED
    │ NO  → 스킵 (무제한)
    ▼
[4] daily_limit 설정됨?
    │ YES → 당일 합계 + 요청액 ≤ daily_limit?  ──NO──▶ DAILY_LIMIT_EXCEEDED
    │ NO  → 스킵 (무제한)
    ▼
[5] address_whitelist_enabled?
    │ YES → 주소 등록됨?  ──NO──▶ ADDRESS_NOT_WHITELISTED
    │ NO  → 스킵
    ▼
[6] 자동승인 판단
    │ amount ≤ auto_approve_threshold → APPROVED
    │ 그 외 → PENDING_APPROVAL
    ▼
정산 잔액 동결 → 저장 → 알림
```

---

## 3. 구현 지침

### 3.1 DDL — 기존 파트너에 기본 정책 INSERT

policy가 없는 29개 파트너에 기본값 행을 생성한다.

```sql
-- 기본 정책 일괄 생성 (이미 존재하는 파트너는 IGNORE)
INSERT IGNORE INTO partner_withdrawal_policies
  (partner_id, daily_limit, single_limit, auto_approve_threshold, address_whitelist_enabled)
SELECT id, 500000.0000, 100000.0000, NULL, FALSE
FROM partners
WHERE id NOT IN (SELECT partner_id FROM partner_withdrawal_policies);
```

**실행 방법** (운영 DB):
```bash
ssh cryptoments-bastion "ssh db-01 'mysql -u cryptoments -p\"Crypt0m3nts!2026\" cryptoments_db -e \"INSERT IGNORE INTO partner_withdrawal_policies (partner_id, daily_limit, single_limit, auto_approve_threshold, address_whitelist_enabled) SELECT id, 500000.0000, 100000.0000, NULL, FALSE FROM partners WHERE id NOT IN (SELECT partner_id FROM partner_withdrawal_policies);\"'"
```

### 3.2 DDL — 불필요 system_settings 제거

```sql
DELETE FROM system_settings WHERE setting_key IN (
  'withdrawal.global_daily_limit_usd',
  'withdrawal.cooldown_default_sec'
);
```

### 3.3 core/WithdrawalService.java — validatePolicy() 수정

기존 `validatePolicy()` 메서드를 아래로 교체:

```java
// ── 코드 상수 ──
private static final BigDecimal MIN_WITHDRAWAL_AMOUNT_USD = new BigDecimal("10");

/**
 * 출금 정책 검증.
 *
 * <p>검증 순서:
 * <ol>
 *   <li>최소 출금액 (10 USD, 코드 상수)</li>
 *   <li>건당 한도 (partner_withdrawal_policies.single_limit)</li>
 *   <li>일일 한도 (partner_withdrawal_policies.daily_limit)</li>
 *   <li>화이트리스트 (partner_withdrawal_policies.address_whitelist_enabled)</li>
 * </ol>
 */
private void validatePolicy(Withdrawal withdrawal, PartnerWithdrawalPolicy policy) {
    BigDecimal amountUsd = computeAmountUsd(withdrawal);

    // ── [1] 최소 출금액 ──
    if (amountUsd.compareTo(BigDecimal.ZERO) > 0
            && amountUsd.compareTo(MIN_WITHDRAWAL_AMOUNT_USD) < 0) {
        throw new ConflictException(ErrorCodes.MIN_AMOUNT_NOT_MET,
                "최소 출금 금액은 " + MIN_WITHDRAWAL_AMOUNT_USD.toPlainString() + " USD 입니다.");
    }

    if (policy == null) {
        // 정책 미설정 파트너 — 최소금액만 검증하고 통과
        log.warn("출금 정책 미설정: partnerId={}", withdrawal.getPartnerId());
        return;
    }

    // ── [2] 건당 한도 ──
    if (policy.getSingleLimit() != null
            && policy.getSingleLimit().compareTo(BigDecimal.ZERO) > 0
            && amountUsd.compareTo(policy.getSingleLimit()) > 0) {
        throw new ConflictException(ErrorCodes.SINGLE_LIMIT_EXCEEDED,
                "건당 한도 " + policy.getSingleLimit().toPlainString() + " USD 초과");
    }

    // ── [3] 일일 한도 ──
    if (policy.getDailyLimit() != null
            && policy.getDailyLimit().compareTo(BigDecimal.ZERO) > 0) {
        BigDecimal todayTotal = withdrawalMapper.sumTodayWithdrawalAmount(withdrawal.getPartnerId());
        if (todayTotal.add(withdrawal.getAmount()).compareTo(policy.getDailyLimit()) > 0) {
            throw new ConflictException(ErrorCodes.DAILY_LIMIT_EXCEEDED,
                    "일일 한도 " + policy.getDailyLimit().toPlainString()
                            + " USD 초과 (당일 합계: " + todayTotal.toPlainString() + ")");
        }
    }

    // ── [4] 화이트리스트 ──
    if (Boolean.TRUE.equals(policy.getAddressWhitelistEnabled())) {
        WithdrawalAddressWhitelist whitelist = whitelistRepository
                .findByPartnerIdAndNetworkIdAndAddress(
                        withdrawal.getPartnerId(),
                        withdrawal.getNetworkId(),
                        withdrawal.getToAddress());
        if (whitelist == null) {
            throw new ConflictException(ErrorCodes.ADDRESS_NOT_WHITELISTED);
        }
    }
}
```

**변경 포인트**:
- `policy == null`일 때 기존: 즉시 return (무검증) → 변경: **최소금액 검증 후** return + warn 로그
- 최소금액 검증이 policy 조회 전에 실행되므로 policy 유무와 무관하게 항상 적용
- 에러 메시지에 실제 한도 값 포함 (디버깅 용이)

### 3.4 core/WithdrawalService.java — checkPolicy() 동기화

`checkPolicy()` 메서드(line 500)에도 최소금액 체크를 추가:

```java
public PolicyCheckResult checkPolicy(Withdrawal withdrawal) {
    PartnerWithdrawalPolicy policy = policyRepository.findByPartnerId(withdrawal.getPartnerId());

    BigDecimal amountUsd = computeAmountUsd(withdrawal);

    // 최소금액
    boolean minAmountPassed = amountUsd.compareTo(MIN_WITHDRAWAL_AMOUNT_USD) >= 0;
    String minAmountDetail = amountUsd.toPlainString() + " / " + MIN_WITHDRAWAL_AMOUNT_USD.toPlainString() + " USD";

    if (policy == null) {
        return new PolicyCheckResult(
                minAmountPassed, minAmountDetail,
                true, "정책 미설정 (무제한)",
                true, "정책 미설정 (무제한)",
                true, "화이트리스트 미사용");
    }

    // 건당 한도
    boolean singlePassed = true;
    String singleDetail = "한도 미설정";
    if (policy.getSingleLimit() != null) {
        singlePassed = amountUsd.compareTo(policy.getSingleLimit()) <= 0;
        singleDetail = amountUsd + " / " + policy.getSingleLimit() + " USD";
    }

    // 일일 한도
    boolean dailyPassed = true;
    String dailyDetail = "한도 미설정";
    if (policy.getDailyLimit() != null) {
        BigDecimal todayTotal = withdrawalMapper.sumTodayWithdrawalAmount(withdrawal.getPartnerId());
        BigDecimal projectedTotal = todayTotal.add(withdrawal.getAmount());
        dailyPassed = projectedTotal.compareTo(policy.getDailyLimit()) <= 0;
        dailyDetail = projectedTotal + " / " + policy.getDailyLimit() + " (당일 합계 / 한도)";
    }

    // 화이트리스트
    boolean whitelistPassed = true;
    String whitelistDetail = "화이트리스트 미사용";
    if (Boolean.TRUE.equals(policy.getAddressWhitelistEnabled())) {
        WithdrawalAddressWhitelist whitelist = whitelistRepository
                .findByPartnerIdAndNetworkIdAndAddress(
                        withdrawal.getPartnerId(),
                        withdrawal.getNetworkId(),
                        withdrawal.getToAddress());
        whitelistPassed = whitelist != null;
        whitelistDetail = whitelistPassed ? "등록된 주소" : "미등록 주소: " + withdrawal.getToAddress();
    }

    return new PolicyCheckResult(
            minAmountPassed, minAmountDetail,
            singlePassed, singleDetail,
            dailyPassed, dailyDetail,
            whitelistPassed, whitelistDetail);
}
```

**PolicyCheckResult record 변경**:

```java
public record PolicyCheckResult(
        boolean minAmountPassed,
        String minAmountDetail,
        boolean singleLimitPassed,
        String singleLimitDetail,
        boolean dailyLimitPassed,
        String dailyLimitDetail,
        boolean whitelistPassed,
        String whitelistDetail
) {}
```

### 3.5 open-api/WithdrawalController.java — getWithdrawalLimits() 수정

UI 표시 기본값을 **policy 값과 동일하게** 반환하도록 변경:

```java
@GetMapping(name = "출금 한도 조회", value = "/withdrawal-limits")
public WithdrawalLimitResponse getWithdrawalLimits(
        @RequestParam String chainType,
        @RequestParam String currencyType,
        WidgetSessionData session) {

    Long partnerId = session.getPartnerId();
    PartnerWithdrawalPolicy policy = withdrawalPolicyRepository.findByPartnerId(partnerId);

    // policy에서 직접 읽기 — NULL이면 "무제한"으로 표시
    BigDecimal dailyLimit = (policy != null && policy.getDailyLimit() != null)
            ? policy.getDailyLimit() : BigDecimal.ZERO;
    BigDecimal singleLimit = (policy != null && policy.getSingleLimit() != null)
            ? policy.getSingleLimit() : BigDecimal.ZERO;

    BigDecimal dailyUsed = withdrawalMapper.sumTodayWithdrawalAmount(partnerId);
    BigDecimal dailyRemaining = dailyLimit.compareTo(BigDecimal.ZERO) > 0
            ? dailyLimit.subtract(dailyUsed)
            : new BigDecimal("-1"); // -1 = 무제한을 의미

    return WithdrawalLimitResponse.builder()
            .minAmount("10")
            .maxAmount(singleLimit.compareTo(BigDecimal.ZERO) > 0
                    ? singleLimit.toPlainString() : "0") // 0 = 무제한
            .dailyLimit(dailyLimit.compareTo(BigDecimal.ZERO) > 0
                    ? dailyLimit.toPlainString() : "0")
            .dailyUsed(dailyUsed.toPlainString())
            .dailyRemaining(dailyRemaining.toPlainString())
            .chainType(chainType)
            .currencyType(currencyType)
            .build();
}
```

> **참고**: 3.1에서 모든 파트너에 기본 policy(daily=500K, single=100K)를 INSERT하므로,
> 정상적으로는 policy가 NULL인 경우가 발생하지 않는다.
> NULL 분기는 안전장치로 유지.

### 3.6 ErrorCodes.java — 최소금액 에러 추가

```java
// 출금 (360번대) 섹션에 추가
public static final ErrorCode MIN_AMOUNT_NOT_MET = new ErrorCode("MIN_AMOUNT_NOT_MET", "최소 출금 금액 미달입니다.");
```

### 3.7 불필요 ErrorCode 정리 (선택)

아래 코드는 system_settings 연동이 제거되면서 사용처가 없어짐. 코드에서 참조하는 곳이 없으면 제거 가능:

```java
// ErrorCodes.COOLDOWN_NOT_MET — 쿨다운 제거됨
// ErrorCodes.GLOBAL_DAILY_LIMIT_EXCEEDED — 글로벌 한도 제거됨 (추가 전이라면 무시)
```

---

## 4. 변경 전/후 비교

### policy 미설정 파트너 (현재 29개 → DDL 실행 후 0개)

| 항목 | Before | After (DDL 실행 후) |
|------|--------|---------------------|
| 건당 한도 | 무제한 (UI엔 100K 표시) | 100,000 USD (policy 행 생성) |
| 일일 한도 | 무제한 (UI엔 500K 표시) | 500,000 USD (policy 행 생성) |
| 최소 출금액 | 무검증 (UI엔 10 표시) | 10 USD (코드 상수 검증) |
| UI ↔ 검증 정합성 | ❌ 불일치 | ✅ 일치 |

### 기존 policy 있는 파트너 (Partner 34)

| 항목 | Before | After |
|------|--------|-------|
| 건당 한도 | 1,000 USD ✅ | 1,000 USD (변경 없음) |
| 일일 한도 | 2,000 USD ✅ | 2,000 USD (변경 없음) |
| 최소 출금액 | 무검증 | 10 USD (신규 적용) |

---

## 5. 제거 대상

| 항목 | 위치 | 조치 |
|------|------|------|
| `withdrawal.global_daily_limit_usd` | system_settings DB 행 | DELETE |
| `withdrawal.cooldown_default_sec` | system_settings DB 행 | DELETE |
| `COOLDOWN_NOT_MET` ErrorCode | ErrorCodes.java | 코드 제거 (참조 없음 확인 후) |

---

## 6. 체크리스트

- [ ] **운영 DB**: 파트너 기본 policy INSERT (3.1 SQL)
- [ ] **운영 DB**: system_settings 불필요 행 DELETE (3.2 SQL)
- [ ] `ErrorCodes.java` — `MIN_AMOUNT_NOT_MET` 추가
- [ ] `WithdrawalService.validatePolicy()` — 최소금액 검증 추가, 에러 메시지 개선 (3.3)
- [ ] `WithdrawalService.checkPolicy()` + `PolicyCheckResult` — 최소금액 필드 추가 (3.4)
- [ ] `WithdrawalController.getWithdrawalLimits()` — policy 값 직접 반환 (3.5)
- [ ] 빌드 검증: `./gradlew :common:compileJava :core:compileJava :open-api:compileJava`
- [ ] 커밋 + push: `git push origin main`
