# 입금 후처리 연결 + 잔액 동기화 수정 지침서

> **목적**: (1) processDeposit() → onTxConfirmed() 연결 (원장 기록 + 집금 enqueue), (2) 잔액 동기화 로그 개선
> **날짜**: 2026-03-22
> **대상 모듈**: open-api, core
> **발견 계기**: Phase 3 E2E 테스트 — ADMIN→HOT 입금 후 collection_queue 미생성, wallet_approvals 미등록

---

## 1. 문제 요약

### 1-1. 입금 후처리 누락 (Critical)

| 항목 | 현재 | 기대 |
|------|------|------|
| Deposit INSERT | ✅ CONFIRMED 상태로 저장됨 | - |
| 원장 기록 (ledger_entries) | ❌ 미실행 | `settlementService.credit()` |
| 수수료 기록 | ❌ 미실행 (feeAmount=0 저장) | `settlementService.recordFee()` |
| 집금 큐 등록 (collection_queue) | ❌ 미실행 | `enqueueCollection()` |
| 상태 이력 기록 | ❌ 미실행 | `transaction_status_history INSERT` |
| HOT approve 트리거 | ❌ 불가 (집금 큐가 없으므로) | CollectionPoller → approve |

**원인**: `WebhookProcessingService.processDeposit()`가 Deposit 레코드만 INSERT하고 끝남.
`DepositService.onTxConfirmed()`를 호출하지 않아서 후처리 로직이 전부 누락.

**영향 체인**:
```
processDeposit() → Deposit INSERT (여기서 끝)
                 ↓ (연결 끊김)
onTxConfirmed() → ledger credit + fee + enqueueCollection()
                                        ↓ (실행 안 됨)
                              CollectionPoller → approve INSERT
                                                  ↓ (실행 안 됨)
                                        ApprovalPoller → approve TX
```

### 1-2. 수수료 미계산

| 항목 | 현재 | 기대 |
|------|------|------|
| processDeposit() feeAmount | `BigDecimal.ZERO` 고정 | 파트너 수수료율 기반 계산 |

**원인**: `processDeposit()`에서 수수료를 계산하지 않고 ZERO로 저장.
`onTxDetected()`에는 수수료 계산 로직이 있지만, processDeposit()에는 없음.

### 1-3. 잔액 동기화 실패 로그 (Minor)

로그: `Webhook 잔액 동기화 실패: address=..., error=null`

**원인**: `WebhookBalanceSyncService.syncWalletByAddress()`에서 `e.getMessage()` 사용.
`NullPointerException` 등 메시지 없는 예외는 `null` 출력 → 디버깅 불가.

---

## 2. 수정 사항

### 2-1. WebhookProcessingService — DepositService 의존성 추가

**파일**: `open-api/src/main/java/com/cryptoments/webhook/service/WebhookProcessingService.java`

**변경 1: import + 필드 추가**

```java
// import 추가
import com.cryptoments.core.deposit.DepositService;
```

**변경 2: 생성자에 DepositService 주입**

**Before**:
```java
private final CurrencyRepository currencyRepository;

public WebhookProcessingService(BlockchainNetworkRepository blockchainNetworkRepository,
                                 BusinessEventClassifier businessEventClassifier,
                                 WebhookBalanceSyncService webhookBalanceSyncService,
                                 DepositRepository depositRepository,
                                 WithdrawalRepository withdrawalRepository,
                                 CollectionQueueRepository collectionQueueRepository,
                                 WalletAddressRepository walletAddressRepository,
                                 CurrencyRepository currencyRepository) {
    this.blockchainNetworkRepository = blockchainNetworkRepository;
    this.businessEventClassifier = businessEventClassifier;
    this.webhookBalanceSyncService = webhookBalanceSyncService;
    this.depositRepository = depositRepository;
    this.withdrawalRepository = withdrawalRepository;
    this.collectionQueueRepository = collectionQueueRepository;
    this.walletAddressRepository = walletAddressRepository;
    this.currencyRepository = currencyRepository;
}
```

