# 출금 실패 자동 재시도 폐지 + 실패 종결(터미널) 전환 지침서

작성 2026-08-21 · 오너 결정 · 대상 모듈: `core`, `common`, `scheduler`
DDL: `withdrawals.failed_finalized_at` (오케스트레이터 선행 적용, v2.9)

---

## 1. 왜 바꾸는가

현재 온체인 출금 실패는 **종결이 아니다.** `RetryFailedWithdrawalsJob`(5분)이 `FAILED → APPROVED`
로 되살려 최대 5회 재시도하고, 상한을 넘겨야 `EXHAUSTED` 로 종결한다. 결과:

- 파트너는 `WITHDRAWAL_FAILED`(reason=`FAILED`)를 받고도 나중에 같은 건의
  `WITHDRAWAL_COMPLETED` 를 받을 수 있다. **실패 웹훅으로 환불 처리를 구현한 파트너는 이중 처리**가 난다.
- 실패 원인이 해소되지 않은 채 5회를 자동 소모하며 가스/에너지를 태운다(2026-08-03 §32).

**오너 결정: 자동 재시도를 폐지하고, 실패는 즉시 종결한다. 재시도는 관리자 수동 버튼만.**

## 2. 가장 중요한 제약 — 이걸 모르면 자금이 잠긴다

### 2-1. `FAILED` 는 현재 hold(자금 선점)에서 빠지지 않는다

`WithdrawalMapper.sumPendingGeneralWithdrawals` 의 제외 목록에 `FAILED` 가 없다.
즉 **재시도잡을 그냥 지우면 실패 건이 파트너 가용잔액을 영구히 잠근다.**
`FAILED` 를 터미널로 승격하면서 **반드시 제외 목록에 넣어야 한다.**

### 2-2. node-service 가 DB `status` 를 직접 `FAILED` 로 쓴다

`node-service/packages/relayer-api/src/services/WithdrawalPoller.ts` 는
`withdrawalRepo.updateStatus(id, 'FAILED', ...)` 로 **Spring 코드를 타지 않고** 상태를 찍는다
(릴레이어 미확보, 브로드캐스트 재시도 상한 초과 등). 이 경로는 웹훅도, 정산 복원도 하지 않는다.
지금까지는 Spring 재시도잡이 그 행을 주워 뒷정리를 해왔다.

**따라서 잡을 삭제하면 안 된다. 재시도를 걷어내고 "실패 종결 처리(sweep)"로 성격을 바꾼다.**
node 폴러의 재시도는 **그대로 둔다**(온체인 전송 이전의 일시 장애 복구라 성격이 다름 — 오너 결정).

---

## 3. 구현 항목

### C1. DDL (적용 완료 — 코드에서 컬럼 존재 전제)

```sql
ALTER TABLE withdrawals
  ADD COLUMN failed_finalized_at DATETIME(6) NULL
  COMMENT '실패 종결 처리 완료 시각(멱등 스탬프). NULL이면 미종결' AFTER retry_count;
```

`Withdrawal` 엔티티에 `@XColumn("failed_finalized_at") private LocalDateTime failedFinalizedAt;` 추가.
⚠️ Axim `IXRepository` 는 엔티티 필드로 SELECT 컬럼을 만든다 — **DDL 적용 전 배포 금지**
(2026-08-13 §50 재발 방지).

### C2. `WithdrawalMapper.sumPendingGeneralWithdrawals` — hold 제외에 `FAILED` 추가

```java
+ "AND status NOT IN ('CONFIRMED', 'SETTLED', 'CANCELLED', 'REJECTED', "
+ "'COMPLETED', 'P2P_PENDING', 'EXHAUSTED', 'FAILED')")
```
JavaDoc 의 제외 목록 설명도 함께 갱신할 것("FAILED — 실패 종결, 자금 미이동").
**`STALE` 은 절대 넣지 말 것** — 브로드캐스트된 tx 가 확정 대기 중일 수 있어 자금이 실제로
나갔을 가능성이 있다. hold 유지가 맞다.

