# 정산 시스템 검증 리포트

> 검증일: 2026-04-28
> 검증자: Cowork AI Architect
> DDL 기준: v1.6 (37개 테이블)
> 코드 기준: core/settlement/SettlementService.java (666 lines)

---

## 1. 검증 범위

### 1.1 정산 주체

| 주체 | 역할 | settlement_balances 참여 |
|------|------|--------------------------|
| SYSTEM | Cryptoments 플랫폼 수익 | participant_type=SYSTEM, partner_id=NULL |
| DISTRIBUTOR (총판) | 트리 중간 노드, 하위 수수료 분배 수취 | participant_type=PARTNER |
| MERCHANT (가맹점) | 입금 발생 리프 노드, depositFeeRate − minFeeRate 수취 | participant_type=PARTNER |

### 1.2 검증한 코드·문서

| 파일 | 위치 | 비고 |
|------|------|------|
| SettlementService.java | core/settlement/ | 666줄, 원장/집계/실현/가스비 전체 |
| SettlementMapper.java | common/mapper/ | aggregateDailyFeesByDate, markAsRealized, sumUnrealizedShareAmount |
| LedgerMapper.java | common/mapper/ | computeBalance, sumTodayDebit |
| GasMapper.java | common/mapper/ | countByPartnerAndMonth, sumFeeUsd, markAsInvoiced |
| SettlementDailyAggregationJob.java | scheduler/job/ | 매일 00:30 KST |
| SettlementRealizationJob.java | scheduler/job/ | 매일 01:00 (timezone 미지정) |
| admin-api SettlementService.java | admin-api/service/ | 조회 + 출금 요청 래퍼 |
| admin-api SettlementController.java | admin-api/controller/ | 6개 엔드포인트 |
| CRYPTOMENTS_V2_DDL.sql | v2-docs/ | Section 6(원장/가스), Section 7(정산) |
| Partner.java | common/entity/ | parentFeeRate, minFeeRate, maxFeeCap, depositFeeRate |

---

## 2. 정산 모델 정리

### 2.1 DDL v1.6 절대 수수료율 모델

파트너 테이블의 4개 수수료 컬럼:

| 컬럼 | 의미 | 예시 |
|------|------|------|
| `deposit_fee_rate` | MERCHANT에 적용되는 실제 입금 수수료율 (%) | 1.0% |
| `parent_fee_rate` | 상위가 이 파트너 트리에서 가져가는 수수료율 (%) | 0.5% |
| `min_fee_rate` | 누적 최소 수수료율 (모든 상위의 parentFeeRate 합, 비정규화) | 0.5% |
| `max_fee_cap` | 하위 파트너 설정 가능 수수료율 상한 (%) | 3.0% |

### 2.2 분배 공식

```
입금 발생 MERCHANT의 depositFeeRate를 트리 순회하며 분배:

1. MERCHANT 마진 = depositFeeRate − minFeeRate
2. 중간 총판 마진 = remainingRate − currentParentFeeRate
3. 최상위 총판: SYSTEM 몫 = min(parentFeeRate, remainingRate)
                 총판 마진 = remainingRate − SYSTEM 몫
4. 각 참여자 쉐어 금액 = totalDepositAmount × margin / 100
```

### 2.3 4단계 트리 분배 예시

```
SYSTEM (parentFeeRate 기준)
└── 총판A (parentFeeRate=0.3%)
    └── 총판B (parentFeeRate=0.5%)
        └── 가맹점C (depositFeeRate=1.0%, minFeeRate=0.8%)

입금 100 USDT 발생 시:

가맹점C 마진: 1.0% − 0.8% = 0.2%  →  0.2 USDT
총판B 마진:   0.8% − 0.5% = 0.3%  →  0.3 USDT  ← ⚠️ P0-2 참조
총판A에서:    SYSTEM = min(0.3%, remaining)
              총판A 마진 = remaining − SYSTEM
```

### 2.4 미실현→실현 전환 규칙

