Transactional Outbox는 DB 저장과 메시지 발행을 함께 해야 할 때, 메시지를 큐에 바로 보내지 않고 같은 DB의 별도 테이블(outbox, 보낼 편지함)에 같은 트랜잭션으로 적어 두는 패턴이다. 메시지를 실제로 큐에 옮기는 일은 나중에 별도 프로세스가 맡는다.
두 시스템에 쓰는 문제
회원 가입을 처리하면서 '환영 메일 보내기' 작업을 메시지 큐(Redis, RabbitMQ 등)에 넣는 상황을 보자. 할 일은 두 가지다.
- 회원 행을 DB에 저장한다.
- 메일 작업 메시지를 큐에 보낸다.
DB와 메시지 큐는 서로 다른 시스템이라 한 트랜잭션으로 묶을 수 없다. 그래서 어느 쪽을 먼저 해도 중간에 서버가 꺼지면 둘이 어긋난다.
- 저장 먼저: DB 커밋 뒤, 메시지를 보내기 전에 꺼지면 회원은 있는데 메일 작업은 영영 생기지 않는다.
- 보내기 먼저: 메시지를 보낸 뒤 DB 저장이 실패하면, 워커가 존재하지 않는 회원에게 메일을 보내러 간다.
이 문제를 이중 쓰기(dual write) 문제라고 부른다.
Outbox의 방식
- 서비스는 회원 행과 함께 '보낼 메시지' 한 줄을
outbox테이블에 같은 트랜잭션으로 넣는다. 둘은 함께 커밋되거나 함께 롤백된다. - Publisher(발행자)가
outbox에서 아직 보내지 않은 행을 읽어 큐로 보낸다. - 보낸 뒤 그 행을 '보냄'으로 표시한다. 옮기다 꺼져도 행이 남아 있어 다시 보낸다.
이제 '회원은 있는데 메시지는 사라지는' 경우는 생기지 않는다. 대신 같은 메시지가 두 번 갈 수 있다. 큐에 보낸 직후, '보냄' 표시를 커밋하기 전에 Publisher가 꺼지면 다음 실행에서 같은 행을 다시 보내기 때문이다. 그래서 받는 쪽은 메시지 ID로 이미 처리한 것을 거르는 식으로 중복을 견뎌야 한다. 이렇게 '최소 한 번은 전달되지만 중복될 수 있는' 보장을 at-least-once 전달이라고 한다.
참고: Pattern: Transactional outbox
Outbox 테이블을 큐로 쓰는 변형
별도 메시지 큐 없이, outbox 테이블 자체를 작업 큐로 쓸 수도 있다. 워커가 Publisher를 거치지 않고 그 테이블에서 바로 작업을 꺼내는 방식이다. 테이블에서 작업을 안전하게 꺼내는 쿼리는 SELECT FOR UPDATE SKIP LOCKED에 정리했다.
- 할 일과 진행 상태(
PENDING,RUNNING,DONE,FAILED)가 한 행에만 있다. - outbox에서 큐로 옮기는 단계가 없으니, 옮기다 생기는 중복이나 '보냄 표시와 큐 내용이 서로 다른' 어긋남도 없다.
- 대신 워커가 DB를 계속 폴링(polling, 주기적으로 새 행이 있는지 조회)하므로, 작업량이 아주 많으면 DB 부하가 커진다.
서비스 하나에 작업량이 많지 않다면 이 변형만으로 충분하다. 별도 메시지 큐는 띄우고 운영할 인프라가 하나 늘어나는 일이라, 워커 수나 초당 작업 수 같은 옮길 기준을 먼저 정하고 부하 테스트로 비교한 뒤 도입하는 편이 판단 근거가 분명하다.
Spring에서 같은 트랜잭션으로 넣기
Spring에서는 저장 코드와 작업 등록 코드를 이벤트로 떼어 놓으면서도 같은 트랜잭션에 묶을 수 있다. @EventListener는 기본적으로 이벤트를 발행한 스레드에서 바로 실행되므로, 발행한 쪽의 트랜잭션에 그대로 참여한다.
data class MemberJoinedEvent(val memberId: Long)
@Service
class MemberService(
private val memberRepository: MemberRepository,
private val events: ApplicationEventPublisher,
) {
@Transactional
fun join(email: String) {
val member = memberRepository.save(Member(email = email))
// 같은 스레드에서 리스너가 바로 실행됨; 아래 작업 행도 이 트랜잭션에 포함됨
events.publishEvent(MemberJoinedEvent(member.id!!))
}
}
@Component
class WelcomeMailEnqueuer(private val jobRepository: JobRepository) {
// 회원과 작업 행이 함께 커밋됨; 커밋 직후 서버가 꺼져도 할 일이 남음
@EventListener
fun onMemberJoined(event: MemberJoinedEvent) {
jobRepository.save(Job(type = JobType.WELCOME_MAIL, targetId = event.memberId))
}
}@TransactionalEventListener를 쓰면 기본값으로 커밋이 끝난 뒤(AFTER_COMMIT)에 리스너가 실행된다. 이때 리스너가 DB에 쓰는 작업 행은 회원 저장과 다른 트랜잭션이 되고, 커밋과 리스너 실행 사이에 서버가 꺼지면 작업 행이 생기지 않는다. outbox 행을 넣는 용도라면 같은 트랜잭션에 참여하는 @EventListener를 쓴다.
공식 문서: Spring Framework, Transaction-bound Events
트랜잭션을 거는 방법은 @Transactional과 트랜잭션 범위에서, 큐에서 꺼낸 작업이 실패했을 때 다시 시도하는 구조는 Dead Letter와 재시도 패턴에서 다뤘다.