# 스케줄러 배치 작업 구현 지침서 — Guide #35

> **작성일**: 2026-03-22
> **범위**: Spring Boot scheduler 모듈
> **기술 스택**: Spring Boot 3.3.1, Java 17, MyBatis
> **핵심 배치 작업**: 4개 (RetryFailedWithdrawals, ExpireStaleWithdrawals, StaleTxMonitor, BalanceLowCheck)

---

## 개요

`scheduler` 모듈은 Spring Boot 애플리케이션 내에서 주기적으로 실행되는 배치 작업들을 관리한다.
각 배치 작업은 **독립적인 @Component 클래스**로 구현되며, `@Scheduled` 어노테이션으로 실행 주기를 정의한다.

### 현재 구조

```
scheduler/
├── job/
│   ├── PriceSyncJob.java              (✅ 기존 — 시세 동기화)
│   ├── RetryFailedWithdrawalsJob.java (⚠️ 신규)
│   ├── ExpireStaleWithdrawalsJob.java (⚠️ 신규)
│   ├── StaleTxMonitorJob.java         (⚠️ 신규)
│   └── BalanceLowCheckJob.java        (⚠️ 신규)
├── client/
│   ├── BithumbApiClient.java          (✅ 기존)
│   └── BlockchainApiClient.java       (⚠️ 신규 — blockchain-api 통합)
├── controller/
│   └── SchedulerController.java       (기존 — 수동 트리거 + 상태 조회)
├── SchedulerApplication.java          (✅ @EnableScheduling 활성화)
└── build.gradle                       (의존성 관리)
```

### 실행 패턴

| 배치 작업 | 주기 | 담당 | 영향 범위 |
|----------|------|------|----------|
| **RetryFailedWithdrawals** | 5분 | `WithdrawalService` | 출금 상태 머신 (FAILED → APPROVED) |
| **ExpireStaleWithdrawals** | 1시간 | `WithdrawalService` | 출금 상태 머신 (REQUESTED/PENDING_APPROVAL → CANCELLED) |
| **StaleTxMonitor** | 10분 | `BlockchainApiClient` + `WebhookService` | 집금/출금 TX 미확정 감시 (BROADCASTING → CONFIRMED/FAILED/STALE) |
| **BalanceLowCheck** | 30분 | `WalletBalanceRepository` + `NotificationService` | 잔액 부족 알림 (Telegram + Alert) |

---

## 아키텍처 원칙

### 1. 배치 작업 독립성

각 배치 작업이 **독립적**으로 실행되도록 설계하여, 한 작업의 실패가 다른 작업에 영향을 주지 않는다.

```java
@Component
public class RetryFailedWithdrawalsJob {

    @Scheduled(fixedRate = 300000)  // 5분(300,000ms)
    public void execute() {
        try {
            long startTime = System.currentTimeMillis();
            log.info("=== 실패 출금 자동 재시도 시작 ===");

            int count = withdrawalService.retryFailedWithdrawals();

            long elapsed = System.currentTimeMillis() - startTime;
            log.info("=== 실패 출금 자동 재시도 완료: {}건, 소요 {}ms ===", count, elapsed);

        } catch (Exception e) {
            // ★ 예외를 로깅하되, 다른 배치에 영향을 주지 않도록 블록 내에서 처리
            log.error("실패 출금 자동 재시도 중 오류 발생", e);
            // 선택: 알림 발송 (메인 로직은 중단하지 않음)
        }
    }
}
```

### 2. 스케줄 설정 방식: @Scheduled 어노테이션

| 방식 | 사용 시점 | 예 |
|------|----------|-----|
| `fixedRate` | 고정 간격 (이전 시작 기준) | `fixedRate = 300000` (5분마다) |
| `fixedDelay` | 고정 간격 (이전 종료 기준) | `fixedDelay = 600000` (이전 작업 종료 후 10분 대기) |
| `cron` | Cron 표현식 | `cron = "0 */5 * * * *"` (매 5분) |

**권장**: `fixedRate` — 일정한 간격으로 실행되므로 예측 가능함.

### 3. 로깅 패턴

모든 배치 작업은 **실행 시작, 진행 중 상태, 완료** 순으로 로깅한다.

```java
log.info("=== [배치명] 시작 ===");
log.info("처리 중: item={}, count={}", item, processedCount);
log.info("=== [배치명] 완료: 성공={}, 실패={}, 소요={}ms ===",
         successCount, failureCount, elapsed);
```

### 4. 오류 처리 전략

- **배치 자체의 예외**: 캐치하여 로깅. 다른 배치에 영향 없음.
- **개별 항목의 예외**: 로그하고 계속 처리 (다른 항목은 영향 없음).
- **DB 연결 오류**: 로깅 후 조용히 종료 (다음 주기에 재시도).

```java
try {
    // 배치 로직
} catch (DataAccessException e) {
    log.error("DB 접근 오류", e);
    return;  // 조용히 종료 — 다음 주기 재시도
} catch (Exception e) {
    log.error("예상 밖의 오류", e);
    return;
}
```

---

## Job 1: RetryFailedWithdrawalsJob (5분 주기)

### 목적

FAILED 상태의 출금을 자동으로 **APPROVED로 변경**하여 재시도 대상으로 만든다.
Node.js relayer-api가 이들 출금을 다시 브로드캐스트한다.

### 설계

| 항목 | 값 |
|------|-----|
| **클래스명** | `RetryFailedWithdrawalsJob` |
| **패키지** | `com.cryptoments.scheduler.job` |
| **실행 주기** | `fixedRate = 300000` (5분) |
| **호출 메서드** | `WithdrawalService.retryFailedWithdrawals()` |
| **반환** | 재시도 처리된 건수 |
| **상태 전이** | FAILED → APPROVED |

### 구현

