Transactional Outbox in Practice: Atomic Persist and Enqueue
TOC
Overview
이전 글에서는 DB 저장과 MQ 발행을 동시에 성공시키기 어렵다는 문제를 정리했다. 이번 글에서는 그 첫 번째 해법으로 비즈니스 데이터와 Outbox row를 같은 트랜잭션에 저장하는 구조를 다룬다.
중요한 점은 application service가 Kafka를 직접 호출하지 않는다는 것이다. 이 계층은 “저장할 데이터”와 “발행할 의도”만 함께 남긴다.
Application Service의 책임
Producer 측 application service는 요청을 검증하고, 비즈니스 데이터를 여러 테이블에 저장하고, 상세 단위 이벤트를 Outbox에 적재한다.
이 모든 작업은 하나의 @Transactional 안에서 수행된다. 따라서 중간에 예외가 발생하면 비즈니스 데이터와 Outbox row가 함께 롤백된다.
@Service
@RequiredArgsConstructor
public class GitLabUserRegistrationService implements GitLabUserManagementUseCase {
private final GitLabUserRegistrationCommandPort commandPort;
private final UserRegistrationEventPublishPort publishPort;
// ...
@Override
@Transactional
public GitLabUserRegistrationResult enqueueBulkUserRegistration(GitLabUserRegistrationCommand command) {
validator.validateNoInProgressRequest(command.getToolId());
// 마스터/상세/사이클/이력 4-테이블 INSERT
GitLabUserRegistrationPersistResult persisted =
commandPort.saveUserRegistrationRequests(persistenceMapper.toPersistenceCommand(command));
// 상세 단위 이벤트 → Outbox row 적재 (같은 @Transactional 안)
publishPort.publishAll(eventMapper.toEvents(command, persisted));
return new GitLabUserRegistrationResult(persisted.getRequestId());
}
}
Outgoing Port 분리
영속 포트와 Outbox 포트는 모두 외부로 나가는 포트지만 책임이 다르다.
- 영속 포트는 비즈니스 데이터를 저장한다.
- Outbox 포트는 Kafka로 보낼 이벤트를 DB row로 저장한다.
이 분리 덕분에 application 계층은 Kafka topic, 직렬화 방식, schema registry 같은 인프라 세부사항을 알 필요가 없다.
public interface GitLabUserRegistrationCommandPort {
// 마스터/상세/사이클/이력 4-테이블 일괄 저장
GitLabUserRegistrationPersistResult saveUserRegistrationRequests(GitLabUserRegistrationPersistenceCommand cmd);
// 상태 전이 메서드(markInProgress / markSuccess / markFailure)는 3편 Consumer에서 소개한다
}
public interface UserRegistrationEventPublishPort {
// 호출자 @Transactional에 참여 — 절대 새 트랜잭션을 시작하지 않는다
void publishAll(List<UserRegistrationEvent> events);
}
Outbox Row의 의미
Outbox row에는 메시징을 위한 정보(topic, message key, payload, 발행 상태)가 저장된다. 이 row는 “이미 발행된 메시지”가 아니라 나중에 발행해야 할 의도를 표현한다.
payload는 Kafka로 흘려보낼 메시지 본문을 그대로 직렬화해 둔다. 우리는 Avro 스키마를 그대로 사용해 producer 시점에 한 번 직렬화하고, 폴러는 별도 변환 없이 그 byte를 그대로 전송한다. 트랜잭션 안에서는 schema registry 같은 외부 의존을 호출하지 않는 것이 안전하다.
주의할 점
Outbox adapter가 별도 트랜잭션을 새로 시작하면 원자성이 깨질 수 있다. producer의 트랜잭션에 참여하도록 두는 것이 핵심이다.
검증은 단순하다. 저장 직후 예외를 강제로 발생시켰을 때 비즈니스 데이터와 Outbox row가 모두 남지 않아야 한다.
@Component
@RequiredArgsConstructor
public class UserRegistrationEventPublishAdapter implements UserRegistrationEventPublishPort {
private final EventPublisher eventPublisher;
private final UserRegistrationEventAvroMapper avroMapper;
@Override
// @Transactional 없음 — 호출자(Service)의 트랜잭션에 참여한다
public void publishAll(List<UserRegistrationEvent> events) {
for (UserRegistrationEvent event : events) {
eventPublisher.publish(
event.getUserRegDmndDtlId(), // Kafka key = 상세 ID
avroMapper.toAvro(event),
Topics.GITLAB_USER_REG_COMMAND.getValue());
// EventPublisher 내부에서 entityManager.persist(OutboxEventEntity) 수행
// → 비즈니스 INSERT와 같은 커밋 단위
}
}
}
Conclusion
이 단계에서 보장되는 것은 저장과 발행 의도의 원자성이다. Kafka에 아직 메시지가 도달한 것은 아니지만, 적어도 “저장은 됐는데 발행 의도는 사라지는” 상태는 막을 수 있다.
Next Step
다음 포스트에서는 Thin Poller and At-least-once를 다룬다. DB에 남겨둔 발행 의도를 실제 Kafka 메시지로 흘려보내는 얇은 폴러의 역할을 정리한다.