**After**:
```java
private final CurrencyRepository currencyRepository;
private final DepositService depositService;

public WebhookProcessingService(BlockchainNetworkRepository blockchainNetworkRepository,
                                 BusinessEventClassifier businessEventClassifier,
                                 WebhookBalanceSyncService webhookBalanceSyncService,
                                 DepositRepository depositRepository,
                                 WithdrawalRepository withdrawalRepository,
                                 CollectionQueueRepository collectionQueueRepository,
                                 WalletAddressRepository walletAddressRepository,
                                 CurrencyRepository currencyRepository,
                                 DepositService depositService) {
    this.blockchainNetworkRepository = blockchainNetworkRepository;
    this.businessEventClassifier = businessEventClassifier;
    this.webhookBalanceSyncService = webhookBalanceSyncService;
    this.depositRepository = depositRepository;
    this.withdrawalRepository = withdrawalRepository;
    this.collectionQueueRepository = collectionQueueRepository;
    this.walletAddressRepository = walletAddressRepository;
    this.currencyRepository = currencyRepository;
    this.depositService = depositService;
}
```

---

### 2-2. processDeposit() — 후처리 호출 추가

**파일**: `open-api/src/main/java/com/cryptoments/webhook/service/WebhookProcessingService.java`

**핵심 변경**: processDeposit()에서 Deposit을 **DETECTED** 상태로 저장한 뒤, `depositService.onTxConfirmed()`를 호출하여 후처리를 실행한다.

> **왜 DETECTED로 저장?**: `onTxConfirmed()`에 멱등성 가드가 있다 (CONFIRMED이면 즉시 return). CONFIRMED로 저장하면 onTxConfirmed()가 아무것도 안 한다.

**변경 위치**: processDeposit() 메서드 전체 (line 111~193)

**Before** (신규 입금 생성 부분, line 130~193):
```java
// 신규 입금 생성 (CONFIRMED 상태로 바로 생성 — webhook은 confirmed TX만 수신)
// ... (중간 코드 동일) ...

Deposit deposit = Deposit.builder()
        .depositCode(generateDepositCode())
        .partnerId(partnerId)
        .networkId(networkId)
        .currencyId(currencyId)
        .depositType(depositType)
        .depositMethod(depositMethod)
        .txHash(dto.getTxHash())
        .fromAddress(dto.getFrom())
        .toAddress(dto.getTo())
        .amount(amount)
        .feeAmount(BigDecimal.ZERO)
        .blockNumber(dto.getBlockNumber())
        .walletAddressId(toWallet != null ? toWallet.getId() : null)
        .status(DepositStatus.CONFIRMED)
        .confirmedAt(LocalDateTime.now())
        .build();
depositRepository.save(deposit);
log.info("신규 입금 생성: txHash={}, to={}, amount={}, partnerId={}, currencyId={}",
        dto.getTxHash(), dto.getTo(), amount, partnerId, currencyId);
```

**After**:
```java
// 신규 입금 생성 (DETECTED 상태로 생성 → onTxConfirmed()에서 후처리)
// ... (중간 코드 동일) ...

// 수수료 계산
BigDecimal feeAmount = BigDecimal.ZERO;
if (partnerId != null) {
    com.cryptoments.common.entity.Partner partner =
            new com.cryptoments.common.repository.PartnerRepository() // ← 아래 2-3 참조
            ... // 실제로는 partnerRepository 주입 필요
}

Deposit deposit = Deposit.builder()
        .depositCode(generateDepositCode())
        .partnerId(partnerId)
        .networkId(networkId)
        .currencyId(currencyId)
        .depositType(depositType)
        .depositMethod(depositMethod)
        .txHash(dto.getTxHash())
        .fromAddress(dto.getFrom())
        .toAddress(dto.getTo())
        .amount(amount)
        .feeAmount(BigDecimal.ZERO)  // 수수료는 onTxConfirmed에서 처리하지 않음 — 아래 참고
        .blockNumber(dto.getBlockNumber())
        .walletAddressId(toWallet != null ? toWallet.getId() : null)
        .status(DepositStatus.DETECTED)       // ← CONFIRMED → DETECTED 변경
        // .confirmedAt 제거 — onTxConfirmed()에서 설정
        .build();
Long depositId = depositRepository.save(deposit);
log.info("신규 입금 생성: txHash={}, to={}, amount={}, partnerId={}, currencyId={}",
        dto.getTxHash(), dto.getTo(), amount, partnerId, currencyId);

// ── 후처리: 원장 기록 + 집금 enqueue ──
try {
    depositService.onTxConfirmed(depositId);
    log.info("입금 후처리 완료: depositId={}", depositId);
} catch (Exception e) {
    log.error("입금 후처리 실패: depositId={}, txHash={}", depositId, dto.getTxHash(), e);
}
```

