# 원장 `balance_after` 경합 수정 지침서 (2026-09-05)

> 서브에이전트 구현 지시서. 오케스트레이터가 리뷰·판정한다.

## 1. 문제

`ledger_entries.balance_after` 가 직전 잔액과 이어지지 않는 행이 운영 40건(2026-09-05 실측,
9월만 23건). 원인은 `SettlementService` 의 원장 쓰기 4개 메서드가 **비원자적 read-modify-write** 이기 때문.

```
credit / debit / recordFee / adjust:
  currentBalance = ledgerMapper.computeBalance(...)   // 일반 SELECT SUM — 잠금 없음
  balanceAfter   = currentBalance ± amount
  ledgerEntryRepository.save(entry)
```

실측 사례(파트너 45): 출금 확정 웹훅 2건(8998, 9000)이 150ms 간격으로 같은 잔액 3994 를 읽고 각자
−800, −756 을 저장 → 9000 의 `balance_after` 가 800 틀림. 입금 확정 2건이 20μs 차이로 겹친 9064/9066 도
동일. `amount` 는 정확하고 `computeBalance` 는 매번 전액 재합산하므로 **자금은 맞다**. 틀린 건
파트너 콘솔 "잔액" 열에 노출되는 `balance_after` 스냅샷뿐.

기존 잠금 `lockPartnerBalanceRow` 는 출금 *접수*(`requestWithdrawal`) 경로에만 있고, 원장 DEBIT 은
온체인 *확정*(`onTxConfirmed`) 시점에 쓰이므로 무관.

## 2. 수정 설계

**파티션(파트너×통화×네트워크) 게이트 잠금 + 잠금 읽기.** 둘 다 필요하다.

- 게이트 잠금: `settlement_balances` 파티션 행 `FOR UPDATE` (이미 있는 `ensurePartnerBalanceRow` +
  `lockPartnerBalanceRow` 재사용). 같은 파티션의 원장 쓰기를 직렬화한다.
- 잠금 읽기: 게이트를 잡아도 **REPEATABLE-READ 에서 일반 SELECT 는 트랜잭션 시작 시점 스냅샷**을
  읽는다. 웹훅 트랜잭션(`WebhookProcessingService.process`)은 원장 쓰기 전에 이미 여러 읽기를 했으므로,
  잠금만 넣으면 상대 커밋을 여전히 못 본다. SUM 을 `FOR UPDATE` 로 실행해 최신 커밋을 읽는다.
- 트랜잭션 경계: 4개 메서드에 `@Transactional` (기본 REQUIRED). 호출자 tx 가 있으면 합류, 없으면 자체 tx 로
  잠금+SUM+INSERT 를 묶는다. 잠금은 커밋까지 유지된다.

트리거 방식은 채택하지 않는다(부호 규칙 이원화, CI 밖 관리). 백필은 별건(배포 후).

## 3. 변경 파일

### 3-1. `common/src/main/java/com/cryptoments/common/mapper/LedgerMapper.java`

`computeBalanceForUpdate` 추가. 기존 `computeBalance` 와 **동일 SQL + `FOR UPDATE`**. 기존 메서드는 그대로 둔다
(읽기 전용 호출부가 많다).

```java
/**
 * 원장 쓰기 전용 잔액 계산 — {@link #computeBalance} 와 같은 산식에 <b>잠금 읽기</b>(FOR UPDATE).
 *
 * <p>REPEATABLE-READ 에서 일반 SELECT 는 트랜잭션 첫 읽기 시점 스냅샷을 보므로, 파티션 게이트 잠금을
 * 잡은 뒤에도 다른 트랜잭션이 방금 커밋한 원장 행을 놓칠 수 있다. 잠금 읽기는 최신 커밋을 읽는다.
 * 반드시 {@code SettlementMapper.lockPartnerBalanceRow} 이후, 같은 트랜잭션 안에서 호출한다
 * (2026-09-05 balance_after 경합 수정).
 */
@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);
```

`<script>` 안에 `<`, `<=`, `<>` 절대 쓰지 말 것(기동 시 SAXParseException).

### 3-2. `core/src/main/java/com/cryptoments/core/settlement/SettlementService.java`

private 헬퍼 하나 추가하고, 4개 메서드가 `computeBalance` 대신 이 헬퍼를 쓴다.

```java
/**
 * 원장 쓰기 직전 잔액 — 파티션 게이트 잠금 + 잠금 읽기.
 *
 * <p>4개 원장 쓰기(credit/debit/recordFee/adjust)의 read-modify-write 를 파티션 단위로 직렬화한다.
 * 호출자 트랜잭션 안에서만 의미가 있으므로 4개 메서드는 모두 {@code @Transactional} 이다
 * (2026-09-05 balance_after 경합 수정 — 운영 40행, 실사례 8998/9000).
 */
private BigDecimal lockAndReadBalance(Long partnerId, Long currencyId, Long networkId) {
    settlementMapper.ensurePartnerBalanceRow(partnerId, currencyId, networkId);
    settlementMapper.lockPartnerBalanceRow(partnerId, currencyId, networkId);
    BigDecimal balance = ledgerMapper.computeBalanceForUpdate(partnerId, currencyId, networkId);
    return balance != null ? balance : BigDecimal.ZERO;
}
```

적용:

| 메서드 | 행(현재) | 변경 |
|---|---|---|
| `credit(8-arg)` | 197 | `@Transactional` 추가, `computeBalance` → `lockAndReadBalance`. **txHash 멱등 검사(190~195)는 잠금 뒤로 옮긴다** — 잠금 전에 검사하면 동일 tx 두 웹훅이 둘 다 통과할 수 있다 |
| `credit(7-arg)` | 172 | `@Transactional` 추가 (self-invocation 이라 프록시를 안 타므로 바깥 메서드에도 필요) |
| `debit` | 240 | `@Transactional` 추가, `computeBalance` → `lockAndReadBalance` |
| `recordFee` | 277 | 동일 |
| `adjust` | 322 | 동일 |