```
[매일 00:30 KST]
  전일 FEE 원장 → aggregateDailyFees() → settlement_daily_fees (UNREALIZED)
  → accumulateUnrealizedBalance() → settlement_balances.unrealized_balance 증가

[매일 01:00]
  UNREALIZED daily_fees → realizeFees() → settlement_realizations (PENDING)
  → markAsRealized() → daily_fees 상태 REALIZED
  → upsertSettlementBalance() → unrealized −=, realized += shareAmount

[온체인 이체]
  Node.js RealizationPoller → MASTER→SETTLEMENT 이체 → COMPLETED

[출금]
  admin-api POST /settlements/withdraw → realized −=, totalWithdrawn +=
```

---

## 3. 발견 사항

### P0 — Critical (4건)

---

#### P0-1. shareRate 단위 불일치 (100배 오차 위험)

**위치**: `core/settlement/SettlementService.java` L279, L296, L330

**현상**: `shareRate`에 저장되는 값은 `myMargin` (예: 0.5 = 0.5%)이나, DDL 코멘트는 `share_rate DECIMAL(10,6) ... '이 참여자의 쉐어율 (예: 0.003 = 0.3%)'`로 정의. 즉 DDL은 소수 비율(0.003)을 기대하지만 코드는 퍼센트 값(0.3)을 저장.

**쉐어 금액 계산식**:
```java
BigDecimal share = totalDeposit.multiply(myMargin)
        .divide(new BigDecimal("100"), 18, RoundingMode.HALF_UP);  // L280
```

코드는 `/ 100`으로 퍼센트→비율 변환하고 있으므로 **쉐어 금액(share_amount) 자체는 정확**하다. 그러나 `share_rate` 컬럼에는 퍼센트 값(0.3)이 저장되어 DDL 코멘트와 불일치. 향후 리포트나 검증 SQL에서 `share_amount = total_deposit_amount × share_rate`로 역산하면 100배 오차 발생.

**영향**: 데이터 정합성 검증 시 혼란, 외부 감사 시 수치 불일치.

**수정 방향**: (A) DDL 코멘트를 퍼센트 단위로 수정하거나, (B) 코드에서 `shareRate`를 `myMargin.divide(100)` 비율 값으로 저장하고 계산식도 정비.

---

#### P0-2. 중간 총판 마진 계산 — minFeeRate 비정규화 의존

**위치**: `core/settlement/SettlementService.java` L314~L325

**현상**:
```java
if (current.getId().equals(partnerId)) {
    // 입금 파트너: 마진 = depositFeeRate - minFeeRate
    myMargin = depositFeeRate.subtract(myMinFeeRate);          // L321
} else {
    // 중간 총판: 마진 = remainingRate - currentParentFeeRate
    myMargin = remainingRate.subtract(currentParentFeeRate);   // L324
}
```

`remainingRate`는 매 레벨에서 `myMargin`만큼 차감되므로 (L345), 중간 총판의 마진은 실질적으로 `minFeeRate − 해당총판.parentFeeRate`가 된다. 이 계산의 정확성은 **`minFeeRate`가 모든 상위의 parentFeeRate 합과 정확히 일치하는지**에 전적으로 의존한다.

`minFeeRate`는 비정규화(denormalized) 컬럼이므로 파트너 생성/수정 시 실수로 불일치가 발생하면 수수료 분배 금액이 틀어진다. 실제로 이전에 `maxFeeCap` 미상속 버그(admin-api)가 발견된 바 있어, `minFeeRate` 역시 같은 위험이 존재한다.

**영향**: minFeeRate 오류 시 수수료 과다/과소 분배.

**수정 방향**: (A) `aggregateDailyFees()` 시작 시 트리를 직접 순회하여 `minFeeRate` 재계산 + 검증, (B) 파트너 변경 이벤트 시 하위 전체 `minFeeRate` 재전파 로직 추가.

---

#### P0-3. realizeFees()가 첫 번째 통화/네트워크 그룹만 처리

**위치**: `core/settlement/SettlementService.java` L392~L405