**기존 DETECTED→CONFIRMED 업데이트 부분도 수정** (line 113~128):

**Before**:
```java
List<Deposit> existingList = depositRepository.findByTxHash(dto.getTxHash());
if (!existingList.isEmpty()) {
    for (Deposit existing : existingList) {
        if (existing.getStatus() == DepositStatus.DETECTED) {
            Deposit updated = existing.toBuilder()
                    .status(DepositStatus.CONFIRMED)
                    .confirmedAt(LocalDateTime.now())
                    .build();
            depositRepository.modify(updated);
            log.info("입금 확인: depositId={}, amount={}", existing.getId(), existing.getAmount());
        } else {
            log.info("입금 중복 감지 (멱등): depositId={}, status={}", existing.getId(), existing.getStatus());
        }
    }
    return;
}
```

**After**:
```java
List<Deposit> existingList = depositRepository.findByTxHash(dto.getTxHash());
if (!existingList.isEmpty()) {
    for (Deposit existing : existingList) {
        if (existing.getStatus() == DepositStatus.DETECTED) {
            // DETECTED 상태 입금 → onTxConfirmed()로 후처리 포함 확정
            try {
                depositService.onTxConfirmed(existing.getId());
                log.info("입금 확인 + 후처리: depositId={}, amount={}", existing.getId(), existing.getAmount());
            } catch (Exception e) {
                log.error("입금 확인 후처리 실패: depositId={}", existing.getId(), e);
            }
        } else {
            log.info("입금 중복 감지 (멱등): depositId={}, status={}", existing.getId(), existing.getStatus());
        }
    }
    return;
}
```

---

### 2-3. 수수료 계산 — 두 가지 방법 중 택 1

`onTxConfirmed()`는 deposit 레코드의 `feeAmount`를 읽어서 원장에 기록한다. 따라서 processDeposit() 단계에서 수수료를 계산해야 한다.

**방법 A (권장): PartnerRepository 주입하여 processDeposit()에서 계산**

PartnerRepository를 WebhookProcessingService에 추가 주입:

```java
// 필드 추가
private final com.cryptoments.common.repository.PartnerRepository partnerRepository;

// 생성자 파라미터 추가
```

processDeposit() 내 수수료 계산:

```java
// amount 계산 후, Deposit.builder() 전에 추가
BigDecimal feeAmount = BigDecimal.ZERO;
if (partnerId != null) {
    com.cryptoments.common.entity.Partner partner = partnerRepository.findOne(partnerId);
    if (partner != null && partner.getDepositFeeRate() != null) {
        feeAmount = amount.multiply(partner.getDepositFeeRate())
                .setScale(18, java.math.RoundingMode.HALF_UP);
    }
}

// builder에서 .feeAmount(feeAmount)
```

**방법 B: onTxConfirmed()에서 feeAmount 재계산**

`DepositService.onTxConfirmed()`의 수수료 기록 부분을 수정:

```java
// 기존: deposit.getFeeAmount()를 읽어서 기록
// 변경: feeAmount가 ZERO이면 재계산
BigDecimal feeAmount = deposit.getFeeAmount();
if (feeAmount == null || feeAmount.compareTo(BigDecimal.ZERO) == 0) {
    Partner partner = partnerRepository.findOne(deposit.getPartnerId());
    if (partner != null && partner.getDepositFeeRate() != null) {
        feeAmount = deposit.getAmount().multiply(partner.getDepositFeeRate())
                .setScale(18, RoundingMode.HALF_UP);
        // deposit에도 업데이트
        Deposit updated = deposit.toBuilder().feeAmount(feeAmount).build();
        depositRepository.modify(updated);
    }
}
```

