Spring mysql jpa 데드락 상황 해결 과정

Nevgiveup·2025년 12월 1일

Backend

목록 보기
1/8

0. 당시 상황

나는 현재 뱅킹 시스템을 혼자 만들어 보고 있었고 locust로 테스트를 하는데 deadlock을 만나게 되었다

transfer 서비스는 HTTP 요청을 받아 실제 이체를 처리하는 온라인 트랜잭션이다.

  1. available_balance에서 출금 가능 금액을 차감하고
  2. snapshot_balance를 업데이트하고
  3. ledger_entry에 이중기장(DEBIT/CREDIT)을 기록하고
  4. outbox에 이벤트를 적재한다.

비동기 프로젝터는 Outbox → MQ → 메모리 큐(sharedMessageQueue)를 통해 들어온 메시지를 처리하는 오프라인 트랜잭션이다.

  1. snapshot_balance를 업데이트하고
  2. available_balance를 증가시키고
  3. ledger 상태를 COMMITTED로 확정한다.

transfer 서비스

@Transactional
public void transfer(Long fromId, Long toId, Long amount) {
    // 1. 출금 계좌: 가용잔액 차감
    availableBalanceRepo.tryDebit(fromId, amount);
    //  → available_balance(account_id = fromId) UPDATE (SELECT ... FOR UPDATE 포함 가능)

    // 2. 출금 계좌: 스냅샷 -amount 반영
    snapshotRepository.applyDelta(fromId, -amount);
    //  → snapshot_balance(account_id = fromId) 에
    //     INSERT ... ON DUPLICATE KEY UPDATE (PK = account_id)

    ledgerRepo.saveAll(List.of(debit, credit)); // 원장에 2줄 insert
    outboxRepository.save(outbox);				// outbox에 insert
}

스케줄러 (인메모리 큐에서 메시지를 빼어 스냅샷, 가용잔액에 반영하는 스케줄러)

@Scheduled(fixedRate = 500)
@Transactional
public void scheduledQueue() {
    for (...) {
        SnapshotMsg msg = sharedMessageQueue.poll();
        if (msg == null) break;

        if (ledgerRepository.existsByTxIdAndSideAndStatus(
                msg.txId(), EntrySide.CREDIT, Status.COMMITTED)) {
            continue;
        }

        try {
            snapshotAppliedRepository.save(SnapshotApplied.of(msg.accountId(), msg.txId()));

            snapshotRepository.applyDelta(msg.accountId(), msg.amount());  // 1. snapshot + insert
            int updated = availableBalanceRepo.credit(msg.accountId(), msg.amount());  // 2. available + update
            if (updated == 0) throw ...;

            outboxRepository.markSent(msg.outboxId());
            ledgerRepository.markCommitted(msg.txId(), msg.accountId(), msg.entrySide());
        } catch (DataIntegrityViolationException dup) {
            outboxRepository.markSent(msg.outboxId());
        }
    }
}

1. 왜 발생했을까

왜 발생할까에 대해 계속 고민하다가 이상한 점을 하나 찾았다

출처 : 땔감툰

프로젝터 입장에서 특정 계좌 accountId에 대해 테이블을 만지는 순서는 다음과 같다.

  1. snapshot_balance(accountId) – applyDelta(+amount)
  2. available_balance(accountId) – credit(+amount)

즉, 락 순서: snapshot => available 이다.

transfer입장에서 accountId에 대해 테이블을 만지는 순서는 다음과 같다.

  1. available_balance(accountId) – tryDebit(-amount)
  2. snapshot_balance(accountId) – applyDelta(-amount)

즉, 락 순서: available => snapshot 이다.

예를 들어서,

계좌 A, B, C가 있다고 하자.
이전에 B → A 송금이 이미 발생했고,
그때 생성된 “계좌 A, CREDIT, +10,000원” 메시지가 Outbox → MQ → sharedMessageQueue 에 들어와 있다.

그 와중에 사용자가 A → C 송금을 요청했다.
즉, 아래 두 트랜잭션이 거의 동시에 실행될 수 있다.

두 트랜잭션은 모두 계좌 A에 대해 snapshot_balance와 available_balance를 건드린다.

T1 = 스케줄러 트랜잭션 (B → A 송금의 CREDIT 메시지 처리 중)
snapshot_balance(A) += 10,000 (락 순서 1: snapshot_balance)
available_balance(A) += 10,000 (락 순서 2: available_balance)

T2 = 온라인 트랜잭션 (A → C 송금 처리 중)
available_balance(A) -= 50,000 (락 순서 1: available_balance)
snapshot_balance(A) -= 50,000 (락 순서 2: snapshot_balance)

이 상황이라고 생각했다.

2. 문제를 해결

락 순서를 통일 시키면 해결이 될 것이라 생각해
프로젝터 입장
락 순서를 snapshot => available 에서 available => snapshot으로 수정했다.

// 변경 전 (프로젝터)
snapshotRepository.applyDelta(msg.accountId(), msg.amount());  			  // 1. snapshot
int updated = availableBalanceRepo.credit(msg.accountId(), msg.amount()); // 2. available

// 변경 후 (락 순서 통일: available -> snapshot)
int updated = availableBalanceRepo.credit(msg.accountId(), msg.amount()); // 1. available
snapshotRepository.applyDelta(msg.accountId(), msg.amount());             // 2. snapshot

그랬더니 snapshot deadlock은 더이상 볼 수 없게 되었다.

3. 그 이후

Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed:
org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction]
[insert into ledger_entry (account_id,amount,idempotency_key,memo,occurred_at,side,status,tx_id) values (?,?,?,?,?,?,?,?)]; 
SQL [insert into ledger_entry (account_id,amount,idempotency_key,memo,occurred_at,side,status,tx_id) values (?,?,?,?,?,?,?,?)]] with root cause

snapshot과 available의 락 순서는 맞춰서 해결했지만 ledger를 동시에 insert하는 부분이 남아있었다.
이 부분에서 새로운 deadlock이 나왔다.

이 데드락은 다음 글에서 정리해보겠다.

profile
while( true ) { study(); }

0개의 댓글