# Spring 트랜잭션 분리 가이드 — Lock Wait Timeout 해결

## 문제

파트너 생성 시 MASTER 지갑이 **첫 번째 네트워크(ETH/BSC)만 생성**되고, 나머지(BSC/Polygon, TRON)에서 `Lock wait timeout exceeded` 에러 발생.

### 에러 로그 (Node.js blockchain-api)
```
Derive failed Lock wait timeout exceeded; try restarting transaction
sql: "INSERT INTO wallet_addresses ... VALUES (3, '0x...', 'MASTER', 5, NULL, 3, 3, ...)"
```

### 에러 로그 (Spring admin-api)
```
Wallet creation failed: partnerId=6, network=TRON
one.axim.framework.rest.exception.XRestException: Wallet derivation failed
```

## 근본 원인

`PartnerManagementService.createPartner()`가 **하나의 `@Transactional`** 안에서 모든 작업을 수행:

```
@Transactional createPartner()
  ├─ partnerRepository.save()          ← DB 트랜잭션 시작
  ├─ partnerChainConfigRepository.save() × N
  └─ walletService.createAllWalletsForPartner()
       ├─ [네트워크1] Node.js HTTP → INSERT wallet_addresses ← 성공
       ├─ (Spring이 initWalletBalances 호출 → wallet_balances INSERT → gap-lock 발생)
       ├─ [네트워크2] Node.js HTTP → INSERT wallet_addresses ← Lock wait timeout!
       └─ [네트워크3] Node.js HTTP → INSERT wallet_addresses ← Lock wait timeout!
```

**MySQL REPEATABLE READ + InnoDB gap-lock 문제:**
- Spring `@Transactional`이 열려있는 동안 `wallet_balances` INSERT가 `wallet_addresses` 테이블에 FK gap-lock을 유발
- Node.js는 별도 DB 커넥션이므로 Spring 트랜잭션의 lock을 기다림
- InnoDB lock wait timeout (기본 50초, 실제 10초 설정) 초과 → 실패

## 해결: 트랜잭션 2단계 분리

### 파일: `admin-api/.../service/PartnerManagementService.java`

**변경 전:**
```java
@Transactional
public PartnerCreateResponse createPartner(PartnerCreateRequest request, Long adminId) {
    // ... 파트너 저장 ...
    Long partnerId = partnerRepository.save(partner);
    partner.setId(partnerId);
    auditLogService.log(adminId, AuditAction.CREATE_PARTNER, partnerId);

    // ⚠️ 같은 트랜잭션 안에서 Node.js 호출 → Lock 충돌
    List<MasterWalletInfo> masterWallets = activateChainsAndCreateWallets(partner);

    return PartnerCreateResponse.builder()
            .partner(partner)
            .masterWallets(masterWallets)
            .build();
}
```