**현상**:
```java
SettlementDailyFee first = target.get(0);
Long currencyId = first.getCurrencyId();
Long networkId = first.getNetworkId();
// ...
for (SettlementDailyFee fee : target) {
    if (!currencyId.equals(fee.getCurrencyId()) || !networkId.equals(fee.getNetworkId())) {
        continue;  // 첫 번째 그룹 외 전부 SKIP
    }
```

`SettlementRealizationJob`은 파트너당 `realizeFees()`를 **1회만 호출**한다 (L54). 따라서 BSC-USDT와 POLYGON-USDC 양쪽에 미실현 수수료가 있는 파트너는, 매일 한쪽만 실현되고 다른 쪽은 영구히 UNREALIZED 상태로 누적된다.

**영향**: 다중 네트워크 파트너의 미실현 잔액 무한 증가, 정산 지연.

**수정 방향**: (A) `realizeFees()`에서 통화/네트워크별 그룹핑 후 반복 처리, 또는 (B) `SettlementRealizationJob`에서 파트너별 고유 (currencyId, networkId) 조합을 조회하여 각각 호출.

---

#### P0-4. @Transactional 미적용 — 다중 DB 쓰기 원자성 미보장

**위치**: `core/settlement/SettlementService.java` 전체 (import 자체가 없음)

**현상**: `aggregateDailyFees()`, `realizeFees()`, `withdrawSettlement()` 등 핵심 메서드에 `@Transactional` 어노테이션이 없다.

`realizeFees()` 예시:
1. `dailyFeeRepository.findByStatus()` — 조회
2. `realizationRepository.save()` — 삽입
3. `settlementMapper.markAsRealized()` — 일괄 UPDATE
4. `upsertSettlementBalance()` — read-then-write

3번 성공 후 4번 실패 시: daily_fees는 REALIZED로 변경되었으나 settlement_balances는 반영되지 않아 **데이터 불일치**. 복구 불가.

**영향**: 부분 커밋에 의한 데이터 정합성 손실, 잔액 불일치.

**수정 방향**: 모든 다중 쓰기 메서드에 `@Transactional` 추가. `aggregateDailyFees()`는 파트너 그룹 단위로 트랜잭션 분리 고려.


### P1 — High (4건)

---

#### P1-1. settlement_balances 누적 멱등성 결여

**위치**: `core/settlement/SettlementService.java` L353~L361

**현상**: `aggregateDailyFees()` 마지막에서 `accumulateUnrealizedBalance()`를 호출하는데, 같은 날짜로 재실행하면 `settlement_daily_fees`에 UNIQUE 제약으로 중복 삽입은 방지되지만, **이미 삽입된 row를 다시 조회하여 잔액을 이중 누적**할 수 있다.

```java
List<SettlementDailyFee> todayFees = dailyFeeRepository.findBySettlementDate(date);
for (SettlementDailyFee fee : todayFees) {
    accumulateUnrealizedBalance(...);  // 재실행 시 이중 누적
}
```

**영향**: 스케줄러 재실행/장애 복구 시 unrealized_balance 이중 증가.

**수정 방향**: (A) 삽입 직후 즉시 누적 (신규 건만 대상), (B) `balance_accumulated` 플래그 추가, (C) 시작 시 해당 날짜 기존 데이터 삭제 후 재생성.

---

#### P1-2. 최소 실현 금액 미적용

**위치**: `core/settlement/SettlementService.java` `realizeFees()` L374~L458

**현상**: 최소 실현 금액 검증이 없어 극소 금액(예: 0.000001 USDT)도 온체인 이체(Realization) 레코드를 생성한다. 가스비가 실현 금액을 초과하는 비경제적 트랜잭션 발생 가능.

또한 DDL 주석에 "주별 1회" 실현이라 명시되어 있으나 스케줄러는 **매일** 실행.

**영향**: 불필요한 온체인 가스비 소모.

**수정 방향**: `system_settings`에 `settlement.min_realization_usd` 추가 (예: 10 USD). 실현 금액 < 최소 금액이면 skip.

---

#### P1-3. PriceService 미연동 — 가스비 USD 환산 호출자 의존

