이전 글과 이어진다.
이전 글에서 snapshot_balance / available_balance 업데이트 과정에서 데드락이 났던 원인과 해결 과정을 정리했다.
이번에는 스냅샷 프로젝터 (consumer) 쪽에서 비슷한 데드락이 다시 발생했다.
이전에 락 순서를 맞춰놔서 이번에는 데드락 덤프를 확인했다.
SHOW ENGINE INNODB STATUS
덤프 일부는 아래와 같았다.
LOCK WAIT ... 1952 row lock(s), undo log entries 141
update available_balance ... where account_id=3
HOLDS THE LOCK(S):
RECORD LOCKS ... index FKn763t766d31cgf7m4w4kj3hs0 of table `tossclone`.`ledger_entry` ...
그 뒤로 무수히 많은 record lock들 . . .
현재 실행중인 쿼리는 available_balance update이다. 그런데 같은 트랜잭션이 ledger_entry쪽 인덱스 락을 엄청 많이 잡고 있었다. (HOLDS THE LOCKS)
추가로 FKn... 처럼 보이는 인덱스명은
SELECT
kcu.CONSTRAINT_NAME AS fk_name,
kcu.TABLE_NAME AS table_name,
kcu.COLUMN_NAME AS column_name,
kcu.REFERENCED_TABLE_NAME AS referenced_table,
kcu.REFERENCED_COLUMN_NAME AS referenced_column
FROM information_schema.KEY_COLUMN_USAGE kcu
WHERE
kcu.TABLE_SCHEMA = '테이블 명'
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
ORDER BY
kcu.TABLE_NAME,
kcu.CONSTRAINT_NAME,
kcu.ORDINAL_POSITION;

ledger_entry.account_id => account.account_id
FK와 연관된 이름이었다.
당시 스냅샷 프로젝터가 메시지 큐에서 메시지를 꺼내서 다음을 처리한다.
1) SnapshotApplied에 UNIQUE(accountId, txId)로 멱등 락
2) snapshot_balance 반영
3) available_balance 반영
4) outbox SENT 마킹
5) ledger 상태 COMMITTED 마킹
그런데 이 작업을 스케줄러에서 다음처럼 돌리고 있었다.
@Scheduled(fixedRate = 500)
@Transactional
public void scheduledQueue() { // 소비자임 MQ를 받아서 스냅샷/가용잔액 반영함.
// 엄청 많은 row lock이 걸림
int max = 2000;
for (int i = 0; i < max; i++) {
SnapshotMsg msg = sharedMessageQueue.poll();
if (msg == null) break;
// 1,2,3,4,5 처리
}
}
@Scheduled(fixedRate = 500)for문으로 처리@Transactional이 붙어 있음결과: 테스트를 돌려보면 가끔씩 데드락이 터졌다.
이부분이 원인이라고 생각하였다.
현재는 Transactional안에 for문이 있었기 때문에 2000개를 한 트랜잭션으로 처리하기 때문에 이부분을 고쳐야겠다고 생각했다.
REQUIRES_NEW를 사용하여 분리해보았다.
@Scheduled(fixedRate = 500)
@Transactional
public void scheduledQueue() {
// 엄청 많은 row lock이 걸림
int max = 2000;
for (int i = 0; i < max; i++) {
SnapshotMsg msg = sharedMessageQueue.poll();
if (msg == null) break;
log.info("메시지 큐에서 꺼냄", msg);
processOneMessage(msg);
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOneMessage(SnapshotMsg msg) {
// 1,2,3,4,5 처리
}
하지만 또 다시 데드락이 났다.
처음엔 processOneMessage()에 REQUIRES_NEW를 붙였으니 메시지 1개당 트랜잭션이 분리되겠지? 라고 생각했다.
하지만 다시 데드락 로그를 확인해봤더니 한 트랜잭션이 이미 수백 개의 row lock을 잡고 있었다.
LOCK WAIT ... 1952 row lock(s), undo log entries 141available_balance 업데이트인데,ledger_entry 쪽 인덱스 락을 엄청 많이 쥐고 있는 상태였다.메시지 하나 처리 => 커밋 => 락 해제 순서가 아니라
한 트랜잭션이 계속 살아 있으면서 ledger_entry 락이 누적되고, 마지막에 available_balance에서 서로 물려 데드락이 터진 형태였다.
REQUIRES_NEW가 실제로는 적용되지 않았다라고 생각을 했다 그래서 한번 확인해보기로 하였다.
트랜잭션 로그를 TRACE로 하고 확인했다.
logging.level.org.springframework.transaction: TRACE
logging.level.org.springframework.transaction.interceptor: TRACE
logging.level.org.springframework.orm.jpa.JpaTransactionManager: TRACE
정상적으로 REQUIRES_NEW가 적용되면 processOneMessage 진입 시점에 이런 로그가 떠야한다고 한다.
하지만 실제 로그는 processOneMessage안에서 찍힌 트랜잭션 이름이 계속 scheduledQueue였다.
(기존 트랜잭션 컨텍스트를 그대로 사용하고 있었던 것 같다.)
아래 로그를 보면 No need to create transaction for... 이 있는데 이건 해당 메서드 호출에서 새로운 트랜잭션을 만들 필요가 없다라는 의미이다.
INFO 46256 --- [toss] [ scheduling-1] c.m.t.s.S.BalanceSnapshotProjector : [TX] where=processOneMessage:beforeCommitPoint, active=true, name=com.maeng.toss.snapshot.SnapshotProjector.BalanceSnapshotProjector.scheduledQueue, isNew=true, thread=scheduling-1
INFO 46256 --- [toss] [ scheduling-1] c.m.t.s.S.BalanceSnapshotProjector : 메시지 큐에서 꺼냄
INFO 46256 --- [toss] [ scheduling-1] c.m.t.s.S.BalanceSnapshotProjector : [TX] where=processOneMessage:enter, active=true, name=com.maeng.toss.snapshot.SnapshotProjector.BalanceSnapshotProjector.scheduledQueue, isNew=true, thread=scheduling-1
TRACE 46256 --- [toss] [ scheduling-1] o.s.t.i.TransactionInterceptor : No need to create transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.existsByTxIdAndAccountId_AccountIdAndStatus]: This method is not transactional.
아래 로그를 보면 실제로 새로운 트랜잭션을 열지않고 기존 트랜잭션에 합류했다.
DEBUG 46256 --- [toss] [ scheduling-1] o.s.orm.jpa.JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(1793071322<open>)] for JPA transaction
DEBUG 46256 --- [toss] [ scheduling-1] o.s.orm.jpa.JpaTransactionManager : Participating in existing transaction
TRACE 46256 --- [toss] [ scheduling-1] o.s.t.i.TransactionInterceptor : Getting transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]
이를 통해 REQUIRES_NEW가 제대로 분리되지 않았다고 생각했다.