**변경 후:**
```java
/**
 * 파트너 생성 — 2단계 트랜잭션.
 * Step 1: 파트너 + 체인 설정 저장 (@Transactional → COMMIT)
 * Step 2: 지갑 생성 (트랜잭션 밖 → Node.js가 자유롭게 INSERT)
 */
public PartnerCreateResponse createPartner(PartnerCreateRequest request, Long adminId) {
    // Step 1: 파트너 + 체인 설정 (트랜잭션 내)
    Partner partner = createPartnerInternal(request, adminId);

    // Step 2: 지갑 생성 (트랜잭션 밖 — Node.js Lock 충돌 방지)
    List<MasterWalletInfo> masterWallets = activateChainsAndCreateWallets(partner);

    return PartnerCreateResponse.builder()
            .partner(partner)
            .masterWallets(masterWallets)
            .build();
}

/**
 * Step 1: 파트너 엔티티 + 체인 설정 저장 (별도 트랜잭션).
 * 이 메서드가 끝나면 COMMIT → Node.js가 partner_id FK를 참조 가능.
 */
@Transactional
protected Partner createPartnerInternal(PartnerCreateRequest request, Long adminId) {
    // 로그인 이메일 중복 체크
    Partner existingEmail = partnerRepository.findByLoginEmail(request.getLoginEmail());
    if (existingEmail != null) {
        throw new PartnerException(PartnerException.DUPLICATE_EMAIL,
                "이미 사용 중인 로그인 이메일입니다: " + request.getLoginEmail());
    }

    // 상위 파트너 조회 및 수수료 검증
    BigDecimal parentMinFeeRate = BigDecimal.ZERO;
    BigDecimal parentMaxFeeCap = new BigDecimal("100");
    if (request.getParentPartnerId() != null) {
        Partner parent = partnerRepository.findOne(request.getParentPartnerId());
        if (parent == null) {
            throw new NotFoundException(ErrorCodes.PARTNER_NOT_FOUND);
        }
        parentMinFeeRate = parent.getMinFeeRate() != null ? parent.getMinFeeRate() : BigDecimal.ZERO;
        parentMaxFeeCap = parent.getMaxFeeCap() != null ? parent.getMaxFeeCap() : new BigDecimal("100");
    }

    BigDecimal calculatedMinFeeRate = parentMinFeeRate.add(request.getParentFeeRate());
    validateFees(request.getParentFeeRate(), request.getMaxFeeCap(), request.getDepositFeeRate(),
            calculatedMinFeeRate, parentMaxFeeCap, request.getPartnerType());

    String partnerCode = generatePartnerCode();
    Partner existingCode = partnerRepository.findByPartnerCode(partnerCode);
    if (existingCode != null) {
        throw new PartnerException(PartnerException.DUPLICATE_CODE,
                "파트너 코드 생성 충돌. 재시도 필요: " + partnerCode);
    }

    String apiKey = "pk_" + UUID.randomUUID().toString().replace("-", "");
    String apiSecret = "sk_" + UUID.randomUUID().toString().replace("-", "");

    Partner partner = Partner.builder()
            .partnerCode(partnerCode)
            .name(request.getName())
            .partnerType(request.getPartnerType())
            .parentPartnerId(request.getParentPartnerId())
            .loginEmail(request.getLoginEmail())
            .passwordHash(passwordEncoder.encode(request.getPassword()))
            .parentFeeRate(request.getParentFeeRate())
            .minFeeRate(calculatedMinFeeRate)
            .maxFeeCap(request.getMaxFeeCap())
            .depositFeeRate(request.getDepositFeeRate())
            .apiKey(apiKey)
            .apiSecretHash(apiSecret)
            .status(PartnerStatus.ACTIVE)
            .activatedAt(LocalDateTime.now())
            .webhookUrl(request.getWebhookUrl())
            .timezone(request.getTimezone() != null ? request.getTimezone() : "Asia/Seoul")
            .locale(request.getLocale() != null ? request.getLocale() : "ko")
            .twoFactorEnabled(false)
            .build();

    Long partnerId = partnerRepository.save(partner);
    partner.setId(partnerId);

    auditLogService.log(adminId, AuditAction.CREATE_PARTNER, partnerId);

    // 체인 설정 저장
    List<BlockchainNetwork> activeNetworks = blockchainNetworkRepository.findByIsActive(true);
    for (BlockchainNetwork network : activeNetworks) {
        List<Currency> currencies = currencyRepository.findByNetworkIdAndIsActive(network.getId(), true);
        for (Currency currency : currencies) {
            PartnerChainConfig config = PartnerChainConfig.builder()
                    .partnerId(partner.getId())
                    .networkId(network.getId())
                    .currencyId(currency.getId())
                    .depositMethod(DepositMethod.HD_WALLET)
                    .build();
            partnerChainConfigRepository.save(config);
        }
    }

    return partner;
    // ← 여기서 COMMIT. 이후 Node.js가 wallet_addresses에 자유롭게 INSERT 가능
}
```

### 핵심 변경 사항