```java
package com.cryptoments.scheduler.job;

import com.cryptoments.core.withdrawal.WithdrawalService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * 실패 출금 자동 재시도 배치.
 *
 * <p>FAILED 상태 출금을 APPROVED로 변경하여 재시도 대상으로 만든다.
 * 실행 주기: 5분 (fixedRate = 300000ms)
 *
 * <p>의존성: WithdrawalService.retryFailedWithdrawals()
 */
@Component
public class RetryFailedWithdrawalsJob {

    private static final Logger log = LoggerFactory.getLogger(RetryFailedWithdrawalsJob.class);

    private final WithdrawalService withdrawalService;

    public RetryFailedWithdrawalsJob(WithdrawalService withdrawalService) {
        this.withdrawalService = withdrawalService;
    }

    /**
     * 실패 출금 자동 재시도 실행.
     */
    @Scheduled(fixedRate = 300000)  // 5분마다
    public void execute() {
        try {
            long startTime = System.currentTimeMillis();
            log.info("=== 실패 출금 자동 재시도 시작 ===");

            int retryCount = withdrawalService.retryFailedWithdrawals();

            long elapsed = System.currentTimeMillis() - startTime;
            log.info("=== 실패 출금 자동 재시도 완료: 재시도 건수={}, 소요 {}ms ===",
                    retryCount, elapsed);

        } catch (Exception e) {
            log.error("실패 출금 자동 재시도 중 오류 발생", e);
            // 조용히 종료 — 다음 주기에 재시도
        }
    }
}
```

### WithdrawalService 메서드 검증

`WithdrawalService.retryFailedWithdrawals()`은 다음을 수행한다:
- FAILED 상태 출금 조회
- 각 출금을 APPROVED로 변경
- TransactionStatusHistory 기록 (FAILED → APPROVED, "자동 재시도")
- 처리 건수 반환

✅ 이미 구현됨 (core 모듈 — lines 271-290).

---

## Job 2: ~~ExpireStaleWithdrawalsJob (1시간 주기)~~ — **폐기됨 (2026-08-13)**

> ⚠️ **이 Job 은 제거됐다. 다시 구현하지 말 것.**
> 관리자가 판단해야 할 건(승인/거부/취소)을 스케줄러가 조용히 CANCELLED 로 종결하는 구조라
> 파트너·회원에게는 "이유 없이 취소됨"으로 보인다. 오너 결정으로 프로세스 자체를 폐기했다
> (제거 시점 운영 대상 건수 0 → 데이터 영향 없음).
> `WithdrawalService.expireStaleWithdrawals()`, `ExpireStaleWithdrawalsJob`,
> `SchedulerController` 의 `/expire-stale-withdrawals/trigger` 가 모두 삭제됐다.
> 장기 대기 출금은 어드민 출금 목록(`status=REQUESTED` + 기간 필터)으로 인지해 직접 종결한다.
>
> 아래 내용은 폐기 전 설계 기록이다.

### 목적

**24시간 이상 대기 중인** REQUESTED/PENDING_APPROVAL 출금을 자동으로 CANCELLED로 변경한다.

### 설계

| 항목 | 값 |
|------|-----|
| **클래스명** | `ExpireStaleWithdrawalsJob` |
| **패키지** | `com.cryptoments.scheduler.job` |
| **실행 주기** | `fixedRate = 3600000` (1시간) |
| **호출 메서드** | `WithdrawalService.expireStaleWithdrawals()` |
| **반환** | 만료 처리된 건수 |
| **상태 전이** | REQUESTED/PENDING_APPROVAL → CANCELLED |
| **임계값** | `LocalDateTime.now().minusHours(24)` |

### 구현

```java
package com.cryptoments.scheduler.job;

import com.cryptoments.core.withdrawal.WithdrawalService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * 장기 대기 출금 자동 만료 배치.
 *
 * <p>REQUESTED/PENDING_APPROVAL 상태로 24시간 이상 경과한 출금을 CANCELLED로 변경한다.
 * 실행 주기: 1시간 (fixedRate = 3600000ms)
 *
 * <p>의존성: WithdrawalService.expireStaleWithdrawals()
 */
@Component
public class ExpireStaleWithdrawalsJob {

    private static final Logger log = LoggerFactory.getLogger(ExpireStaleWithdrawalsJob.class);

    private final WithdrawalService withdrawalService;

    public ExpireStaleWithdrawalsJob(WithdrawalService withdrawalService) {
        this.withdrawalService = withdrawalService;
    }

    /**
     * 장기 대기 출금 자동 만료 실행.
     *
     * <p>24시간 이상 REQUESTED 또는 PENDING_APPROVAL 상태인 출금을 만료 처리한다.
     */
    @Scheduled(fixedRate = 3600000)  // 1시간마다
    public void execute() {
        try {
            long startTime = System.currentTimeMillis();
            log.info("=== 장기 대기 출금 자동 만료 시작 ===");

            int expiredCount = withdrawalService.expireStaleWithdrawals();

            long elapsed = System.currentTimeMillis() - startTime;
            log.info("=== 장기 대기 출금 자동 만료 완료: 만료 건수={}, 소요 {}ms ===",
                    expiredCount, elapsed);

        } catch (Exception e) {
            log.error("장기 대기 출금 자동 만료 중 오류 발생", e);
        }
    }
}
```

### WithdrawalService 메서드 검증

`WithdrawalService.expireStaleWithdrawals()`은 다음을 수행한다:
- 24시간 이상 경과한 REQUESTED/PENDING_APPROVAL 출금 조회
- 각 출금을 CANCELLED로 변경
- SettlementService.unfreeze() 호출 (동결된 잔액 해제)
- TransactionStatusHistory 기록
- 처리 건수 반환

✅ 이미 구현됨 (core 모듈 — lines 298-327).

---

## Job 3: StaleTxMonitorJob (10분 주기)

### 목적

BROADCASTING 상태로 오래 머물러 있는 **출금/집금 TX**를 감시하고,
온체인 상태를 점검하여 미확정 TX를 복구하거나 실패 처리한다.

### 설계

#### 3-1. 감시 대상

| 테이블 | 감시 조건 | 상태 전이 |
|--------|----------|---------|
| `withdrawals` | status='BROADCASTING', updated_at < NOW() - 30분 | CONFIRMED/FAILED/STALE |
| `collection_batches` | status='BROADCASTING', updated_at < NOW() - 30분 | CONFIRMED/FAILED/STALE |

#### 3-2. 분기 처리 로직

