# 정산 시스템 수정 계획

> 기준: SETTLEMENT_VERIFICATION_REPORT.md (2026-04-28)
> 작성일: 2026-04-28

---

## 수정 대상 파일 요약

| 파일 | 수정 건수 | 관련 이슈 |
|------|----------|----------|
| `core/settlement/SettlementService.java` | 7건 | P0-1,2,3,4 / P1-1,2 / P2-1 |
| `scheduler/job/SettlementRealizationJob.java` | 2건 | P0-3 / P1-2 |
| `common/mapper/SettlementMapper.java` | 1건 | P2-3 |
| `v2-docs/CRYPTOMENTS_V2_DDL.sql` | 2건 | P0-1 / P1-4 |
| `core/settlement/SettlementService.java` (가스) | 1건 | P2-2 |

---

## Phase 1 — 안전장치 (P0-4, P1-4)

가장 기본적인 데이터 무결성 보호. 비즈니스 로직 변경 없이 적용 가능.

### 1-1. @Transactional 추가 (P0-4)

**파일**: `core/settlement/SettlementService.java`

```java
// import 추가
import org.springframework.transaction.annotation.Transactional;

// 각 메서드에 추가
@Transactional
public int aggregateDailyFees(LocalDate date) { ... }

@Transactional
public SettlementRealization realizeFees(Long partnerId, String period) { ... }

@Transactional
public SettlementBalance withdrawSettlement(...) { ... }

@Transactional
public void reverseSettlementWithdraw(...) { ... }

@Transactional
public LedgerEntry credit(...) { ... }

@Transactional
public LedgerEntry debit(...) { ... }

@Transactional
public LedgerEntry recordFee(...) { ... }

@Transactional
public LedgerEntry adjust(...) { ... }
```

`aggregateDailyFees()`는 파트너 수가 많으면 트랜잭션이 길어질 수 있으나, 일일 배치이므로 허용 범위. 추후 필요 시 파트너 그룹 단위로 분리.

### 1-2. debit() 비관적 잠금 (P1-4)

freeze/unfreeze 구현 대신 `debit()` 시점에 잠금을 건다. DDL 변경 불필요.

**파일**: `common/mapper/LedgerMapper.java`

```java
// 기존 computeBalance는 그대로 두고, 잠금 버전 추가
@Select("<script>"
        + "SELECT COALESCE("
        + "  SUM(CASE WHEN entry_type IN ('CREDIT','ADJUSTMENT') THEN amount ELSE 0 END) "
        + "  - SUM(CASE WHEN entry_type IN ('DEBIT','FEE') THEN amount ELSE 0 END)"
        + ", 0) "
        + "FROM ledger_entries "
        + "WHERE partner_id = #{partnerId} "
        + "  AND currency_id = #{currencyId} "
        + "  AND network_id = #{networkId} "
        + "FOR UPDATE"
        + "</script>")
BigDecimal computeBalanceForUpdate(@Param("partnerId") Long partnerId,
                                   @Param("currencyId") Long currencyId,
                                   @Param("networkId") Long networkId);
```

**파일**: `core/settlement/SettlementService.java` — `debit()` 내부

```java
// 변경 전
BigDecimal currentBalance = ledgerMapper.computeBalance(partnerId, currencyId, networkId);

// 변경 후
BigDecimal currentBalance = ledgerMapper.computeBalanceForUpdate(partnerId, currencyId, networkId);
```

`credit()`, `recordFee()`도 동일하게 `computeBalanceForUpdate()` 사용 권장 (동시 입금 시 balanceAfter 정합성).

---

## Phase 2 — 핵심 로직 수정 (P0-1, P0-2, P0-3)

### 2-1. shareRate 단위 통일 (P0-1)

DDL 코멘트를 코드 기준(퍼센트)으로 수정. 코드 변경 없음.

**파일**: `v2-docs/CRYPTOMENTS_V2_DDL.sql` L1874

```sql
-- 변경 전
share_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT '이 참여자의 쉐어율 (예: 0.003 = 0.3%)',

-- 변경 후
share_rate DECIMAL(10,6) NOT NULL DEFAULT 0
    COMMENT '이 참여자의 쉐어율 (퍼센트 단위, 예: 0.3 = 0.3%)',
```

운영 DB ALTER 불필요 (코멘트만 수정).

### 2-2. minFeeRate 정합성 검증 추가 (P0-2)