Spring의 @Transactional은 AOP 프록시 기반이다.
프록시를 통해 호출될 때만 트랜잭션이 새로 열리고(REQUIRES_NEW), 커밋/롤백이 관리된다.
그런데 내 코드는 이런 구조였다.
scheduledQueue() (같은 클래스)processOneMessage()를 직접 호출이 같은 빈 내부에서 자기 메서드 호출은 프록시를 거치지 않는다.
그래서 processOneMessage()에 REQUIRES_NEW를 붙여도, 실제로는 새로운 트랜잭션이 열리지 않을 수 있다.
결과적으로 아래 코드는 의도와 달리 2000개를 한 트랜잭션으로 처리하는 효과가 남는다.
@Scheduled(fixedRate = 500)
@Transactional
public void scheduledQueue() {
for (...) {
processOneMessage(msg); // 같은 빈 내부 호출 -> 프록시 미적용 가능
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOneMessage(SnapshotMsg msg) { ... }
그래서 DB 반영 (processOneMessage)을 별도 컴포넌트로 분리했다.
스케줄러: 큐에서 poll만 하고 위임
프로세서 빈: 메시지 1개를 트랜잭션으로 처리
그리고 스케줄러 메서드의 @Transactional은 제거했다.
스케줄러 빈
@Component
@RequiredArgsConstructor
public class BalanceSnapshotProjector {
private final Queue<SnapshotMsg> sharedMessageQueue;
private final SnapshotMessageProcessor processor;
@Scheduled(fixedRate = 500)
public void scheduledQueue() {
int max = 2000;
for (int i = 0; i < max; i++) {
SnapshotMsg msg = sharedMessageQueue.poll();
if (msg == null) break;
processor.processOneMessage(msg); // 다른 빈 호출 -> 프록시 적용
}
}
}
처리 빈
@Component
@RequiredArgsConstructor
public class SnapshotMessageProcessor {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOneMessage(SnapshotMsg msg) {
// 1,2,3,4,5 처리
}
}
이렇게 바꾸니 트랜잭션이 메시지 1개 단위로 끊기면서
락이 누적되지 않았고, 같은 조건에서 데드락이 일어나지 않았다.
@Transactional + for(2000) 조합은 트랜잭션 생존시간이 길어지고 락이 누적될 가능성이 커진다.
REQUIRES_NEW를 쓸때는 프록시를 타는 호출인지 확인하는 것이 핵심이라고 생각했다.
같은 빈 내부 호출(self-invocation)이면 REQUIRES_NEW가 적용되지 않을 수 있다.
트랜잭션/락 문제는 SHOW ENGINE INNODB STATUS + 트랜잭션 TRACE 로그를 확인하여 방향을 잡으면 해결이 빨라진다.