```
[각 미확정 TX에 대해]

1. blockchain-api에서 receipt 조회
   GET /api/v1/tx/{networkId}/{txHash}/receipt

2. receipt 존재 + status=1 (success)?
   ✅ YES → CONFIRMED 처리
      - status → CONFIRMED
      - gas_cost_records INSERT (receipt 가스비)
      - collection_queue → COLLECTED (집금인 경우)
      - Telegram 알림: "⚠️ Webhook 누락 복구: txHash={hash}"

   ❌ NO (receipt 있는데 status=0, 즉 revert)?
      - status → FAILED
      - collection_queue QUEUED 유지 (집금인 경우 — 다음 배치 재시도)
      - Telegram 알림: "❌ TX Revert 감지: txHash={hash}"

   ❌ NO (receipt 없음)?
      - status → STALE
      - 해당 Relayer에 추가 TX 발송 차단 (막힌 nonce 보호)
      - Telegram 알림: "⚠️ 미확정 TX 감지: txHash={hash}, 관리자 판단 필요"
```

#### 3-3. 구현 전 준비사항

**blockchain-api에 TX receipt 조회 API 필요**:

```
GET /api/v1/tx/{networkId}/{txHash}/receipt
응답:
{
  "transactionHash": "0xabc...",
  "blockNumber": 12345,
  "gasUsed": "50000",
  "gasPrice": "20000000000",
  "status": 1,        // 1=success, 0=revert, null=pending
  "from": "0x...",
  "to": "0x...",
  "value": "1000000000000000000",
  "input": "0xa9059cbb..."
}
```

Node.js blockchain-api 모듈에서 이 엔드포인트를 구현해야 한다.

### 구현

#### Step 1: BlockchainApiClient 생성

`scheduler` 모듈에 blockchain-api와 통신하는 REST 클라이언트를 추가한다.

```java
package com.cryptoments.scheduler.client;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

/**
 * blockchain-api REST 클라이언트.
 *
 * <p>TX receipt 조회, 잔액 조회 등 블록체인 정보를 조회한다.
 */
@Component
public class BlockchainApiClient {

    private static final Logger log = LoggerFactory.getLogger(BlockchainApiClient.class);

    private final String baseUrl;
    private final HttpClient httpClient;
    private final ObjectMapper objectMapper;

    public BlockchainApiClient(
            @Value("${blockchain-api.base-url:http://localhost:3001}") String baseUrl,
            @Value("${blockchain-api.connect-timeout:5}") int connectTimeout,
            @Value("${blockchain-api.read-timeout:10}") int readTimeout) {
        this.baseUrl = baseUrl;
        this.httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(connectTimeout))
                .build();
        this.objectMapper = new ObjectMapper();
    }

    /**
     * TX receipt 조회.
     *
     * @param networkId 네트워크 ID (1=BSC, 195=TRON, ...)
     * @param txHash TX 해시
     * @return TX receipt (null이면 미확정 또는 존재하지 않음)
     */
    public TxReceipt getTxReceipt(Long networkId, String txHash) {
        String url = baseUrl + "/api/v1/tx/" + networkId + "/" + txHash + "/receipt";

        try {
            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(url))
                    .timeout(Duration.ofSeconds(15))
                    .header("Accept", "application/json")
                    .GET()
                    .build();

            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());

            if (response.statusCode() == 200) {
                return objectMapper.readValue(response.body(), TxReceipt.class);
            } else if (response.statusCode() == 404) {
                log.debug("TX receipt not found: networkId={}, txHash={}", networkId, txHash);
                return null;
            } else {
                log.warn("blockchain-api 응답 오류: status={}, body={}",
                        response.statusCode(), response.body());
                return null;
            }

        } catch (Exception e) {
            log.error("blockchain-api 호출 실패: networkId={}, txHash={}, error={}",
                    networkId, txHash, e.getMessage(), e);
            return null;
        }
    }

    /**
     * TX receipt 응답 모델.
     */
    public static class TxReceipt {
        /** TX 해시 */
        @JsonProperty("transactionHash")
        private String transactionHash;

        /** 블록 번호 */
        @JsonProperty("blockNumber")
        private Long blockNumber;

        /** 사용된 가스 */
        @JsonProperty("gasUsed")
        private String gasUsed;

        /** 가스 가격 (Wei) */
        @JsonProperty("gasPrice")
        private String gasPrice;

        /** 실행 상태: 1=성공, 0=실패, null=미확정 */
        @JsonProperty("status")
        private Integer status;

        /** 발신자 */
        @JsonProperty("from")
        private String from;

        /** 수신자 */
        @JsonProperty("to")
        private String to;

        /** 송금액 (Wei) */
        @JsonProperty("value")
        private String value;

        // Getters
        public String getTransactionHash() { return transactionHash; }
        public Long getBlockNumber() { return blockNumber; }
        public String getGasUsed() { return gasUsed; }
        public String getGasPrice() { return gasPrice; }
        public Integer getStatus() { return status; }
        public String getFrom() { return from; }
        public String getTo() { return to; }
        public String getValue() { return value; }

        /**
         * TX 실행 성공 여부.
         * status=1이면 성공, 0이면 revert, null이면 미확정.
         */
        public boolean isSuccess() {
            return status != null && status == 1;
        }

        /**
         * TX 실패(revert) 여부.
         */
        public boolean isReverted() {
            return status != null && status == 0;
        }

        /**
         * TX 미확정 여부.
         */
        public boolean isPending() {
            return status == null;
        }
    }
}
```

#### Step 2: StaleTxMonitorJob 구현