`aggregateDailyFees()` 시작 시 트리를 역순회하여 `minFeeRate` 검증. 불일치 발견 시 로그 경고 + 실제 계산값 사용.

**파일**: `core/settlement/SettlementService.java` — `aggregateDailyFees()` 내부, 트리 순회 직전에 추가

```java
// 트리 순회 전 minFeeRate 검증
BigDecimal computedMinFeeRate = computeMinFeeRate(partner);
if (partner.getMinFeeRate() != null
        && partner.getMinFeeRate().compareTo(computedMinFeeRate) != 0) {
    log.warn("minFeeRate 불일치: partnerId={}, stored={}, computed={}",
            partnerId, partner.getMinFeeRate(), computedMinFeeRate);
}
// 검증된 값 사용
BigDecimal verifiedMinFeeRate = computedMinFeeRate;
```

새 private 메서드:

```java
/**
 * 파트너 트리를 역순회하여 실제 minFeeRate(상위 전체 parentFeeRate 합)를 계산.
 */
private BigDecimal computeMinFeeRate(Partner partner) {
    BigDecimal sum = BigDecimal.ZERO;
    Partner current = partner;
    while (current.getParentPartnerId() != null) {
        current = partnerRepository.findOne(current.getParentPartnerId());
        if (current == null) break;
        BigDecimal pfr = current.getParentFeeRate() != null
                ? current.getParentFeeRate() : BigDecimal.ZERO;
        sum = sum.add(pfr);
    }
    return sum;
}
```

L321의 `myMinFeeRate` 대신 `verifiedMinFeeRate` 사용:

```java
// 변경 전
myMargin = depositFeeRate.subtract(myMinFeeRate);

// 변경 후
myMargin = depositFeeRate.subtract(verifiedMinFeeRate);
```

### 2-3. realizeFees() 다중 통화/네트워크 처리 (P0-3)

`SettlementRealizationJob`에서 파트너별 고유 (currencyId, networkId) 조합을 조회 후 각각 호출.

**파일**: `common/mapper/SettlementMapper.java` — 쿼리 추가

```java
/**
 * 특정 참여자의 UNREALIZED 건이 있는 (currencyId, networkId) 조합 조회.
 */
@Select("<script>"
        + "SELECT DISTINCT currency_id AS currencyId, network_id AS networkId "
        + "FROM settlement_daily_fees "
        + "WHERE status = 'UNREALIZED' "
        + "  AND participant_partner_id "
        + "<if test='participantPartnerId != null'>= #{participantPartnerId}</if>"
        + "<if test='participantPartnerId == null'>IS NULL</if>"
        + "</script>")
List<Map<String, Object>> findUnrealizedCurrencyNetworkPairs(
        @Param("participantPartnerId") Long participantPartnerId);
```

**파일**: `core/settlement/SettlementService.java` — `realizeFees()` 시그니처 변경

```java
// 변경 전
public SettlementRealization realizeFees(Long partnerId, String period) {

// 변경 후 — 통화/네트워크 파라미터 추가
public SettlementRealization realizeFees(Long partnerId, Long currencyId,
                                         Long networkId, String period) {
```

`realizeFees()` 내부에서 "첫 번째 그룹만 처리" 로직 제거, 전달받은 `currencyId/networkId`로 필터링:

```java
// 변경 전 (L392~L395)
SettlementDailyFee first = target.get(0);
Long currencyId = first.getCurrencyId();
Long networkId = first.getNetworkId();

// 변경 후 — 파라미터로 받은 값 사용, 필터링
List<SettlementDailyFee> filtered = target.stream()
        .filter(f -> currencyId.equals(f.getCurrencyId())
                  && networkId.equals(f.getNetworkId()))
        .toList();
if (filtered.isEmpty()) return null;
SettlementDailyFee first = filtered.get(0);
```

**파일**: `scheduler/job/SettlementRealizationJob.java` — 루프 변경

```java
// 변경 전
if (settlementService.realizeFees(partner.getId(), period) != null) count++;

// 변경 후
List<Map<String, Object>> pairs = settlementMapper
        .findUnrealizedCurrencyNetworkPairs(partner.getId());
for (Map<String, Object> pair : pairs) {
    Long cid = ((Number) pair.get("currencyId")).longValue();
    Long nid = ((Number) pair.get("networkId")).longValue();
    try {
        if (settlementService.realizeFees(partner.getId(), cid, nid, period) != null) count++;
    } catch (Exception e) {
        log.error("파트너 수수료 실현 실패: partnerId={}, currency={}, network={}",
                partner.getId(), cid, nid, e);
    }
}
```