> **판단**: 방법 A가 깔끔. processDeposit()에서 모든 데이터를 완성한 뒤 onTxConfirmed()을 호출.

---

### 2-4. WebhookBalanceSyncService — 로그 개선

**파일**: `core/src/main/java/com/cryptoments/core/webhook/WebhookBalanceSyncService.java`

**변경 위치**: `syncWalletByAddress()` 메서드 (line 86~88)

**Before**:
```java
} catch (Exception e) {
    // 동기화 실패해도 비즈니스 처리는 이미 완료됨 — 로그만 남기고 진행
    log.warn("Webhook 잔액 동기화 실패: address={}, error={}", address, e.getMessage());
}
```

**After**:
```java
} catch (Exception e) {
    // 동기화 실패해도 비즈니스 처리는 이미 완료됨 — 로그만 남기고 진행
    log.warn("Webhook 잔액 동기화 실패: address={}, error={}", address, e.toString(), e);
}
```

> `e.getMessage()`는 NPE 등에서 null 반환. `e.toString()`은 예외 클래스명 포함. 세 번째 인자 `e`는 스택트레이스 출력.

---

## 3. 전체 호출 체인 (수정 후)

```
[Webhook 수신]
  → WebhookProcessingService.process()
    → BusinessEventClassifier.classify() → "DEPOSIT"
    → processDeposit()
      → Deposit INSERT (DETECTED 상태)
      → depositService.onTxConfirmed(depositId) ← ★ 새로 추가
        → status: DETECTED → CONFIRMED
        → settlementService.credit() [원장 기록]
        → settlementService.recordFee() [수수료 기록]
        → enqueueCollection() [collection_queue INSERT, QUEUED 상태]
          → deposit.status → COLLECTING
    → webhookBalanceSyncService.syncAffectedWallets()

[Node.js — CollectionPoller, 3초 간격]
  → collection_queue 에서 QUEUED 항목 조회
  → executeCollection()
    → walletApprovalRepo.findByWalletAndCurrency()
    → approve 없음 → wallet_approvals INSERT (PENDING) ← ★ approve 트리거
    → collection_queue.status → DEFERRED

[Node.js — ApprovalPoller, 5초 간격]
  → wallet_approvals에서 PENDING 조회
  → processApproval()
    → GAS 전송 (필요시)
    → approve TX 브로드캐스트
    → wallet_approvals.status → APPROVED

[Node.js — CollectionPoller, 다음 주기]
  → DEFERRED 항목 재조회
  → approve 확인 (APPROVED)
  → transferFrom 실행 (HOT → MASTER 집금)
  → collection_queue.status → BROADCASTING

[Webhook — 집금 TX 확인]
  → COLLECTION_CONFIRM
  → collection_queue.status → CONFIRMED
  → deposit.status → SETTLED
```

---

## 4. 적용 체크리스트

| # | 작업 | 파일 |
|---|------|------|
| 1 | WebhookProcessingService — DepositService 의존성 주입 (섹션 2-1) | `WebhookProcessingService.java` |
| 2 | processDeposit() — DETECTED 저장 + onTxConfirmed() 호출 (섹션 2-2) | `WebhookProcessingService.java` |
| 3 | processDeposit() — 수수료 계산 추가 (섹션 2-3, 방법 A 또는 B) | `WebhookProcessingService.java` 또는 `DepositService.java` |
| 4 | 기존 DETECTED→CONFIRMED 분기 — onTxConfirmed() 호출로 변경 (섹션 2-2) | `WebhookProcessingService.java` |
| 5 | WebhookBalanceSyncService — 로그 e.getMessage() → e.toString(), e (섹션 2-4) | `WebhookBalanceSyncService.java` |
| 6 | `./gradlew :open-api:compileJava` — 컴파일 확인 | - |
| 7 | open-api 재기동 | - |

