DB 커밋이 끝나기 전에 Kafka 메시지를 보내는 코드였습니다. Consumer가 먼저 조회하면 아직 저장 중인 데이터를 찾지 못할 수 있었습니다. 메시지 전송을 커밋 뒤로 옮기고, 전송 결과를 DB에 저장하는 후처리에는 별도 트랜잭션을 적용했습니다.
나중에 행이 있다는 사실로는 실패한 조회를 설명할 수 없었다
Consumer가 메시지에 담긴 식별자로 데이터를 조회하지 못했습니다. 그런데 나중에 같은 식별자로 조회하니 정상적으로 저장된 행이 있었습니다.
확인해야 할 것은 데이터의 존재 여부보다 Consumer가 조회한 시점에 커밋돼 있었는가였습니다. 로그에는 메시지 수신과 조회 실패가 남아 있었지만, DB 커밋의 정확한 시각은 없었습니다. 특정 장애의 원인을 확정할 수는 없었고, 먼저 코드에서 가능한 실행 순서를 확인했습니다.
조회 실패 전후의 타임라인
메시지 수신 시점을 T0로 두고 확인된 순서를 나열했습니다. T1부터 T3는 순서 표시이며, 실제 경과 시간을 뜻하지 않습니다.
| 시점 | 관찰한 동작 | 확인되지 않은 부분 |
|---|---|---|
| T0 · 메시지 수신 | Consumer가 메시지를 받음 | 당시 Producer의 커밋 완료 여부 |
| T1 · 조회 실패 | Consumer가 필요한 데이터를 찾지 못함 | 조회 대상 행이 없었는지, 관련 데이터가 없었는지 |
| T2 · 처리 중단 | 조회 실패로 해당 메시지를 건너뜀 | 일시적인 조회 실패였는지 |
| T3 · 이후 DB 확인 | 같은 식별자로 데이터가 조회됨 | 최초 조회와 커밋 사이의 정확한 간격 |
이 타임라인에는 DB 커밋을 넣지 않았습니다. 커밋 시각이 로그에 없어 어느 구간에서 완료됐는지 확인할 수 없었기 때문입니다. 커밋보다 조회가 앞설 수 있는지는 다음 코드의 실행 순서로 살펴봤습니다.
서비스 코드를 보니 데이터를 변경하는 트랜잭션 안에서 Kafka Producer를 호출하고 있었습니다. 저장 메서드가 실행된 뒤였지만, 트랜잭션의 커밋은 아직 남아 있었습니다.
작업 생성 이벤트를 예로 들어보겠습니다. 아래 코드는 흐름을 설명하기 위한 범용 예제입니다.
@Transactional
public void create(JobRequest request) {
Job job = Job.create(request);
repository.save(job);
producer.send(job.getId());
}
save() 호출이 끝났다고 다른 연결에서 변경 내용을 볼 수 있는 것은 아닙니다. Kafka 전송도 DB 트랜잭션의 커밋을 자동으로 기다려주지 않습니다. Consumer가 충분히 빨랐다면 아래 순서가 가능했습니다.
변경 전 — 가능한 경쟁 순서
같은 영속성 컨텍스트에서 저장한 엔티티를 다시 읽으면 조회가 성공할 수 있습니다. Consumer처럼 다른 연결에서 읽는 경우에는 커밋 여부가 영향을 줍니다.
재시도를 넣기 전에 Producer의 순서를 바꿨다
조회 실패를 잠깐 재시도하면 증상이 줄어들 수 있습니다. 하지만 Producer가 나중에 롤백해도 이미 보낸 메시지는 남습니다. Consumer가 몇 번 기다릴지를 정하는 것만으로 이 순서를 해결할 수는 없었습니다.
| 선택 | 얻는 것 | 남는 문제 |
|---|---|---|
| 일정 시간 대기 | 소비 시작을 늦춤 | 커밋 소요시간이 일정하지 않음 |
| Consumer 조회 재시도 | 일시적인 미가시성을 흡수 | 롤백된 데이터의 메시지도 도착할 수 있음 |
| 커밋 이후 전송 | 성공한 트랜잭션 뒤에 전송 | 커밋 직후 프로세스 종료 시 전송을 놓칠 수 있음 |
| 같은 트랜잭션에 Outbox 저장 | 재전송할 의도가 DB에 남음 | 릴레이와 중복 처리 관리가 필요함 |
이번에는 전송 시점을 먼저 고쳤습니다. 필요한 데이터가 같은 트랜잭션에서 저장되고, Consumer가 커밋된 데이터를 읽는다는 전제였습니다. 지연된 replica를 읽거나 관련 데이터를 별도 트랜잭션에서 저장한다면 이 수정만으로 조회 성공을 보장할 수 없습니다.
이벤트를 발행하는 시점과 외부로 보내는 시점을 나눴다
Spring 이벤트를 쓰는 것만으로는 커밋 이후 실행이 보장되지 않습니다. 기본 멀티캐스터의 일반 @EventListener는 호출 스레드에서 실행될 수 있고, @Async를 붙이면 작업 스레드가 커밋보다 먼저 실행될 수 있습니다.
여기서 바꾼 것은 리스너의 실행 조건이었습니다.
public record JobCreated(long jobId) {}
@Transactional
public void create(JobRequest request) {
Job job = repository.save(Job.create(request));
events.publishEvent(new JobCreated(job.getId()));
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCreated(JobCreated event) {
producer.send(event.jobId());
}
트랜잭션 안에서 발행한 이벤트는 커밋이 성공한 뒤 리스너가 처리합니다. 롤백되면 외부 전송 리스너는 실행되지 않습니다.
publishEvent() 자체가 영속 큐에 이벤트를 저장하는 것은 아닙니다. 실행 시점은 리스너와 멀티캐스터 구성에 따라 달라집니다. 트랜잭션 단계별 동작과 트랜잭션 없는 호출은 트랜잭션과 이벤트 발행에 정리했습니다. Spring 공식 설명도 이 구분을 기준으로 읽으면 됩니다.
변경 후 — 커밋 성공 뒤에 외부 전송
공유 리스너를 바꾸니 다른 호출 경로도 고쳐야 했다
같은 리스너를 쓰는 경로 중에는 트랜잭션을 직접 커밋하는 코드도 있었습니다. 그 경로는 커밋이 끝난 뒤에 Spring 이벤트를 발행하고 있었습니다.
// 이전 호출 순서
transactionManager.commit(status);
events.publishEvent(new JobCreated(jobId));
리스너를 AFTER_COMMIT으로 바꾸면 이 이벤트는 기본 설정에서 처리되지 않습니다. 발행 시점에 연결할 활성 트랜잭션이 없기 때문입니다.
// 수정한 호출 순서
events.publishEvent(new JobCreated(jobId));
transactionManager.commit(status);
fallbackExecution=true를 사용하면 트랜잭션 없는 발행도 처리할 수 있습니다. 다만 그 분기에서는 커밋 성공을 기다리는 의미가 사라집니다. 이번에는 호출 순서를 바꿀 수 있었기 때문에 fallback을 켜지 않았습니다. 이미 커밋 뒤에 실행되던 직접 전송은 기존 위치를 유지했습니다.
공유 리스너의 애노테이션을 바꿀 때는 발행하는 쪽을 모두 찾아야 합니다. 선언적 트랜잭션에서 정상 동작한다고 수동 커밋 경로도 같은 방식으로 동작하는 것은 아니었습니다.
커밋 이후 DB 쓰기에는 커밋할 트랜잭션이 하나 더 필요했다
전송 시점을 고친 뒤 검수에서 다른 문제가 나왔습니다. 한 리스너는 외부 전송뿐 아니라 후처리 결과를 DB에 저장하고 있었습니다.
AFTER_COMMIT 단계에서는 원래 트랜잭션의 커밋은 끝났지만 자원이 아직 정리되지 않았을 수 있습니다. 그 자원을 사용해 DB에 접근한다고 해서 변경 내용이 다시 커밋되는 것은 아닙니다. Spring의 트랜잭션 동기화 API는 이 시점의 트랜잭션 작업에 REQUIRES_NEW를 사용하도록 설명합니다.
@Async가 있어서 다른 스레드에서 실행될 것이라는 가정도 안전하지 않았습니다. 실행기가 CallerRunsPolicy를 사용하면, 포화 상태에서는 작업을 제출한 스레드가 직접 실행합니다. 커밋 스레드가 리스너를 실행하는 경우도 고려해야 했습니다.
@Async("notificationExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onCreated(JobCreated event) {
// 기존 외부 호출과 후처리 결과 저장
}
이번에는 기존 리스너 전체에 새 트랜잭션을 적용했습니다. 호출 스레드가 어느 쪽이든 후처리 저장을 확정하려는 수정이었습니다. 대신 외부 호출을 기다리는 동안 트랜잭션이 유지될 수 있습니다. 호출이 길어지면 외부 통신과 저장 메서드를 분리하고, 저장 쪽에만 새 트랜잭션을 두는 편이 낫습니다.
새 트랜잭션은 원래 거래와 별개의 성공·실패를 갖습니다. 이미 확정된 거래를 되돌리지는 않습니다. 프록시 적용, 커넥션 풀 여유, 인라인 실행 테스트까지는 커밋 이후 리스너의 DB 저장에서 자세히 다룹니다.
Outbox에는 전송할 의도를 커밋 전에 남겼다
기존 Outbox의 INSERT까지 커밋 뒤로 옮기지는 않았습니다. 데이터만 확정된 직후 프로세스가 종료되면 전송할 의도를 기록할 기회가 사라집니다.
@Transactional
public void create(JobRequest request) {
Job job = repository.save(Job.create(request));
long eventId = outbox.append(job.getId());
events.publishEvent(new OutboxReady(eventId));
}
데이터 변경과 Outbox 기록은 같은 DB 트랜잭션에 남깁니다. 커밋 후 즉시 전송을 시도하고, 미전송 기록은 릴레이가 다시 읽습니다. 빠른 전송과 장애 이후 복구가 서로 다른 역할을 맡는 구조입니다.
Outbox가 보장하는 것은 데이터와 전송 의도의 저장입니다. DB와 Kafka를 하나의 커밋으로 묶거나, 외부 처리까지 정확히 한 번 보장하는 것은 아닙니다. 전송 성공 후 완료 상태를 저장하기 전에 종료되면 같은 이벤트가 다시 전달될 수 있습니다.
기존 Outbox 경로에서는 즉시 전송과 릴레이를 유지했습니다. 중복 처리와 릴레이의 실패 구간은 Transactional Outbox의 전달 보장에 이어 적었습니다.
테스트에서는 별도 연결로 커밋 결과를 읽는다
애노테이션 검사만으로는 전송 시점과 DB 가시성을 확인하기 어렵습니다. 테스트에서 다음 조건을 직접 만들어야 합니다.
| 테스트 조건 | 확인할 결과 |
|---|---|
| 트랜잭션 안에서 이벤트 발행 | 커밋 전 전송 없음 |
| 정상 커밋 | 전송 시 다른 연결에서 데이터 조회 가능 |
| 롤백 | 전송 없음 |
| 트랜잭션 없는 발행 | 기본 설정에서 리스너 실행 없음 |
| 수동 커밋 경로 | 이벤트 발행이 커밋보다 앞에 있음 |
| 호출 스레드에서 실행 | 후처리 저장이 새 트랜잭션에서 확정됨 |
DB 가시성은 실제 DB와 Spring 이벤트를 연결하고, 별도 연결과 EntityManager로 조회해 검증합니다. 같은 영속성 컨텍스트에 남은 객체를 다시 읽으면 커밋 전에도 조회가 성공할 수 있습니다. 조회 쪽 연결이 새로 커밋된 데이터를 읽는 조건인지도 확인해야 합니다.
전송 여부를 기록하는 대역으로 Kafka Producer를 대체하면 DB와 리스너의 실행 순서에 집중할 수 있습니다. 브로커까지 포함한 전달 성공은 별도 통합 테스트에서 확인합니다.
CallerRuns 경로는 작업을 호출 스레드에서 실행하는 동기 실행기로 재현할 수 있습니다. 실행기를 실제로 포화시킬 때까지 기다리지 않고, 후처리 저장이 원래 스레드에서도 확정되는지 확인하는 방법입니다. 실행기 자체의 포화·종료 정책은 별도 테스트 대상으로 둡니다.
Outbox는 커밋·롤백 시 데이터와 전송 의도가 함께 남거나 사라지는지 확인합니다. 전송 성공 직후 완료 기록 전에 처리를 중단하는 테스트도 필요합니다. 이때 재전송된 이벤트를 Consumer가 중복 적용하지 않아야 합니다.
전송 시점과 전달 보장을 따로 생각하게 됐다
이번 수정에는 두 경계가 필요했습니다. 외부 전송은 원래 트랜잭션의 커밋 뒤에 실행하고, 그 단계에서 다시 DB를 쓰는 코드는 새 트랜잭션으로 확정했습니다.
커밋 직후 프로세스가 종료되는 경우도 생각해야 합니다. 이때 놓친 통지를 복구해야 한다면 전송 의도를 영속화하고 재전송과 중복 수신을 함께 설계해야 합니다.
다음에 비슷한 코드를 볼 때는 @Async나 이벤트 클래스의 존재부터 확인하지 않으려고 합니다. 먼저 데이터를 확정하는 트랜잭션이 어디서 끝나는지 보고, 외부 전송과 후처리 저장이 그 경계의 어느 쪽에 있는지 확인할 생각입니다.
더 읽기
- 트랜잭션과 이벤트 발행: 리스너 종류, 트랜잭션 단계, fallback의 적용 조건
- 커밋 이후 리스너의 DB 저장: 자원 정리 시점, 새 트랜잭션, CallerRuns와 검증 방법
- Transactional Outbox의 전달 보장: 원자적 저장, 재전송, 중복 처리
- Spring: 트랜잭션에 연결한 이벤트
- Spring 6.1.14: TransactionSynchronization
- JDK 17: CallerRunsPolicy
- Chris Richardson: Transactional Outbox