**위치**: `core/settlement/SettlementService.java` `recordGasCost()` L527~L548

**현상**: `feeUsd` 파라미터를 호출자가 직접 전달해야 한다. 네이티브 토큰(ETH/BNB/MATIC/TRX) 시세 조회 + USD 환산 로직이 없다. `PriceService`는 스테이블코인(USDT/USDC) 시세만 관리하며 네이티브 토큰을 다루지 않는다.

**영향**: `feeUsd`가 0이거나 부정확한 값으로 기록될 가능성. 가스비 인보이스 신뢰도 저하.

**수정 방향**: (A) PriceService에 네이티브 토큰 시세 기능 추가, (B) `recordGasCost()` 내부에서 자동 환산, (C) Node.js 측 환산의 정확성 검증.

---

#### P1-4. 잔액 동결(freeze) 미구현 — 출금 이중 지출 가능

**위치**: `core/settlement/SettlementService.java` L207~L223

**현상**: `freeze()`는 잔액 확인만 수행, `unfreeze()`는 no-op. DDL에 `frozen_amount` 컬럼 없음.

시나리오: 출금 A(100 USDT) → freeze 통과 (잔액 150) → 출금 B(100 USDT) → freeze 통과 (여전히 150) → 두 건 모두 debit → 잔액 −50.

**영향**: 동시 출금 시 잔액 초과 출금 가능.

**수정 방향**: (A) DDL에 `frozen_amount` 추가 + freeze/unfreeze 구현, (B) `debit()` 시 `SELECT ... FOR UPDATE` 비관적 잠금.


### P2 — Medium (3건)

---

#### P2-1. 가스비 인보이스 생성 시 markAsInvoiced() 미호출

**위치**: `core/settlement/SettlementService.java` `generateMonthlyInvoice()` L554~L577

**현상**: 인보이스 생성 후 `gasMapper.markAsInvoiced()`를 호출하지 않는다. `gas_cost_records.billing_status`가 영구 PENDING 상태.

**영향**: 가스비 레코드-인보이스 연결 불가, 청구 상세 추적 불가.

**수정 방향**: `gasInvoiceRepository.save()` 후 `gasMapper.markAsInvoiced(partnerId, yearMonthStr, id)` 호출 추가.

---

#### P2-2. 가스 인보이스 — 비활성 파트너 포함 + 시스템 가스 추적 누락

**위치**: `core/settlement/SettlementService.java` `generateAllInvoices()` L585~L598

**현상**:
- (A) `partnerRepository.findAll()`로 전체 파트너 조회 → SUSPENDED/INACTIVE 포함
- (B) `recordGasCost()`에서 `partnerId == null || partnerId == 0`이면 skip → 시스템 지갑 가스비 미기록

**영향**: 비활성 파트너 불필요 인보이스, 시스템 운영 가스비 총액 파악 불가.

**수정 방향**: (A) `findByStatus(ACTIVE)` 사용, (B) 시스템 가스비 별도 기록 경로.

---

#### P2-3. sumUnrealizedShareAmount() 미사용

**위치**: `common/mapper/SettlementMapper.java` L49~L61

**현상**: `realizeFees()`에서 DB 쿼리 대신 Java 루프로 합산. DB 집계와의 교차 검증을 하지 않는다.

**영향**: Java 필터 조건과 DB 쿼리 조건 불일치 시 합산 오차 발생 가능.

**수정 방향**: `realizeFees()` 시작 시 `sumUnrealizedShareAmount()`로 DB 합계 조회 + Java 합산 결과와 비교 검증.

---

## 4. 정상 동작 부분