```java
package com.cryptoments.scheduler.job;

import com.cryptoments.common.entity.CollectionBatch;
import com.cryptoments.common.entity.Withdrawal;
import com.cryptoments.common.enums.CollectionBatchStatus;
import com.cryptoments.common.enums.WithdrawalStatus;
import com.cryptoments.common.repository.CollectionBatchRepository;
import com.cryptoments.common.repository.WithdrawalRepository;
import com.cryptoments.core.notification.NotificationService;
import com.cryptoments.core.notification.TelegramMessageFormatter;
import com.cryptoments.scheduler.client.BlockchainApiClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;

/**
 * 미확정 TX 안전망 모니터링 배치.
 *
 * <p>BROADCASTING 상태로 오래 머물러 있는 출금/집금 TX를 감시하고,
 * 온체인 상태를 확인하여 미확정 TX를 복구하거나 실패 처리한다.
 * 실행 주기: 10분 (fixedRate = 600000ms)
 *
 * <p>설정값 (system_settings):
 * - stale_tx.check_interval_minutes: 점검 주기 (분, 기본 10분)
 * - stale_tx.threshold_minutes: BROADCASTING 후 미확정 임계 시간 (분, 기본 30분)
 */
@Component
public class StaleTxMonitorJob {

    private static final Logger log = LoggerFactory.getLogger(StaleTxMonitorJob.class);

    private final CollectionBatchRepository collectionBatchRepository;
    private final WithdrawalRepository withdrawalRepository;
    private final BlockchainApiClient blockchainApiClient;
    private final NotificationService notificationService;

    /** 미확정 임계 시간 (분) — application.yml에서 설정 */
    @Value("${stale-tx.threshold-minutes:30}")
    private int staleThresholdMinutes;

    public StaleTxMonitorJob(CollectionBatchRepository collectionBatchRepository,
                             WithdrawalRepository withdrawalRepository,
                             BlockchainApiClient blockchainApiClient,
                             NotificationService notificationService) {
        this.collectionBatchRepository = collectionBatchRepository;
        this.withdrawalRepository = withdrawalRepository;
        this.blockchainApiClient = blockchainApiClient;
        this.notificationService = notificationService;
    }

    /**
     * 미확정 TX 모니터링 실행.
     */
    @Scheduled(fixedRate = 600000)  // 10분마다
    public void execute() {
        try {
            long startTime = System.currentTimeMillis();
            log.info("=== 미확정 TX 안전망 모니터링 시작 ===");

            int collectionStaleCount = monitorCollectionBatches();
            int withdrawalStaleCount = monitorWithdrawals();

            long elapsed = System.currentTimeMillis() - startTime;
            log.info("=== 미확정 TX 안전망 모니터링 완료: 집금 {}건, 출금 {}건, 소요 {}ms ===",
                    collectionStaleCount, withdrawalStaleCount, elapsed);

        } catch (Exception e) {
            log.error("미확정 TX 안전망 모니터링 중 오류 발생", e);
        }
    }

    /**
     * 미확정 집금 배치 모니터링.
     */
    private int monitorCollectionBatches() {
        LocalDateTime threshold = LocalDateTime.now().minusMinutes(staleThresholdMinutes);
        List<CollectionBatch> staleBatches = collectionBatchRepository
                .findByStatusAndUpdatedBefore(CollectionBatchStatus.BROADCASTING, threshold);

        int staleCount = 0;

        for (CollectionBatch batch : staleBatches) {
            try {
                monitorSingleBatch(batch);
                staleCount++;
            } catch (Exception e) {
                log.error("집금 배치 모니터링 실패: batchId={}, txHash={}, error={}",
                        batch.getId(), batch.getTxHash(), e.getMessage(), e);
            }
        }

        return staleCount;
    }

    /**
     * 단일 집금 배치 모니터링.
     */
    private void monitorSingleBatch(CollectionBatch batch) {
        if (batch.getTxHash() == null) {
            log.debug("집금 배치 TX 해시 없음: batchId={}", batch.getId());
            return;
        }

        BlockchainApiClient.TxReceipt receipt = blockchainApiClient
                .getTxReceipt(batch.getNetworkId(), batch.getTxHash());

        if (receipt == null) {
            // TX not found — STALE 마킹
            markCollectionBatchAsStale(batch);
            sendStaleAlert("COLLECTION", batch.getId(), batch.getTxHash());

        } else if (receipt.isSuccess()) {
            // TX confirmed — 상태 업데이트
            confirmCollectionBatch(batch, receipt);
            sendWebhookRecoveryAlert("COLLECTION", batch.getId(), batch.getTxHash());

        } else if (receipt.isReverted()) {
            // TX reverted — FAILED 처리
            failCollectionBatch(batch);
            sendRevertAlert("COLLECTION", batch.getId(), batch.getTxHash());
        }
        // isPending()인 경우: 아직 대기 중이므로 다음 주기에 재확인
    }

    /**
     * 미확정 출금 모니터링.
     */
    private int monitorWithdrawals() {
        LocalDateTime threshold = LocalDateTime.now().minusMinutes(staleThresholdMinutes);
        List<Withdrawal> staleWithdrawals = withdrawalRepository
                .findByStatusAndUpdatedBefore(WithdrawalStatus.BROADCASTING, threshold);

        int staleCount = 0;

        for (Withdrawal withdrawal : staleWithdrawals) {
            try {
                monitorSingleWithdrawal(withdrawal);
                staleCount++;
            } catch (Exception e) {
                log.error("출금 모니터링 실패: withdrawalId={}, txHash={}, error={}",
                        withdrawal.getId(), withdrawal.getTxHash(), e.getMessage(), e);
            }
        }

        return staleCount;
    }

    /**
     * 단일 출금 모니터링.
     */
    private void monitorSingleWithdrawal(Withdrawal withdrawal) {
        if (withdrawal.getTxHash() == null) {
            log.debug("출금 TX 해시 없음: withdrawalId={}", withdrawal.getId());
            return;
        }

        BlockchainApiClient.TxReceipt receipt = blockchainApiClient
                .getTxReceipt(withdrawal.getNetworkId(), withdrawal.getTxHash());

        if (receipt == null) {
            // TX not found — STALE 마킹
            markWithdrawalAsStale(withdrawal);
            sendStaleAlert("WITHDRAWAL", withdrawal.getId(), withdrawal.getTxHash());

        } else if (receipt.isSuccess()) {
            // TX confirmed — 상태 업데이트
            confirmWithdrawal(withdrawal, receipt);
            sendWebhookRecoveryAlert("WITHDRAWAL", withdrawal.getId(), withdrawal.getTxHash());

        } else if (receipt.isReverted()) {
            // TX reverted — FAILED 처리
            failWithdrawal(withdrawal);
            sendRevertAlert("WITHDRAWAL", withdrawal.getId(), withdrawal.getTxHash());
        }
    }

    /**
     * 집금 배치 CONFIRMED 처리.
     *
     * <p>이 메서드는 Webhook 누락 복구 로직과 동일하다.
     * 실제 구현은 open-api의 WebhookProcessingService.processCollectionConfirm()과
     * 동일한 로직을 따른다.
     */
    private void confirmCollectionBatch(CollectionBatch batch,
                                       BlockchainApiClient.TxReceipt receipt) {
        log.info("집금 배치 확정: batchId={}, txHash={}", batch.getId(), batch.getTxHash());

        // 상태 업데이트: BROADCASTING → CONFIRMED
        CollectionBatch updated = batch.toBuilder()
                .status(CollectionBatchStatus.CONFIRMED)
                .build();
        collectionBatchRepository.modify(updated);

        // TODO: gasCostService.recordCollectionGas(batch, receipt);
        // TODO: collectionQueueRepository.markCollected(batch.getWalletAddressId(),
        //                                               batch.getCurrencyId(),
        //                                               batch.getId());
        // TODO: depositService.onCollectionConfirmed(collect_queue_list);
    }

    /**
     * 집금 배치 FAILED 처리.
     */
    private void failCollectionBatch(CollectionBatch batch) {
        log.info("집금 배치 실패: batchId={}, txHash={}", batch.getId(), batch.getTxHash());

        CollectionBatch updated = batch.toBuilder()
                .status(CollectionBatchStatus.FAILED)
                .errorMessage("TX reverted onchain")
                .build();
        collectionBatchRepository.modify(updated);
    }

    /**
     * 집금 배치 STALE 마킹.
     */
    private void markCollectionBatchAsStale(CollectionBatch batch) {
        log.warn("집금 배치 STALE 마킹: batchId={}, txHash={}", batch.getId(), batch.getTxHash());

        CollectionBatch updated = batch.toBuilder()
                .status(CollectionBatchStatus.STALE)
                .errorMessage("TX not found — stuck or dropped from mempool")
                .build();
        collectionBatchRepository.modify(updated);
    }

    /**
     * 출금 CONFIRMED 처리.
     */
    private void confirmWithdrawal(Withdrawal withdrawal,
                                   BlockchainApiClient.TxReceipt receipt) {
        log.info("출금 확정: withdrawalId={}, txHash={}", withdrawal.getId(), withdrawal.getTxHash());

        // ★ 실제 구현은 WithdrawalService.onTxConfirmed()과 동일해야 한다
        // Withdrawal updated = withdrawal.toBuilder()
        //         .status(WithdrawalStatus.CONFIRMED)
        //         .confirmedAt(LocalDateTime.now())
        //         .build();
        // withdrawalRepository.modify(updated);
        //
        // gasCostService.recordWithdrawalGas(withdrawal, receipt);
        // settlementService.debit(...);
        // notificationService.send(...);
    }

    /**
     * 출금 FAILED 처리.
     */
    private void failWithdrawal(Withdrawal withdrawal) {
        log.warn("출금 실패: withdrawalId={}, txHash={}", withdrawal.getId(), withdrawal.getTxHash());

        Withdrawal updated = withdrawal.toBuilder()
                .status(WithdrawalStatus.FAILED)
                .build();
        withdrawalRepository.modify(updated);
    }

    /**
     * 출금 STALE 마킹.
     */
    private void markWithdrawalAsStale(Withdrawal withdrawal) {
        log.warn("출금 STALE 마킹: withdrawalId={}, txHash={}", withdrawal.getId(), withdrawal.getTxHash());

        Withdrawal updated = withdrawal.toBuilder()
                .status(WithdrawalStatus.STALE)
                .build();
        withdrawalRepository.modify(updated);
    }

    // ── 알림 발송 ──

    private void sendWebhookRecoveryAlert(String txType, Long id, String txHash) {
        String message = String.format(
                "⚠️ <b>Webhook 누락 복구</b>\n\n%s ID: %d\nTX: %s",
                txType, id, txHash);
        log.info(message);
        // TODO: telegramBotClient.sendAlert(message);
    }

    private void sendRevertAlert(String txType, Long id, String txHash) {
        String message = String.format(
                "❌ <b>TX Revert 감지</b>\n\n%s ID: %d\nTX: %s",
                txType, id, txHash);
        log.warn(message);
        // TODO: telegramBotClient.sendAlert(message);
    }

    private void sendStaleAlert(String txType, Long id, String txHash) {
        String message = String.format(
                "⚠️ <b>미확정 TX (STALE)</b>\n\n%s ID: %d\nTX: %s\n\n관리자 판단 필요",
                txType, id, txHash);
        log.warn(message);
        // TODO: telegramBotClient.sendAlert(message);
    }
}
```

