byworld 님의 블로그

20260424 - Outbox DLT 상태 머신과 async 콜백 race — saveAndFlush 는 commit 이 아니다 본문

TIL

20260424 - Outbox DLT 상태 머신과 async 콜백 race — saveAndFlush 는 commit 이 아니다

byworld 님의 블로그 2026. 4. 24. 16:18

 

한 줄 요약

saveAndFlush로 DB 반영을 "확정"했다고 믿고 Kafka async 발행을 곧장 태우면, producer 콜백이 outer transaction commit보다 먼저 뛰면서 상태 머신이 뒤집힌다. 해법은 saveAndFlush가 아니라 commit 경계 뒤로 send를 미루는 것.

문제 상황

공통 라이브러리에 Outbox 패턴을 다듬던 중, CodeRabbit 리뷰어가 한 줄을 찔렀다.

"DLT 격리를 결정한 순간에 이미 message 손실 경로가 열린다."

처음엔 뭔 소린가 싶었다. 코드는 이렇게 생겼었다.

 
 
// OutboxCallback.onFailure 내부, @Transactional(REQUIRES_NEW)
if (outbox.getRetryCount() >= MAX_RETRY) {
    outbox.markDltPending();
    outboxRepository.saveAndFlush(outbox);  // ← DB 반영 "완료"로 착각
    sendToDlt(outbox);                       // ← Kafka 비동기 발행
}

그리고 sendToDlt는:

 
 
kafkaTemplate.send(record).whenComplete((result, ex) -> {
    if (ex == null) {
        dltAckHandler.markDltSent(correlationId); // REQUIRES_NEW 새 트랜잭션
    }
});

DLT_PENDING을 먼저 flush해뒀으니 Kafka ack가 오면 DLT_SENT로 덮으면 된다 — 순서가 맞아 보인다.

왜 깨지나

flush ≠ commit.

saveAndFlush는 대기 중이던 INSERT/UPDATE SQL을 DB로 송출할 뿐, 여전히 현재 트랜잭션 안이다. 다른 트랜잭션에서 그 변경을 읽으려면 이 트랜잭션이 commit될 때까지 기다려야 한다.

타임라인을 풀면 이렇다:

  1. onFailure 트랜잭션 T1이 DLT_PENDING을 flush (아직 commit 전)
  2. T1 내부에서 kafkaTemplate.send() 호출 → producer 스레드로 넘어감
  3. Kafka가 빠르게 ack → producer 콜백이 markDltSent REQUIRES_NEW 트랜잭션 T2를 연다
  4. T2가 T1보다 먼저 commit (운 나쁘면)
  5. 이어서 T1이 commit되며 DLT_PENDING을 덮어써 T2의 DLT_SENT가 사라진다

결과: 이미 DLT에 잘 도착한 메시지를 relay 스케줄러가 다음 주기에 다시 집어 중복 발행. 격리 수준에 따라선 T2 update 자체가 lock으로 막히거나 실패하기도 한다.

Kafka는 외부 시스템이라 트랜잭션으로 묶을 수도 없다. 문제의 본질은 "send가 commit 전에 시작된다"는 것이고, 이걸 막아야 한다.

saveAndFlush를 여러 번 하면?

안 된다. flush를 백 번 해도 commit이 아니면 다른 트랜잭션 관점에선 과거 상태다. 격리 수준이 해결해주지 않는다.

해법: TransactionSynchronizationManager

Spring은 이런 경우를 위해 "현재 활성 트랜잭션이 commit되면 실행할 콜백" 을 등록하는 장치를 둔다.

 
 
java
outbox.markDltPending();
outboxRepository.saveAndFlush(outbox);

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            sendToDlt(outbox);
        }
    }
);

이제 흐름이 선형이다:

  1. T1이 commit → DLT_PENDING이 DB에 영구화
  2. 그 다음에 sendToDlt 실행 → Kafka send
  3. ack 콜백이 T2를 열고 DLT_SENT로 덮음 — 덮을 원본이 이미 commit돼 있으므로 race 자체가 성립 불가

되짚어보기

이 버그의 뿌리는 두 가지다.

  1. flush를 commit처럼 취급한 것 — 혼자선 "알긴 아는" 개념인데, 여러 트랜잭션과 외부 async가 엮이는 자리에선 바로 버그가 된다.
  2. async Kafka 콜백 타이밍이 outer transaction보다 빠를 수 있다는 사실을 간과한 것.

아이러니한 건, Outbox의 publish 단계에선 이미 @TransactionalEventListener(phase = AFTER_COMMIT)를 쓰고 있었다는 점이다. 같은 요구사항("commit 이후에만 안전한 작업")이었는데, DLT 전이 쪽에선 "saveAndFlush 한 줄이면 되겠지" 하고 지나갔다.

다음부터 체크할 질문

async 콜백이 엮인 DB 상태 전이를 만나면 먼저 이걸 물어본다:

"이 상태가 DB에 commit된 다음에만 이 콜백이 돌아야 하는가?"

YES면 saveAndFlush로는 해결이 안 된다. 선택지는 둘 중 하나:

  • TransactionSynchronizationManager.registerSynchronization (메서드 내부 임시 등록)
  • @TransactionalEventListener(phase = AFTER_COMMIT) (이벤트 기반 분리)