| # | 항목 | 판정 |
|---|------|------|
| 1 | 원장(Ledger) CREDIT/DEBIT/FEE 기록 및 `balanceAfter` 계산 | ✅ 정상 — `computeBalance()` 재계산 후 기록 |
| 2 | `debit()` 잔액 부족 검증 | ✅ 정상 — `currentBalance < amount` 시 ConflictException |
| 3 | 파트너 트리 순회 방향 (리프 → 루트) | ✅ 정상 — `findOne(parentPartnerId)` 반복 |
| 4 | 최상위 SYSTEM 몫 계산: `min(parentFeeRate, remainingRate)` | ✅ 정상 — 잔여보다 많이 가져가지 않음 |
| 5 | UNIQUE 제약 `uk_daily_participant`로 중복 집계 방지 | ✅ 정상 — 같은 날 같은 참여자 재삽입 불가 |
| 6 | 정산 출금 취소 시 `reverseSettlementWithdraw()` 복원 | ✅ 정상 — realized += amount, totalWithdrawn -= amount |

---

## 5. 권장 조치 순서

| 단계 | 작업 | 우선순위 | 예상 난이도 |
|------|------|---------|------------|
| 1 | `@Transactional` 추가 (P0-4) — 가장 기본적 안전장치 | P0 | 하 |
| 2 | `realizeFees()` 다중 통화/네트워크 처리 (P0-3) | P0 | 중 |
| 3 | `shareRate` 단위 통일 — DDL 코멘트 수정 또는 코드 정비 (P0-1) | P0 | 하 |
| 4 | `minFeeRate` 검증 로직 추가 (P0-2) — 트리 재계산 or 정합성 체크 | P0 | 중 |
| 5 | 잔액 누적 멱등성 확보 (P1-1) — 재실행 시 이중 누적 방지 | P1 | 중 |
| 6 | 최소 실현 금액 설정 추가 (P1-2) | P1 | 하 |
| 7 | freeze/unfreeze 구현 또는 debit SELECT FOR UPDATE (P1-4) | P1 | 중 |
| 8 | `markAsInvoiced()` 호출 추가 + 비활성 파트너 필터 (P2-1, P2-2) | P2 | 하 |

---

## 6. 부록

### 6.1 시드 데이터 SQL (테스트용)

```sql
-- 파트너 트리: SYSTEM → 총판A(id=100) → 총판B(id=200) → 가맹점C(id=300)
INSERT INTO partners (id, partner_code, name, partner_type, parent_partner_id,
                      parent_fee_rate, min_fee_rate, max_fee_cap, deposit_fee_rate, status)
VALUES
    (100, 'DIST-A', '총판A', 'DISTRIBUTOR', NULL,
     0.300000, 0.000000, 3.000000, 0.000000, 'ACTIVE'),
    (200, 'DIST-B', '총판B', 'DISTRIBUTOR', 100,
     0.500000, 0.300000, 2.000000, 0.000000, 'ACTIVE'),
    (300, 'MERCH-C', '가맹점C', 'MERCHANT', 200,
     0.000000, 0.800000, 0.000000, 1.000000, 'ACTIVE');

-- 원장 데이터 (가맹점C에 100 USDT 입금, 1% 수수료)
INSERT INTO ledger_entries (partner_id, currency_id, network_id, entry_type, amount, balance_after,
                            reference_type, reference_id, description)
VALUES
    (300, 1, 2, 'CREDIT', 100.000000000000000000, 100.000000000000000000,
     'DEPOSIT', 1001, '입금 확정'),
    (300, 1, 2, 'FEE', 1.000000000000000000, 99.000000000000000000,
     'DEPOSIT', 1001, '입금 수수료 1.0%');
```

### 6.2 기대 분배 결과

```
depositFeeRate = 1.0%,  minFeeRate = 0.8%

[가맹점C]  마진: 1.0% − 0.8% = 0.2%  → 100 × 0.2 / 100 = 0.2 USDT
           remainingRate: 1.0% − 0.2% = 0.8%

[총판B]    마진: 0.8% − 0.5%(총판B.parentFeeRate) = 0.3%  → 100 × 0.3 / 100 = 0.3 USDT
           remainingRate: 0.8% − 0.3% = 0.5%

[총판A]    (최상위, parentPartnerId = NULL)
           SYSTEM 몫: min(0.3%, 0.5%) = 0.3%  → 100 × 0.3 / 100 = 0.3 USDT
           총판A 마진: 0.5% − 0.3% = 0.2%     → 100 × 0.2 / 100 = 0.2 USDT

합계: 0.2 + 0.3 + 0.3 + 0.2 = 1.0 USDT = totalFee ✅
```