### 3-4. Repository 메서드 (공통 모듈)

StaleTxMonitorJob이 사용할 새로운 Repository 메서드들:

```java
// CollectionBatchRepository
List<CollectionBatch> findByStatusAndUpdatedBefore(CollectionBatchStatus status,
                                                   LocalDateTime updatedBefore);

// WithdrawalRepository
List<Withdrawal> findByStatusAndUpdatedBefore(WithdrawalStatus status,
                                              LocalDateTime updatedBefore);
```

### 3-5. application.yml 설정

```yaml
# Stale TX Monitor
stale-tx:
  threshold-minutes: 30    # BROADCASTING 후 30분 이상 미확정이면 감시 대상

# blockchain-api
blockchain-api:
  base-url: http://localhost:3001
  connect-timeout: 5
  read-timeout: 10
```

---

## Job 4: BalanceLowCheckJob (30분 주기)

### 목적

각 파트너의 **MASTER 지갑 잔액**과 **GAS 지갑 네이티브 잔액**을 정기적으로 확인하고,
임계값 이하이면 **Telegram 알림**을 발송한다.

### 설계

| 항목 | 값 |
|------|-----|
| **클래스명** | `BalanceLowCheckJob` |
| **패키지** | `com.cryptoments.scheduler.job` |
| **실행 주기** | `fixedRate = 1800000` (30분) |
| **감시 대상** | MASTER 지갑 (입금/출금용), GAS 지갑 (가스비용) |
| **임계값** | system_settings 또는 파트너 설정 |
| **알림** | Telegram 채팅 (HTML 포맷) |