1. `createPartner()`에서 `@Transactional` 제거 — 이 메서드 자체는 트랜잭션이 아님
2. `createPartnerInternal()`을 `@Transactional`로 분리 — 파트너 + 체인 설정만 저장 후 COMMIT
3. `activateChainsAndCreateWallets()`에서 체인 설정 저장 코드를 `createPartnerInternal()`로 이동
4. `activateChainsAndCreateWallets()`는 지갑 생성만 담당 (Node.js 호출)

### activateChainsAndCreateWallets 변경

체인 설정 저장 코드가 `createPartnerInternal()`로 이동했으므로, 지갑 생성만 남김:

```java
/**
 * 파트너 지갑 생성 (트랜잭션 밖에서 호출).
 * Node.js blockchain-api가 wallet_addresses에 직접 INSERT하므로
 * Spring 트랜잭션과 분리해야 Lock 충돌이 발생하지 않는다.
 */
private List<MasterWalletInfo> activateChainsAndCreateWallets(Partner partner) {
    // 지갑 생성은 core WalletService로 위임 (트랜잭션 밖)
    List<WalletCreationResult> walletResults = walletService.createAllWalletsForPartner(partner.getId());

    return walletResults.stream()
            .filter(r -> r.getError() == null)
            .map(r -> MasterWalletInfo.builder()
                    .networkId(r.getNetworkId())
                    .chainSymbol(r.getChainSymbol())
                    .walletAddressId(r.getMasterWalletId())
                    .address(r.getMasterAddress())
                    .build())
            .toList();
}
```

### createPartnerWallets도 동일 적용

```java
/**
 * 파트너 지갑 수동 생성 (기존 파트너에 지갑 추가/복원).
 * 지갑 생성은 Node.js 호출이므로 트랜잭션 불필요.
 */
public List<MasterWalletInfo> createPartnerWallets(Long partnerId, Long adminId) {
    getPartner(partnerId);  // 존재 검증만

    List<WalletCreationResult> walletResults = walletService.createAllWalletsForPartner(partnerId);

    return walletResults.stream()
            .filter(r -> r.getError() == null)
            .map(r -> MasterWalletInfo.builder()
                    .networkId(r.getNetworkId())
                    .chainSymbol(r.getChainSymbol())
                    .walletAddressId(r.getMasterWalletId())
                    .address(r.getMasterAddress())
                    .build())
            .toList();
}
```

## 주의: Spring Proxy와 self-invocation

`createPartnerInternal()`이 같은 클래스의 메서드를 `this.createPartnerInternal()`로 호출하면
Spring AOP 프록시가 적용되지 않아 `@Transactional`이 동작하지 않는다.

### 해결 방법 (택 1)

**방법 A: self-injection** (권장)
```java
@Service
public class PartnerManagementService {

    @Lazy
    @Autowired
    private PartnerManagementService self;

    public PartnerCreateResponse createPartner(PartnerCreateRequest request, Long adminId) {
        Partner partner = self.createPartnerInternal(request, adminId);  // ← self 통해 호출
        List<MasterWalletInfo> masterWallets = activateChainsAndCreateWallets(partner);
        return PartnerCreateResponse.builder()
                .partner(partner)
                .masterWallets(masterWallets)
                .build();
    }

    @Transactional
    public Partner createPartnerInternal(PartnerCreateRequest request, Long adminId) {
        // ... 파트너 + 체인 설정 저장 ...
    }
}
```
※ `protected` → `public`으로 변경 필요 (프록시가 호출해야 하므로)

**방법 B: 별도 서비스로 분리**
```java
// PartnerPersistenceService.java (새 파일)
@Service
@RequiredArgsConstructor
public class PartnerPersistenceService {

    @Transactional
    public Partner createPartner(PartnerCreateRequest request, Long adminId) {
        // 파트너 + 체인 설정 저장 로직
    }
}

// PartnerManagementService.java
public PartnerCreateResponse createPartner(PartnerCreateRequest request, Long adminId) {
    Partner partner = partnerPersistenceService.createPartner(request, adminId);
    List<MasterWalletInfo> wallets = activateChainsAndCreateWallets(partner);
    return PartnerCreateResponse.builder().partner(partner).masterWallets(wallets).build();
}
```