### 6.3 정합성 검증 SQL

```sql
-- 1. settlement_daily_fees 분배 합계 = totalFeeAmount 검증
SELECT
    settlement_date,
    source_partner_id,
    currency_id,
    network_id,
    MAX(total_fee_amount) AS total_fee,
    SUM(share_amount) AS sum_shares,
    CASE
        WHEN ABS(MAX(total_fee_amount) - SUM(share_amount)) < 0.000001 THEN 'OK'
        ELSE 'MISMATCH'
    END AS check_result
FROM settlement_daily_fees
GROUP BY settlement_date, source_partner_id, currency_id, network_id
HAVING check_result = 'MISMATCH';

-- 2. settlement_balances 미실현 잔액 vs daily_fees UNREALIZED 합계
SELECT
    sb.participant_type,
    sb.participant_partner_id,
    sb.currency_id,
    sb.network_id,
    sb.unrealized_balance,
    COALESCE(df.sum_unrealized, 0) AS daily_fee_unrealized,
    CASE
        WHEN ABS(sb.unrealized_balance - COALESCE(df.sum_unrealized, 0)) < 0.000001 THEN 'OK'
        ELSE 'MISMATCH'
    END AS check_result
FROM settlement_balances sb
LEFT JOIN (
    SELECT participant_type, participant_partner_id, currency_id, network_id,
           SUM(share_amount) AS sum_unrealized
    FROM settlement_daily_fees
    WHERE status = 'UNREALIZED'
    GROUP BY participant_type, participant_partner_id, currency_id, network_id
) df ON sb.participant_type = df.participant_type
     AND (sb.participant_partner_id = df.participant_partner_id
          OR (sb.participant_partner_id IS NULL AND df.participant_partner_id IS NULL))
     AND sb.currency_id = df.currency_id
     AND sb.network_id = df.network_id
HAVING check_result = 'MISMATCH';

-- 3. minFeeRate 정합성 — 실제 트리 vs 비정규화 값 비교
WITH RECURSIVE tree AS (
    SELECT id, name, parent_partner_id, parent_fee_rate, min_fee_rate,
           CAST(0 AS DECIMAL(10,6)) AS computed_min
    FROM partners WHERE parent_partner_id IS NULL
    UNION ALL
    SELECT c.id, c.name, c.parent_partner_id, c.parent_fee_rate, c.min_fee_rate,
           CAST(t.computed_min + t.parent_fee_rate AS DECIMAL(10,6))
    FROM partners c JOIN tree t ON c.parent_partner_id = t.id
)
SELECT id, name, min_fee_rate AS stored, computed_min AS computed,
       ABS(min_fee_rate - computed_min) AS diff
FROM tree
WHERE ABS(min_fee_rate - computed_min) > 0.000001;

-- 4. 실현 완료 금액 vs realized_balance + total_withdrawn 검증
SELECT
    sr.partner_id,
    sr.currency_id,
    sr.network_id,
    SUM(sr.total_share_amount) AS total_realized_onchain,
    sb.realized_balance + sb.total_withdrawn AS balance_side,
    CASE
        WHEN ABS(SUM(sr.total_share_amount) - (sb.realized_balance + sb.total_withdrawn)) < 0.000001 THEN 'OK'
        ELSE 'MISMATCH'
    END AS check_result
FROM settlement_realizations sr
JOIN settlement_balances sb ON sb.participant_partner_id = sr.partner_id
    AND sb.currency_id = sr.currency_id AND sb.network_id = sr.network_id
WHERE sr.status = 'COMPLETED'
GROUP BY sr.partner_id, sr.currency_id, sr.network_id
HAVING check_result = 'MISMATCH';
```

---

> 이 리포트는 코드 정적 분석 기반이며, 실제 운영 데이터에 대한 검증은 §6.3의 SQL로 별도 수행 필요.