### 구현

```java
package com.cryptoments.scheduler.job;

import com.cryptoments.common.entity.Currency;
import com.cryptoments.common.entity.Partner;
import com.cryptoments.common.entity.WalletAddress;
import com.cryptoments.common.entity.WalletBalance;
import com.cryptoments.common.enums.WalletType;
import com.cryptoments.common.repository.CurrencyRepository;
import com.cryptoments.common.repository.PartnerRepository;
import com.cryptoments.common.repository.WalletAddressRepository;
import com.cryptoments.common.repository.WalletBalanceRepository;
import com.cryptoments.core.notification.NotificationService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.math.BigDecimal;
import java.util.List;

/**
 * 잔액 부족 감시 배치.
 *
 * <p>각 파트너의 MASTER/GAS 지갑 잔액을 정기적으로 확인하고,
 * 임계값 이하이면 Telegram 알림을 발송한다.
 * 실행 주기: 30분 (fixedRate = 1800000ms)
 */
@Component
public class BalanceLowCheckJob {

    private static final Logger log = LoggerFactory.getLogger(BalanceLowCheckJob.class);

    private final PartnerRepository partnerRepository;
    private final WalletAddressRepository walletAddressRepository;
    private final WalletBalanceRepository walletBalanceRepository;
    private final CurrencyRepository currencyRepository;
    private final NotificationService notificationService;

    /** MASTER 지갑 임계값 (USD) — system_settings에서 설정 가능 */
    @Value("${balance.low-threshold-usd:100}")
    private BigDecimal masterBalanceLowThreshold;

    /** GAS 지갑 임계값 (USD) — system_settings에서 설정 가능 */
    @Value("${balance.gas-low-threshold-usd:20}")
    private BigDecimal gasBalanceLowThreshold;

    public BalanceLowCheckJob(PartnerRepository partnerRepository,
                              WalletAddressRepository walletAddressRepository,
                              WalletBalanceRepository walletBalanceRepository,
                              CurrencyRepository currencyRepository,
                              NotificationService notificationService) {
        this.partnerRepository = partnerRepository;
        this.walletAddressRepository = walletAddressRepository;
        this.walletBalanceRepository = walletBalanceRepository;
        this.currencyRepository = currencyRepository;
        this.notificationService = notificationService;
    }

    /**
     * 잔액 부족 감시 실행.
     */
    @Scheduled(fixedRate = 1800000)  // 30분마다
    public void execute() {
        try {
            long startTime = System.currentTimeMillis();
            log.info("=== 잔액 부족 감시 시작 ===");

            int alertCount = 0;

            // 1. 모든 파트너 순회
            List<Partner> partners = partnerRepository.findAll();
            for (Partner partner : partners) {
                try {
                    // 2. MASTER 지갑 잔액 확인
                    alertCount += checkMasterWallets(partner);

                    // 3. GAS 지갑 잔액 확인
                    alertCount += checkGasWallets(partner);

                } catch (Exception e) {
                    log.error("파트너 잔액 확인 실패: partnerId={}, error={}",
                            partner.getId(), e.getMessage(), e);
                }
            }

            long elapsed = System.currentTimeMillis() - startTime;
            log.info("=== 잔액 부족 감시 완료: 알림 {}건, 소요 {}ms ===", alertCount, elapsed);

        } catch (Exception e) {
            log.error("잔액 부족 감시 중 오류 발생", e);
        }
    }

    /**
     * MASTER 지갑 잔액 확인.
     */
    private int checkMasterWallets(Partner partner) {
        List<WalletAddress> masterWallets = walletAddressRepository
                .findByPartnerIdAndWalletType(partner.getId(), WalletType.MASTER);

        int alertCount = 0;

        for (WalletAddress wallet : masterWallets) {
            List<WalletBalance> balances = walletBalanceRepository
                    .findByWalletAddressId(wallet.getId());

            for (WalletBalance balance : balances) {
                if (balance.getBalance().compareTo(BigDecimal.ZERO) <= 0) {
                    continue;  // 잔액 0 이하는 표시하지 않음
                }

                // 임계값 확인
                BigDecimal balanceUsd = convertToUsd(balance);

                if (balanceUsd.compareTo(masterBalanceLowThreshold) < 0) {
                    sendLowBalanceAlert(partner, wallet, balance, "MASTER", balanceUsd);
                    alertCount++;
                }
            }
        }

        return alertCount;
    }

    /**
     * GAS 지갑 잔액 확인.
     */
    private int checkGasWallets(Partner partner) {
        List<WalletAddress> gasWallets = walletAddressRepository
                .findByPartnerIdAndWalletType(partner.getId(), WalletType.GAS);

        int alertCount = 0;

        for (WalletAddress wallet : gasWallets) {
            List<WalletBalance> balances = walletBalanceRepository
                    .findByWalletAddressId(wallet.getId());

            for (WalletBalance balance : balances) {
                if (balance.getBalance().compareTo(BigDecimal.ZERO) <= 0) {
                    continue;
                }

                BigDecimal balanceUsd = convertToUsd(balance);

                if (balanceUsd.compareTo(gasBalanceLowThreshold) < 0) {
                    sendLowBalanceAlert(partner, wallet, balance, "GAS", balanceUsd);
                    alertCount++;
                }
            }
        }

        return alertCount;
    }

    /**
     * USD 환산.
     */
    private BigDecimal convertToUsd(WalletBalance balance) {
        Currency currency = currencyRepository.findOne(balance.getCurrencyId());
        if (currency == null || currency.getPriceUsd() == null) {
            return BigDecimal.ZERO;
        }
        return balance.getBalance().multiply(currency.getPriceUsd());
    }

    /**
     * 잔액 부족 알림 발송.
     */
    private void sendLowBalanceAlert(Partner partner, WalletAddress wallet,
                                    WalletBalance balance, String walletType,
                                    BigDecimal balanceUsd) {
        Currency currency = currencyRepository.findOne(balance.getCurrencyId());
        String currencySymbol = currency != null ? currency.getSymbol() : "?";

        String message = String.format(
                "⚠️ <b>잔액 부족 알림</b>\n\n" +
                "파트너: %s\n" +
                "지갑 유형: %s\n" +
                "주소: %s\n" +
                "통화: %s\n" +
                "잔액: %s %s (≈ %.2f USD)\n" +
                "임계값: %.2f USD",
                partner.getPartnerName(),
                walletType,
                wallet.getAddress(),
                currencySymbol,
                balance.getBalance(),
                currencySymbol,
                balanceUsd,
                walletType.equals("MASTER") ? masterBalanceLowThreshold : gasBalanceLowThreshold
        );

        log.warn("잔액 부족 감지: partnerId={}, walletId={}, balance={} (USD)",
                partner.getId(), wallet.getId(), balanceUsd);

        // TODO: 실제 Telegram 발송 로직
        // notificationService.sendSystemAlert("BALANCE_LOW", message);
    }
}
```

