# 알림(Telegram) 발송 afterCommit 이관 지침서

> 작성 2026-09-08 · 배경: 지식 repo `runbooks/troubleshooting.md` §92
> 대상 모듈: `core` (전 서비스 공용 — admin-api / partner-api / open-api / scheduler 가 함께 배포돼야 한다)

## 1. 왜 고치는가 (사고 요약)

2026-09-08 11:34~11:43 partner 서버에서 `api.telegram.org` 응답이 없었다(건당 60초 response-timeout 후 실패).
`WithdrawalService.requestWithdrawal` 은 `@Transactional` 안에서 파트너 잔액 게이트 행을 `FOR UPDATE` 로 잠근 뒤
(`settlementService.lockPartnerBalanceForWithdrawal`, 328행) 마지막에 `notificationService.send(...)` 로 Telegram HTTP 를
동기 호출한다(391행). 결과:

1. 출금 1건이 **64초 동안 (파트너,통화,네트워크) 게이트 잠금을 점유** — 파트너 콘솔은 응답 타임아웃으로 "OTP 입력 후 진행 안 됨" 표시
2. 파트너가 재시도 → `lockPartnerBalanceRow` 가 `innodb_lock_wait_timeout`(prod 10s) 초과 → `QueryTimeoutException` → 500
3. 그런데 첫 요청은 서버에서 64초 뒤 **정상 커밋**돼 있어, 같은 출금이 5번 접수됨(1988·1990·1991·1992·1993, 파트너가 수동 취소)

규칙 위반 지점이다: **"잠금 안 HTTP 금지"** (2026-09-05, `ledger-balance-after-race`). 같은 메서드가 온체인 잔액 조회는
잠금 앞으로 뺐으면서(307~317행 주석) 알림은 잠금 안에 남겨 뒀다.

부수 효과로 지금 구조는 **롤백 시 유령 알림**도 낸다 — ④ 원자 재검증(364행)에서 롤백돼도 그 전 단계 알림은 이미 나갈 수 있다
(현재는 알림이 ④ 뒤라 요청 경로는 해당 없지만, 다른 호출부는 보장이 없다).

## 2. 수정 원칙

- **한 곳에서 고친다** — 호출부(WithdrawalService 9곳 + DepositService + P2pWithdrawService + …)를 일일이 옮기지 않고
  `NotificationService` 가 트랜잭션 동기화를 감지해 **Telegram HTTP 만 afterCommit 으로 미룬다.**
- **Webhook 은 그대로 둔다** — `sendWebhook` 은 HTTP 가 아니라 `webhook_delivery_logs` INSERT(실 전송은 scheduler 배달 잡)다.
  트랜잭션 안에 있어야 롤백과 함께 사라지므로 **옮기면 안 된다.**
- 트랜잭션 밖 호출(동기화 비활성)은 지금처럼 즉시 발송 — `WithdrawalService.notifyMemberAfterCommit`(206~230행)과 동일 패턴.
- 시그니처 변경 없음. 호출부 코드 무변경.

## 3. 수정 대상 파일

### 3-1. `core/src/main/java/com/cryptoments/core/notification/NotificationService.java`

`sendTelegram(TransactionType, Long, String)` 을 아래처럼 바꾼다. 기존 본문은 `doSendTelegram` 으로 내린다.

```java
/**
 * Telegram 알림 발송 — 트랜잭션 안이면 <b>커밋 후</b> 발송한다 (2026-09-08).
 *
 * <p>☠️ HTTP 를 트랜잭션에 물리면 (a) 호출부가 쥔 FOR UPDATE 잠금이 Telegram 장애 시간(response-timeout 60s)
 * 만큼 늘어나 같은 파티션의 모든 접수가 lock_wait 10s 로 죽고, (b) 롤백돼도 알림은 이미 나간다.
 * 2026-09-08 출금 5중 접수 사고의 원인. 지식 repo troubleshooting §92.
 *
 * <p>config/subscription 조회(DB)는 여기서 즉시 하고, HTTP 만 미룬다 — afterCommit 시점엔 트랜잭션이
 * 없어 새 커넥션을 잡게 되는데, 그 조회를 굳이 커넥션 풀에서 다시 할 이유가 없다.
 */
public void sendTelegram(TransactionType transactionType, Long partnerId, String message) {
    PartnerTelegramConfig config = telegramConfigRepository.findByPartnerId(partnerId);
    if (config == null) { log.info(...생략 로그 그대로...); return; }
    if (!Boolean.TRUE.equals(config.getIsActive())) { ...그대로...; return; }

    if (TransactionSynchronizationManager.isSynchronizationActive()) {
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override public void afterCommit() { doSendTelegram(partnerId, config, message); }
        });
    } else {
        doSendTelegram(partnerId, config, message);
    }
}

private void doSendTelegram(Long partnerId, PartnerTelegramConfig config, String message) {
    try {
        boolean success = telegramBotClient.sendMessage(config.getBotTokenRef(), config.getChatId(), message, "HTML");
        // 성공/실패 로그 기존 그대로
    } catch (RuntimeException e) {
        // ☠️ afterCommit 안의 예외는 호출부 스레드로 전파돼 "커밋은 됐는데 요청은 500" 을 만든다. 반드시 삼킨다.
        log.warn("Telegram 발송 예외(커밋 후, 무시): partnerId={}", partnerId, e);
    }
}
```

