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

transfer 서비스는 HTTP 요청을 받아 실제 이체를 처리하는 온라인 트랜잭션이다.
비동기 프로젝터는 Outbox → MQ → 메모리 큐(sharedMessageQueue)를 통해 들어온 메시지를 처리하는 오프라인 트랜잭션이다.
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());
}
}
}
왜 발생할까에 대해 계속 고민하다가 이상한 점을 하나 찾았다

출처 : 땔감툰
프로젝터 입장에서 특정 계좌 accountId에 대해 테이블을 만지는 순서는 다음과 같다.
즉, 락 순서: snapshot => available 이다.
transfer입장에서 accountId에 대해 테이블을 만지는 순서는 다음과 같다.
즉, 락 순서: 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)
이 상황이라고 생각했다.
락 순서를 통일 시키면 해결이 될 것이라 생각해
프로젝터 입장
락 순서를 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은 더이상 볼 수 없게 되었다.
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이 나왔다.
이 데드락은 다음 글에서 정리해보겠다.