### 4-2. application.yml 설정

```yaml
# Balance Low Check
balance:
  low-threshold-usd: 100    # MASTER 지갑 임계값 (USD)
  gas-low-threshold-usd: 20  # GAS 지갑 임계값 (USD)
```

---

## SchedulerController 업데이트

관리자가 스케줄러 작업을 수동으로 트리거할 수 있도록 컨트롤러를 확장한다.

```java
package com.cryptoments.scheduler.controller;

import com.cryptoments.scheduler.job.BalanceLowCheckJob;
import com.cryptoments.scheduler.job.ExpireStaleWithdrawalsJob;
import com.cryptoments.scheduler.job.PriceSyncJob;
import com.cryptoments.scheduler.job.RetryFailedWithdrawalsJob;
import com.cryptoments.scheduler.job.StaleTxMonitorJob;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.Map;

/**
 * 스케줄러 관리 API.
 *
 * <p>배치 작업의 수동 트리거 및 상태 확인.
 */
@RestController
@RequestMapping("/scheduler")
public class SchedulerController {

    private final PriceSyncJob priceSyncJob;
    private final RetryFailedWithdrawalsJob retryFailedWithdrawalsJob;
    private final ExpireStaleWithdrawalsJob expireStaleWithdrawalsJob;
    private final StaleTxMonitorJob staleTxMonitorJob;
    private final BalanceLowCheckJob balanceLowCheckJob;

    public SchedulerController(PriceSyncJob priceSyncJob,
                               RetryFailedWithdrawalsJob retryFailedWithdrawalsJob,
                               ExpireStaleWithdrawalsJob expireStaleWithdrawalsJob,
                               StaleTxMonitorJob staleTxMonitorJob,
                               BalanceLowCheckJob balanceLowCheckJob) {
        this.priceSyncJob = priceSyncJob;
        this.retryFailedWithdrawalsJob = retryFailedWithdrawalsJob;
        this.expireStaleWithdrawalsJob = expireStaleWithdrawalsJob;
        this.staleTxMonitorJob = staleTxMonitorJob;
        this.balanceLowCheckJob = balanceLowCheckJob;
    }

    /**
     * 스케줄러 상태 확인.
     */
    @GetMapping("/status")
    public Map<String, Object> status() {
        return Map.of(
                "service", "scheduler",
                "status", "running",
                "timestamp", LocalDateTime.now().toString()
        );
    }

    /**
     * 빗썸 시세 동기화 수동 트리거.
     */
    @PostMapping("/price-sync/trigger")
    public Map<String, Object> triggerPriceSync() {
        long start = System.currentTimeMillis();
        priceSyncJob.execute();
        long elapsed = System.currentTimeMillis() - start;

        return Map.of(
                "result", "completed",
                "job", "price-sync",
                "elapsedMs", elapsed,
                "timestamp", LocalDateTime.now().toString()
        );
    }

    /**
     * 실패 출금 자동 재시도 수동 트리거.
     */
    @PostMapping("/retry-failed-withdrawals/trigger")
    public Map<String, Object> triggerRetryFailedWithdrawals() {
        long start = System.currentTimeMillis();
        retryFailedWithdrawalsJob.execute();
        long elapsed = System.currentTimeMillis() - start;

        return Map.of(
                "result", "completed",
                "job", "retry-failed-withdrawals",
                "elapsedMs", elapsed,
                "timestamp", LocalDateTime.now().toString()
        );
    }

    /**
     * 장기 대기 출금 자동 만료 수동 트리거.
     */
    @PostMapping("/expire-stale-withdrawals/trigger")
    public Map<String, Object> triggerExpireStaleWithdrawals() {
        long start = System.currentTimeMillis();
        expireStaleWithdrawalsJob.execute();
        long elapsed = System.currentTimeMillis() - start;

        return Map.of(
                "result", "completed",
                "job", "expire-stale-withdrawals",
                "elapsedMs", elapsed,
                "timestamp", LocalDateTime.now().toString()
        );
    }

    /**
     * 미확정 TX 안전망 모니터링 수동 트리거.
     */
    @PostMapping("/stale-tx-monitor/trigger")
    public Map<String, Object> triggerStaleTxMonitor() {
        long start = System.currentTimeMillis();
        staleTxMonitorJob.execute();
        long elapsed = System.currentTimeMillis() - start;

        return Map.of(
                "result", "completed",
                "job", "stale-tx-monitor",
                "elapsedMs", elapsed,
                "timestamp", LocalDateTime.now().toString()
        );
    }

    /**
     * 잔액 부족 감시 수동 트리거.
     */
    @PostMapping("/balance-low-check/trigger")
    public Map<String, Object> triggerBalanceLowCheck() {
        long start = System.currentTimeMillis();
        balanceLowCheckJob.execute();
        long elapsed = System.currentTimeMillis() - start;

        return Map.of(
                "result", "completed",
                "job", "balance-low-check",
                "elapsedMs", elapsed,
                "timestamp", LocalDateTime.now().toString()
        );
    }
}
```