- `send(...)` 두 오버로드는 손대지 않는다 — `sendWebhook` → `sendTelegram` 순서 그대로.
- `sendSystemAlert(String, String)`(219행)도 **같은 방식으로** `systemAlertNotifier.send` 를 afterCommit 으로 미룬다.
  이건 SettlementService 등 원장 트랜잭션 안에서 불린다(`SettlementService.java:963`). 스로틀/심각도 판단은
  `SystemAlertNotifier` 안에 있으므로 호출만 미루면 된다. `log.warn` 은 즉시 남긴다(Vigil 경로 유지).
- import: `org.springframework.transaction.support.TransactionSynchronization`, `TransactionSynchronizationManager`.
  풀네임 인라인 말고 import 로.

### 3-2. `core/src/main/java/com/cryptoments/core/notification/P2pMemberNotifier.java` — **변경 없음**

이미 호출부(`WithdrawalService.notifyMemberAfterCommit`, `P2pWithdrawService.notifyMemberAfterCommit`)가 afterCommit 으로
감싸고 있다. 이중으로 감싸면 afterCommit 안에서 동기화가 비활성이라 즉시 발송 — 동작은 같지만 손대지 않는다.

### 3-3. `WithdrawalService.java` — **변경 없음**

376~396행 알림 블록은 그대로 둔다. `NotificationService` 가 미루므로 잠금 밖으로 나간다.
주석 한 줄만 추가: `// Telegram HTTP 는 NotificationService 가 afterCommit 으로 미룬다(2026-09-08) — 잠금 안 HTTP 금지`.

### 3-4. (선택·별건) Telegram 클라이언트 타임아웃

`TelegramBotClient` 는 공용 `XWebClientFactory` 를 쓰므로 `axim.rest.client.*` 타임아웃을 그대로 받는다
(admin/open-api 는 `response-timeout: 60`, partner-api 는 미설정 → 프레임워크 기본값. 실측 실패까지 ~64s).
afterCommit 으로 미루면 잠금은 풀리지만 **요청 스레드는 여전히 60초를 기다린다**(커밋 후 응답 전).
Axim `XWebClientFactory` 가 클라이언트별 타임아웃을 지원하는지 확인해서 지원하면 Telegram 만 5s 로 줄인다.
지원하지 않으면 이 지침 범위 밖 — 별도 지침으로. **이번 지침의 완료 조건에 넣지 않는다.**

## 4. 테스트

`core/src/test/java/com/cryptoments/core/notification/NotificationServiceAfterCommitTest.java` 신규.
기존 `LedgerBalanceAfterRaceTest` 처럼 로컬 MySQL 통합 테스트로 만들지 말고 **순수 단위 테스트**로 한다
(`TransactionSynchronizationManager.initSynchronization()` / `triggerAfterCommit` 류 직접 구동 또는
`TransactionTemplate` + 테스트용 `PlatformTransactionManager` mock). 케이스 3개:

1. 동기화 활성 + 커밋 → `telegramBotClient.sendMessage` **커밋 전 0회, 커밋 후 1회**
2. 동기화 활성 + 롤백 → `sendMessage` **0회** (유령 알림 차단)
3. 동기화 비활성 → 즉시 1회
4. (추가) `sendMessage` 가 RuntimeException 을 던져도 afterCommit 이 예외를 전파하지 않는다

`sendWebhook` 은 케이스 1·2 에서 **동기화와 무관하게 즉시 `webhookDeliveryLogRepository.save` 1회** — 옮기지 않았음을 고정한다.

## 5. 완료 기준

- [ ] `./gradlew :core:compileJava :core:test` 통과 (로컬 Mac, Desktop Commander)
- [ ] `sendWebhook` 은 트랜잭션 안에서 즉시 INSERT — 변경 없음 확인
- [ ] `WithdrawalService.requestWithdrawal` 에서 잠금(328행)~return 사이에 **HTTP 호출이 0개** (온체인 게이트는 잠금 앞, 알림은 커밋 뒤)
- [ ] 호출부 시그니처 변경 0, 컴파일 영향 4개 서비스 모듈 전부 통과 (`./gradlew compileJava`)
- [ ] 커밋 메시지에 `§92` 명시. **push 는 오너 확인 후** (`no-push-without-request`)

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

- partner-api 로그에서 `Telegram 발송` 라인이 `출금 요청 알림` 처리 **응답 이후** 시각에 찍히는지
- 강제 재현: `PartnerTelegramConfig.chat_id` 를 잘못된 값으로 바꾼 테스트 파트너로 출금 요청 → 응답은 즉시(＜1s), Telegram 실패 로그는 그 뒤
- `SELECT COUNT(*) FROM withdrawals WHERE partner_id=? AND created_at > NOW() - INTERVAL 1 HOUR` 로 연타 접수 재발 여부