---

## partner-api: createSubPartner()도 동일 수정 필요

### 파일: `partner-api/.../service/PartnerSubMgmtService.java`

**동일한 문제**: `@Transactional` 안에서 `walletService.createAllWalletsForPartner()` 호출.

추가 버그: **168번 줄 `partnerRepository.save(newPartner)`** 후 `setId()` 누락.

### 변경 사항

```java
// ── 변경 전 ──
@Transactional
public SubPartnerCreateResponse createSubPartner(Long distributorId, CreateSubPartnerRequest request) {
    // ... 검증 ...
    partnerRepository.save(newPartner);  // ← setId() 누락!
    List<...> masterWallets = activateChainsAndCreateWallets(newPartner, request.getChainConfigs());
    // ...
}

// ── 변경 후 ──
public SubPartnerCreateResponse createSubPartner(Long distributorId, CreateSubPartnerRequest request) {
    // Step 1: 파트너 + 체인 설정 (트랜잭션 내)
    Partner newPartner = self.createSubPartnerInternal(distributorId, request);

    // Step 2: 지갑 생성 (트랜잭션 밖)
    List<WalletCreationResult> walletResults = walletService.createAllWalletsForPartner(newPartner.getId());

    List<SubPartnerCreateResponse.MasterWalletInfo> masterWallets = walletResults.stream()
            .filter(r -> r.getError() == null)
            .map(r -> SubPartnerCreateResponse.MasterWalletInfo.builder()
                    .networkId(r.getNetworkId())
                    .chainSymbol(r.getChainSymbol())
                    .walletAddressId(r.getMasterWalletId())
                    .address(r.getMasterAddress())
                    .build())
            .toList();

    return SubPartnerCreateResponse.builder()
            .partnerId(newPartner.getId())
            .partnerCode(newPartner.getPartnerCode())
            .name(newPartner.getName())
            .apiKey(newPartner.getApiKey())
            .apiSecret(newPartner.getApiSecretHash())
            .masterWallets(masterWallets)
            .build();
}

@Transactional
public Partner createSubPartnerInternal(Long distributorId, CreateSubPartnerRequest request) {
    // ... 검증, 파트너 저장 (기존 107~168번 줄) ...
    Long partnerId = partnerRepository.save(newPartner);
    newPartner.setId(partnerId);  // ★ setId() 추가

    // 체인 설정 저장 (기존 activateChainsAndCreateWallets의 앞부분)
    // ... partnerChainConfigRepository.save() ...

    return newPartner;
    // ← COMMIT. Node.js가 자유롭게 INSERT 가능
}
```

### self-injection 추가

```java
@Service
public class PartnerSubMgmtService {

    @Lazy
    @Autowired
    private PartnerSubMgmtService self;

    // ...
}
```

---

## 적용 후 확인 사항

1. 파트너 생성 시 3개 네트워크 MASTER + HOT 전부 생성되는지 확인
2. `wallet_approvals` 테이블에 approve 레코드가 생기는지 확인
3. 생성 시간이 수 초 이내로 단축되는지 확인
4. Node.js 로그에 `Lock wait timeout` 에러가 없는지 확인

## 요약

| 항목 | 변경 전 | 변경 후 |
|------|--------|--------|
| `createPartner()` | `@Transactional` (전체) | 트랜잭션 없음 (오케스트레이터) |
| 파트너/체인 저장 | 같은 트랜잭션 | `createPartnerInternal()` 별도 `@Transactional` |
| 지갑 생성 | 같은 트랜잭션 (Lock 충돌) | 트랜잭션 밖 (Node.js 자유 INSERT) |
| `createPartnerWallets()` | `@Transactional` | 트랜잭션 제거 |
| 파트너 생성 시간 | ~30초+ (timeout 포함) | ~3초 |