### C3. `WithdrawalService` — 실패 종결 처리 신설

```java
/**
 * 출금 실패 종결 처리 — hold 반환 · 정산도메인 복원 · 파트너 웹훅 1회.
 * onTxFailed(웹훅 경로)와 sweep 잡(node 가 직접 찍은 FAILED) 양쪽에서 호출된다.
 * failed_finalized_at 스탬프로 멱등을 보장한다 — 두 번째 호출은 즉시 return.
 */
@Transactional
public void finalizeFailure(Long withdrawalId, String reasonDetail) { ... }
```

동작 순서:
1. `findById` → 상태가 `FAILED` 가 아니면 return(방어).
2. `failedFinalizedAt != null` 이면 return (**멱등 — 이중 reverse·이중 웹훅 금지**).
3. `withdrawalType == SETTLEMENT_WITHDRAW` 이면
   `settlementService.reverseSettlementWithdraw(partnerId, currencyId, networkId, amount)`.
   (일반 출금은 hold 가 상태에서 파생되므로 별도 반환 불필요 — C2 로 자동 제외됨)
4. `failedFinalizedAt = now()` 로 `modify`.
5. 파트너 웹훅 + 텔레그램 발송:
   `webhookPayloadBuilder.buildWithdrawalFailed(withdrawal, "FAILED", reasonDetail)` +
   `TelegramMessageFormatter.withdrawalFailed(...)` → `notificationService.send(...)`.
   ⚠️ 발송은 **try/catch 로 격리** — 알림 실패가 종결 트랜잭션을 되돌리면 안 된다.

`onTxFailed(Long, String)`: 기존 상태 전이 + 이력 저장 뒤 `finalizeFailure(id, error)` 호출.
⚠️ 이미 `FAILED` 인 건에 대한 재진입 시 상태 이력만 중복 저장되지 않도록, 진입 시
`status == FAILED && failedFinalizedAt != null` 이면 즉시 return.

⚠️ **웹훅 이중 발송 주의**: `WebhookProcessingService.handleFailedTx` 는 지금
`onTxFailed` 호출 **후 별도로** `sendWithdrawalNotification(failed, true, ...)` 를 부른다.
발송 소유권을 `finalizeFailure` 로 옮기므로 **`handleFailedTx` 의 그 호출을 제거**할 것
(제거하지 않으면 파트너가 같은 실패를 2번 받는다).

### C4. `retryFailedWithdrawals()` 제거

`WithdrawalService.retryFailedWithdrawals()` 삭제. `MAX_RETRY` 상수는 node 폴러와 무관하므로
Java 쪽에서는 미사용이 되면 함께 제거한다. `exhaust()` 는 **남긴다** —
관리자 강제 종결/구건 호환 경로이며 `retry()` 가 `EXHAUSTED` 를 되살릴 수 있어야 한다.

### C5. `scheduler` — 잡 성격 전환

`RetryFailedWithdrawalsJob` → **`WithdrawalFailureFinalizeJob`** (파일명·클래스명 변경, 5분 주기 유지).

```java
// status=FAILED AND failed_finalized_at IS NULL 인 건을 집어 finalizeFailure 호출
int n = withdrawalService.finalizeUnfinalizedFailures();
```

`WithdrawalService.finalizeUnfinalizedFailures()` 신설 — 미종결 FAILED 목록을 조회해
건별로 `finalizeFailure` 호출(**건별 try/catch** — 한 건 실패가 배치를 죽이면 안 된다), 처리 건수 반환.
조회는 `WithdrawalMapper` 에 추가:

```java
@Select("SELECT * FROM withdrawals WHERE status = 'FAILED' AND failed_finalized_at IS NULL "
      + "ORDER BY id LIMIT 200")
List<Withdrawal> findUnfinalizedFailures();
```
(`<script>` 아님 — 부등호 없음. §DDL 주의사항 무관)