SYSTEM 몫도 동일하게:

```java
List<Map<String, Object>> systemPairs = settlementMapper
        .findUnrealizedCurrencyNetworkPairs(null);
for (Map<String, Object> pair : systemPairs) {
    Long cid = ((Number) pair.get("currencyId")).longValue();
    Long nid = ((Number) pair.get("networkId")).longValue();
    try {
        if (settlementService.realizeFees(null, cid, nid, period) != null) count++;
    } catch (Exception e) {
        log.error("SYSTEM 수수료 실현 실패: currency={}, network={}", cid, nid, e);
    }
}
```

`SettlementRealizationJob`에 `SettlementMapper` 의존성 주입 추가.

---

## Phase 3 — 멱등성 + 안전 가드 (P1-1, P1-2)

### 3-1. 잔액 누적 멱등성 확보 (P1-1)

`accumulateUnrealizedBalance()`를 `dailyFeeRepository.save()` 직후에 즉시 호출하도록 변경. 마지막 일괄 누적 루프 제거.

**파일**: `core/settlement/SettlementService.java` — `aggregateDailyFees()` 내부

```java
// 변경 전 (L332~L342): save만
dailyFeeRepository.save(SettlementDailyFee.builder()...build());
count++;

// 변경 후: save 직후 즉시 잔액 누적
SettlementDailyFee dailyFee = SettlementDailyFee.builder()...build();
try {
    dailyFeeRepository.save(dailyFee);
    accumulateUnrealizedBalance(
            dailyFee.getParticipantType(),
            dailyFee.getParticipantPartnerId(),
            dailyFee.getCurrencyId(),
            dailyFee.getNetworkId(),
            dailyFee.getShareAmount());
    count++;
} catch (Exception e) {
    // UNIQUE 제약 위반 = 이미 처리됨 → skip
    log.warn("일일 수수료 중복 skip: date={}, partnerId={}, participant={}",
            date, partnerId, dailyFee.getParticipantPartnerId());
}
```

마지막의 일괄 누적 루프 (L352~L361) 제거:

```java
// 삭제
// List<SettlementDailyFee> todayFees = dailyFeeRepository.findBySettlementDate(date);
// for (SettlementDailyFee fee : todayFees) {
//     accumulateUnrealizedBalance(...);
// }
```

이 변경으로 UNIQUE 제약에 의한 중복 삽입 방지 + 잔액 이중 누적 동시에 해결.

SYSTEM 몫 (L281~L291)과 최상위 총판 몫 (L298~L308)에도 동일 패턴 적용.

### 3-2. 최소 실현 금액 + timezone 통일 (P1-2)

**파일**: `core/settlement/SettlementService.java` — `realizeFees()` 초반에 추가

```java
// 최소 실현 금액 (10 USD 기본값)
private static final BigDecimal MIN_REALIZATION_AMOUNT = new BigDecimal("10");

// realizeFees() 내부, totalShare 합산 후
if (totalShare.compareTo(MIN_REALIZATION_AMOUNT) < 0) {
    log.debug("최소 실현 금액 미달: partnerId={}, totalShare={}", partnerId, totalShare);
    return null;
}
```

향후 `system_settings` 테이블에서 동적 로드하도록 확장 가능.

**파일**: `scheduler/job/SettlementRealizationJob.java` — timezone 추가

```java
// 변경 전
@Scheduled(cron = "0 0 1 * * *")

// 변경 후
@Scheduled(cron = "0 0 1 * * *", zone = "Asia/Seoul")
```

---

## Phase 4 — 가스비 + 보조 수정 (P2-1, P2-2, P2-3, P1-3)

### 4-1. markAsInvoiced() 호출 추가 (P2-1)

**파일**: `core/settlement/SettlementService.java` — `generateMonthlyInvoice()` L574 다음에 추가

```java
Long id = gasInvoiceRepository.save(invoice);
invoice.setId(id);

// 추가: 가스비 레코드 → INVOICED 상태 전환
gasMapper.markAsInvoiced(partnerId, yearMonthStr, id);
```

