..

Transactional Outbox in Practice: Atomic Persist and Enqueue

TOC


  1. Overview
  2. Application Service의 책임
  3. Outgoing Port 분리
  4. Outbox Row의 의미
  5. 주의할 점
  6. Conclusion
  7. Next Step

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 메시지로 흘려보내는 얇은 폴러의 역할을 정리한다.