---

## 의존성 설정

### build.gradle 업데이트

```gradle
dependencies {
    implementation project(':core')
    implementation project(':common')

    // Axim REST Framework
    implementation 'com.github.Axim-one.rest-framework:core:1.3.1'
    implementation 'com.github.Axim-one.rest-framework:rest-api:1.3.1'
    implementation 'com.github.Axim-one.rest-framework:mybatis:1.3.1'

    // Spring Boot
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'

    // MyBatis
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'

    // MySQL
    runtimeOnly 'com.mysql:mysql-connector-j'

    // Jackson (JSON 직렬화)
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'

    // Test
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
```

### application.yml 통합

```yaml
spring:
  application:
    name: scheduler

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/cryptoments_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC&characterEncoding=UTF-8&useUnicode=true}
    username: ${SPRING_DATASOURCE_USERNAME:root}
    password: ${SPRING_DATASOURCE_PASSWORD:1qw2!QW@}
    hikari:
      minimum-idle: 3
      maximum-pool-size: 10
      idle-timeout: 300000
      max-lifetime: 1200000

  jackson:
    default-property-inclusion: non_null
    serialization:
      write-dates-as-timestamps: false
      write-bigdecimal-as-plain: true
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: UTC

server:
  port: ${SERVER_PORT:8085}

# MyBatis
mybatis:
  config-location: classpath:mybatis-config.xml

# Axim Framework
axim:
  rest:
    debug: false
  web-client:
    services:
      blockchain-api: http://localhost:3001
      relayer-api: http://localhost:3002

# Bithumb API
bithumb:
  api:
    base-url: https://api.bithumb.com
    connect-timeout: 5
    read-timeout: 10

# Price Sync Schedule
price:
  sync:
    enabled: true
    cron: "0 */1 * * * *"  # 매 1분마다
    currencies: USDT,USDC,BNB,POL,TRX

# blockchain-api
blockchain-api:
  base-url: http://localhost:3001
  connect-timeout: 5
  read-timeout: 10

# Stale TX Monitor
stale-tx:
  threshold-minutes: 30    # BROADCASTING 후 30분 이상 미확정이면 감시 대상

# Balance Low Check
balance:
  low-threshold-usd: 100    # MASTER 지갑 임계값 (USD)
  gas-low-threshold-usd: 20  # GAS 지갑 임계값 (USD)

management:
  endpoints:
    web:
      exposure:
        include: health
      base-path: /actuator

logging:
  level:
    root: ${LOG_LEVEL_ROOT:INFO}
    com.cryptoments.scheduler: ${LOG_LEVEL_APP:DEBUG}
```

---

## 구현 체크리스트

### Phase 1: 코드 작성 (IntelliJ)

- [ ] `RetryFailedWithdrawalsJob.java` 구현
- [ ] `ExpireStaleWithdrawalsJob.java` 구현
- [ ] `BlockchainApiClient.java` 구현 (TX receipt 조회)
- [ ] `StaleTxMonitorJob.java` 구현
- [ ] `BalanceLowCheckJob.java` 구현
- [ ] `SchedulerController.java` 확장 (수동 트리거 엔드포인트)
- [ ] `build.gradle` 의존성 확인
- [ ] `application.yml` 설정 추가

### Phase 2: Repository 메서드 (common 모듈)

- [ ] `CollectionBatchRepository.findByStatusAndUpdatedBefore()`
- [ ] `WithdrawalRepository.findByStatusAndUpdatedBefore()`

### Phase 3: Node.js 통합 (blockchain-api)

- [ ] `GET /api/v1/tx/{networkId}/{txHash}/receipt` 엔드포인트 구현
- [ ] TX receipt 응답 포맷 확정

### Phase 4: 테스트

- [ ] 수동 트리거 테스트 (Postman: POST /scheduler/{job}/trigger)
- [ ] 정기 실행 테스트 (기간 경과 후 자동 실행 확인)
- [ ] 로그 검증 (시작/완료/오류 로그 확인)
- [ ] DB 상태 변경 검증

### Phase 5: 모니터링

- [ ] 스케줄러 헬스 체크 엔드포인트 추가 (actuator)
- [ ] Telegram 알림 통합 (system alerts)
- [ ] 장기 실행 관찰 (메모리 누수, 성능 저하)

---

## 주의사항

### 1. 스케줄 충돌 방지

여러 배치가 동시에 실행되지 않도록 주의한다. 필요시 `@Scheduled` 간격을 조정한다.

```
RetryFailedWithdrawals: 5분 ─┐
ExpireStaleWithdrawals: 1시간 ├─ 독립 실행 (충돌 없음)
StaleTxMonitor: 10분 ────────┤
BalanceLowCheck: 30분 ───────┘
```

### 2. 트랜잭션 관리

각 배치 작업의 개별 항목 처리 중 예외 발생 시, **해당 항목만 롤백**되고 다른 항목은 계속 처리되도록 한다.

```java
for (Withdrawal w : failed) {
    try {
        // 개별 처리
    } catch (Exception e) {
        log.error("항목 처리 실패: id={}", w.getId(), e);
        // 계속 진행
    }
}
```

### 3. DB 커넥션 풀

`HikariCP` 설정:
- `minimum-idle: 3` (최소 커넥션)
- `maximum-pool-size: 10` (최대 커넥션)
- scheduler는 동시 다중 배치를 실행하지 않으므로 풀 크기는 작아도 됨

### 4. 성능 모니터링

각 배치의 실행 시간을 로깅하고 모니터링한다:

```
2026-03-22 09:05:00 === 실패 출금 자동 재시도 완료: 재시도 건수=3, 소요 245ms ===
```

일반적으로 모든 배치가 **1분 이내**에 완료되어야 한다.

---

## 다음 단계

1. **코드 구현** (위 체크리스트 참고)
2. **Node.js blockchain-api TX receipt API 구현**
3. **E2E 테스트**: BROADCASTING 상태 TX → StaleTxMonitor 감시 → 상태 전이 확인
4. **프로덕션 배포** 및 장기 모니터링

---

**Guide #35 완료**
