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

Nevgiveup·2025년 12월 16일

Backend

목록 보기
2/8

0. 문제 상황

이전 글과 이어진다.

이전 글에서 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. 문제 원인

당시 스냅샷 프로젝터가 메시지 큐에서 메시지를 꺼내서 다음을 처리한다.

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 처리
        }
    }
  1. @Scheduled(fixedRate = 500)
  2. 한 번에 최대 2000개까지 for문으로 처리
  3. 스케줄러 메서드에 @Transactional이 붙어 있음

결과: 테스트를 돌려보면 가끔씩 데드락이 터졌다.

이부분이 원인이라고 생각하였다.


2. 문제를 해결해보자

현재는 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 처리
    }

하지만 또 다시 데드락이 났다.

2-1. REQUIRES_NEW를 붙였는데도 왜 데드락이 났을까?

처음엔 processOneMessage()에 REQUIRES_NEW를 붙였으니 메시지 1개당 트랜잭션이 분리되겠지? 라고 생각했다.

하지만 다시 데드락 로그를 확인해봤더니 한 트랜잭션이 이미 수백 개의 row lock을 잡고 있었다.

  • LOCK WAIT ... 1952 row lock(s), undo log entries 141
  • 현재 실행 중인 쿼리는 available_balance 업데이트인데,
  • 동시에 ledger_entry 쪽 인덱스 락을 엄청 많이 쥐고 있는 상태였다.

메시지 하나 처리 => 커밋 => 락 해제 순서가 아니라
한 트랜잭션이 계속 살아 있으면서 ledger_entry 락이 누적되고, 마지막에 available_balance에서 서로 물려 데드락이 터진 형태였다.

REQUIRES_NEW가 실제로는 적용되지 않았다라고 생각을 했다 그래서 한번 확인해보기로 하였다.

2-1-1. 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 진입 시점에 이런 로그가 떠야한다고 한다.

  • Suspending current transaction
  • Creating new transaction with name [..processOneMessage]: PROPAGATION_REQUIRES_NEW
  • (메서드 종료 후) Resuming suspended transaction

하지만 실제 로그는 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가 제대로 분리되지 않았다고 생각했다.

2-2. 원인: 같은 빈 내부 호출(Self-invocation)로 프록시를 안 탄다

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) { ... }

3. 해결: 메시지 처리 로직을 별도 빈으로 분리하기

그래서 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개 단위로 끊기면서
락이 누적되지 않았고, 같은 조건에서 데드락이 일어나지 않았다.


4. 정리

@Transactional + for(2000) 조합은 트랜잭션 생존시간이 길어지고 락이 누적될 가능성이 커진다.

REQUIRES_NEW를 쓸때는 프록시를 타는 호출인지 확인하는 것이 핵심이라고 생각했다.

같은 빈 내부 호출(self-invocation)이면 REQUIRES_NEW가 적용되지 않을 수 있다.

트랜잭션/락 문제는 SHOW ENGINE INNODB STATUS + 트랜잭션 TRACE 로그를 확인하여 방향을 잡으면 해결이 빨라진다.

profile
while( true ) { study(); }

0개의 댓글