이 잡이 **node 가 직접 찍은 FAILED 를 종결시키는 유일한 경로**다. 절대 지우지 말 것.

### C6. 관리자 수동 재시도 `retry()` — 가용액 재검증 추가

C2 이후 `FAILED` 는 hold 에서 빠지므로, 관리자가 `FAILED → APPROVED` 로 되살리는 순간
**그 금액이 다시 hold 로 잡힌다.** 그 사이 다른 출금이 잔액을 가져갔을 수 있으므로
되살리기 전에 가용액을 재검증해야 한다(요청 경로와 동일한 검증 재사용).
부족하면 409 도메인 예외. 되살릴 때 `failedFinalizedAt = null` 로 초기화한다
(다시 실패하면 종결 처리가 한 번 더 돌아야 하므로).

---

## 4. 하지 말 것

- ❌ `RetryFailedWithdrawalsJob` **삭제만** 하고 끝내기 → node 가 찍은 FAILED 가 영구 방치.
- ❌ hold 제외 목록에 `STALE` 추가 → 확정 대기 중인 실제 송금액이 가용액으로 되살아남.
- ❌ `finalizeFailure` 를 멱등 없이 구현 → 정산출금 realized 가 반복 복원되어 **잔액 부풀림**.
- ❌ 웹훅 발송을 `handleFailedTx` 와 `finalizeFailure` 양쪽에 남기기 → 파트너 이중 수신.
- ❌ DDL 적용 전 배포 → `Unknown column` 으로 `withdrawals` 를 읽는 모든 쿼리 마비(§50).

## 5. 완료 기준

1. `./gradlew :common:compileJava :core:compileJava :scheduler:compileJava :open-api:compileJava` 통과.
2. 재시도 관련 잔여 참조 0건: `grep -rn "retryFailedWithdrawals\|MAX_RETRY" --include=*.java` 결과 없음.
3. `sumPendingGeneralWithdrawals` 제외 목록에 `FAILED` 포함, `STALE` 미포함.
4. `handleFailedTx` 에 `sendWithdrawalNotification` 호출이 남아 있지 않음.
5. `finalizeFailure` 가 `failedFinalizedAt` 로 이중 실행을 막고 있음(코드상 확인 가능).

## 6. 배포 후 검증 (운영)

```sql
-- 미종결 FAILED 가 5분 내 0 으로 수렴하는지
SELECT COUNT(*) FROM withdrawals WHERE status='FAILED' AND failed_finalized_at IS NULL;

-- 종결된 실패 건이 hold 에서 빠졌는지(파트너 가용액이 그만큼 늘어야 함)
SELECT partner_id, currency_id, network_id, SUM(amount)
  FROM withdrawals WHERE status='FAILED' AND withdrawal_type IN ('PARTNER_WITHDRAW','USER_PAYOUT')
 GROUP BY 1,2,3;

-- 파트너 웹훅이 1건씩만 나갔는지
SELECT reference_id, COUNT(*) FROM webhook_delivery_logs
 WHERE JSON_UNQUOTE(JSON_EXTRACT(request_payload,'$.eventType'))='WITHDRAWAL_FAILED'
   AND created_at > NOW() - INTERVAL 1 DAY GROUP BY 1 HAVING COUNT(*) > 1;
```

## 7. 파트너 공지 필요

`reason=FAILED` 의 의미가 **"재시도 예정"에서 "최종 실패"로 바뀐다.** 신규 `EXHAUSTED` 는
더 이상 발생하지 않는다(구건 호환으로만 남음). 연동 문서
(`cryptoments-admin/guide-ui/webhook.html` → docs.cryptoments.cc/webhook)의 실패 섹션을
"실패 = 종결, 재시도는 관리자 수동" 으로 갱신할 것.