---

## 5. 검증 시나리오

### 5-1. ADMIN→HOT 입금 후처리 (핵심 시나리오)

```bash
curl -X POST http://localhost:8082/api/v2/webhooks/blockchain-monitor \
  -H "Content-Type: application/json" \
  -d '{
    "eventType": "TRANSFER",
    "chainId": "56",
    "status": "CONFIRMED",
    "txHash": "0xtest_post_processing_001",
    "from": "0xF9cbB86D5fae82183AA8e0E4fFf05a4C917414FE",
    "to": "0x66059E8C81D5E400D546B07a356922d1F0D158E5",
    "amount": "2000000000000000000",
    "tokenSymbol": "USDT",
    "contractAddress": "0x55d398326f99059fF775485246999027B3197955",
    "tokenDecimals": 18
  }'
```

**DB 검증**:

```sql
-- 1. deposits: CONFIRMED 또는 COLLECTING 상태
SELECT id, deposit_code, status, amount, fee_amount, collection_queue_id
FROM deposits WHERE tx_hash = '0xtest_post_processing_001';
-- 기대: status=COLLECTING, amount=2.0, collection_queue_id IS NOT NULL

-- 2. collection_queue: QUEUED 상태
SELECT id, collection_code, status, wallet_address_id, amount
FROM collection_queue ORDER BY id DESC LIMIT 1;
-- 기대: status=QUEUED, wallet_address_id=21, amount=2.0

-- 3. ledger_entries: credit 기록
SELECT id, partner_id, entry_type, amount, reference_type, reference_id
FROM ledger_entries WHERE reference_type = 'DEPOSIT' ORDER BY id DESC LIMIT 2;
-- 기대: CREDIT 행 + (수수료 있으면) FEE 행

-- 4. transaction_status_history: 이력
SELECT * FROM transaction_status_history
WHERE tx_type = 'DEPOSIT' ORDER BY id DESC LIMIT 1;
-- 기대: from_status=DETECTED, to_status=CONFIRMED
```

### 5-2. CollectionPoller → approve 트리거 확인

collection_queue가 QUEUED 상태로 들어가면, relayer-api의 CollectionPoller가 3초 내 조회.
HOT 지갑(id=21)에 대한 approve가 없으면 wallet_approvals에 PENDING INSERT.

```sql
-- CollectionPoller 실행 후 (약 5~10초 대기)
SELECT * FROM wallet_approvals WHERE wallet_address_id = 21;
-- 기대: status=PENDING (또는 wallet-activator가 빠르면 APPROVED)

SELECT * FROM collection_queue ORDER BY id DESC LIMIT 1;
-- 기대: status=DEFERRED (approve 대기 중)
```

### 5-3. 잔액 동기화 로그 확인

수정 후 동일한 테스트 실행 → 로그에서 `error=null` 대신 실제 예외 정보가 출력되는지 확인.
예: `error=java.lang.NullPointerException: Cannot invoke ...`

---

## 6. 순환 의존성 주의

`WebhookProcessingService`(open-api) → `DepositService`(core) → `WalletService`(core)

이 방향은 open-api → core 단방향이므로 순환 의존성 없음.
DepositService는 이미 core 모듈에 있고, open-api의 build.gradle에 `implementation project(':core')`가 있으므로 추가 설정 불필요.

---

## 7. 잔액 동기화 실패 근본 원인 (참고)

`error=null`의 실체가 밝혀진 후 추가 대응:

1. **blockchain-api 응답 null**: `syncOnchainBalance()`에서 `blockchainApiClient.queryBalance()` 반환값이 null → `onchain.getBalance()` 호출 시 NPE
2. **대응**: `WalletService.syncOnchainBalance()`에 null 체크 추가 (별도 지침서)
3. **우선순위**: 입금 파이프라인에는 영향 없음 (잔액 동기화는 백업 메커니즘). 입금 후처리 연결이 우선.