`getAvailableBreakdown`·`getLedgerBalance` 등 읽기 전용은 손대지 않는다. `reverseDepositLedger` 도 그대로.

`lockPartnerBalanceForWithdrawal`(476) 은 유지. JavaDoc 첫 줄만 "출금 접수 직렬화용" → "파트너 잔액 파티션 게이트
(출금 접수 + 원장 쓰기 공용)" 로 갱신.

### 3-3. 동시성 테스트 (신규)

`core/src/test/java/com/cryptoments/core/settlement/LedgerBalanceAfterRaceTest.java`

- **실제 MySQL 에서만 실행**. H2 는 집계 + FOR UPDATE 를 거부하고 MVCC 의미도 다르다.
  시스템 프로퍼티 `ledger.race.jdbc.url` (예: `jdbc:mysql://127.0.0.1:3306/cryptoments_race_test`),
  `ledger.race.jdbc.user`, `ledger.race.jdbc.password` 가 없으면 `Assumptions.assumeTrue` 로 skip.
- 테스트가 자기 스키마를 만든다: `ledger_entries`, `settlement_balances` 두 테이블을
  `v2-docs/CRYPTOMENTS_V2_DDL.sql` 의 컬럼 그대로 `CREATE TABLE IF NOT EXISTS` (테스트 시작 시 TRUNCATE).
  운영/로컬 `cryptoments_db` 를 건드리지 않는다.
- Spring 컨텍스트 없이 구성: `CollectionPendingCollectionSqlTest` 처럼 MyBatis `SqlSessionFactory` 직접 구성
  (`LedgerMapper`, `SettlementMapper` 등록). `SettlementService` 는 생성자 의존성이 많으므로 서비스가 아니라
  **같은 순서의 매퍼 호출을 재현하는 헬퍼**로 테스트한다: 스레드마다 별도 `SqlSession`(autocommit=false) →
  (스냅샷 고정을 위해) `SELECT COUNT(*) FROM ledger_entries` 한 번 먼저 → `ensurePartnerBalanceRow` →
  `lockPartnerBalanceRow` → `computeBalanceForUpdate` → INSERT (JDBC 직접) → `commit`.
- 시나리오: 파티션 (45,3,3) 에 초기 CREDIT 10000 을 넣고, 스레드 16개 × 각 25회, CREDIT/DEBIT 를 무작위 금액으로
  `CyclicBarrier` 로 동시 출발. 완료 후 `id` 순으로 `prev.balance_after ± amount == balance_after` 를 전 행 검증.
  마지막 `balance_after == SUM(부호 적용 amount)` 도 검증.
- 대조군: 같은 시나리오를 `computeBalance`(잠금 없음, 게이트 없음)로 돌리면 위반이 생긴다는 테스트 하나
  (`assertTrue(violations > 0)` 는 flaky 하므로 **assert 하지 말고 로그만** 남긴다 — 문서용).

### 3-4. 손대지 말 것

- 작업 트리에 이미 다른 작업의 미커밋 변경(BankAccount·P2pScrapingService·WithdrawalService 등 10개 파일)이 있다.
  **되돌리거나 stash 하지 말 것.** 위 3개 파일 + 테스트 1개만 수정.
- DDL 변경 없음. `computeBalance` 산식 변경 없음. 커밋·push 하지 말 것(오케스트레이터가 리뷰 후 결정).

## 3-5. 구현 중 확정된 변경 (리뷰 결과, 2026-09-05)

- **게이트 잠금 순서는 `잠금 → 없을 때만 생성 → 재잠금`.** 지침 초안의 `ensure → lock` 은 행이 이미 있을 때
  `INSERT IGNORE` 가 중복 레코드에 S 잠금을 걸어 동시 두 tx 가 `ER_LOCK_DEADLOCK 1213` 을 낸다(로컬 MySQL 9.6
  두 세션 재현). `lockPartitionGate` 공통 헬퍼로 통일하고 기존 `lockPartnerBalanceForWithdrawal` 도 이 순서로 바꿨다
  (출금 접수 경로의 잠재 데드락 동시 해소).
- txHash 멱등 검사(`countCreditByTxHash`)는 잠금 뒤로 옮겼지만 여전히 비잠금 읽기 — 잔여 갭으로 기록.
- `core/build.gradle` 에 `testRuntimeOnly 'com.mysql:mysql-connector-j'` 추가(테스트 전용, BOM 버전).

## 4. 완료 기준

- [x] 전 모듈 `compileJava` 통과 (로컬 Mac, 2026-09-05)
- [x] 4개 원장 쓰기 메서드 전부 `@Transactional` + `lockAndReadBalance` 경유, `computeBalance` 직접 호출은 읽기 전용에만 남음
- [x] txHash 멱등 검사가 잠금 뒤에 있음
- [x] 동시성 테스트 로컬 MySQL 위반 0 (16×25=400건, 데드락 0) / 대조군 401행 중 395행 위반. core 테스트 97개 전부 통과
- [x] `<script>` 안에 `<` 계열 연산자 없음
- [ ] 커밋·push·배포(spring:deploy-production ▶) — 오너 승인 후
- [ ] 배포 확인 후 `migrations/LEDGER_BALANCE_AFTER_BACKFILL_2026_09_05.sql` 실행 → 위반 0 확인