### 4-2. 비활성 파트너 필터 (P2-2)

**파일**: `core/settlement/SettlementService.java` — `generateAllInvoices()` L586

```java
// 변경 전
List<Partner> partners = partnerRepository.findAll();

// 변경 후
List<Partner> partners = partnerRepository.findByStatus(com.cryptoments.common.enums.PartnerStatus.ACTIVE);
```

### 4-3. sumUnrealizedShareAmount() 교차 검증 (P2-3)

**파일**: `core/settlement/SettlementService.java` — `realizeFees()` 내부, realization 생성 직전에 추가

```java
// DB 합계와 Java 합산 교차 검증
BigDecimal dbSum = settlementMapper.sumUnrealizedShareAmount(partnerId, currencyId, networkId);
if (totalShare.compareTo(dbSum) != 0) {
    log.error("실현 금액 불일치: Java합산={}, DB합산={}, partnerId={}", totalShare, dbSum, partnerId);
    throw new ConflictException(ErrorCodes.SETTLEMENT_AMOUNT_MISMATCH,
            "Java/DB 합산 불일치: " + totalShare + " vs " + dbSum);
}
```

### 4-4. PriceService 네이티브 토큰 시세 (P1-3) — 설계만

현재 Node.js에서 가스비 USD 환산 후 전달하는 구조. 단기적으로는 Node.js 측 환산 정확성 검증으로 충분.

장기적으로 PriceService에 네이티브 토큰 시세 기능 추가 시:
- `currency_prices` 테이블에 ETH/BNB/MATIC/TRX 행 추가
- `PriceSyncJob`에서 빗썸 API로 네이티브 토큰 시세도 갱신
- `recordGasCost()`에서 `feeUsd` 자동 계산 옵션 제공

→ 별도 설계 문서로 분리 (현 계획 범위 외).

---

## 적용 순서 + 체크리스트

| 순서 | Phase | 작업 | 변경 파일 수 | 테스트 |
|------|-------|------|------------|--------|
| 1 | 1-1 | @Transactional 추가 | 1 | 컴파일 확인 |
| 2 | 1-2 | computeBalanceForUpdate + debit 잠금 | 2 | 동시 출금 시나리오 |
| 3 | 2-1 | DDL shareRate 코멘트 수정 | 1 | N/A |
| 4 | 2-2 | computeMinFeeRate 검증 | 1 | §6.3 SQL #3 실행 |
| 5 | 2-3 | realizeFees 다중 통화/네트워크 | 3 | 다중 체인 파트너 실현 테스트 |
| 6 | 3-1 | 잔액 누적 멱등성 | 1 | aggregateDailyFees 재실행 |
| 7 | 3-2 | 최소 실현 금액 + timezone | 2 | 소액 실현 skip 확인 |
| 8 | 4-1 | markAsInvoiced 호출 | 1 | 인보이스 생성 후 billing_status 확인 |
| 9 | 4-2 | 비활성 파트너 필터 | 1 | SUSPENDED 파트너 인보이스 미생성 확인 |
| 10 | 4-3 | sumUnrealizedShareAmount 교차 검증 | 1 | 불일치 시 예외 발생 확인 |

---

## 배포 순서

```
1. Phase 1 (안전장치) → admin-api, core 재빌드 → app-01 배포
2. Phase 2 (핵심 로직) → core, scheduler, common 재빌드 → app-01 배포
3. Phase 3 (멱등성)   → core, scheduler 재빌드 → app-01 배포
4. Phase 4 (가스비)   → core 재빌드 → app-01 배포
```

Phase 1은 독립 배포 가능 (기존 동작에 영향 없음).
Phase 2~3은 같이 배포 권장 (realizeFees 시그니처 변경이 scheduler에 영향).
Phase 4는 독립 배포 가능.

---

## 배포 후 검증

운영 DB에서 리포트 §6.3의 검증 SQL 4개 실행:

```bash
ssh cryptoments-bastion "ssh db-01 'mysql -u cryptoments -p\"Crypt0m3nts!2026\" cryptoments_db -e \"
  -- SQL #1: 분배 합계 검증
  -- SQL #2: 미실현 잔액 정합성
  -- SQL #3: minFeeRate 정합성
  -- SQL #4: 실현 완료 금액 검증
\"'"
```

4개 쿼리 모두 결과 0건이면 정합성 확인 완료.
