..

Transactional Outbox in Practice: Idempotent Consumer

TOC


  1. Overview
  2. Consumer의 위치
  3. 멱등 상태 머신
  4. Race Condition 대응
  5. 외부 API와 상태 전이 분리
  6. Conclusion
  7. Next Step

Overview


이전 글에서 Outbox 발행은 at-least-once라고 정리했다. 즉, 같은 메시지가 두 번 도착할 수 있고 컨슈머는 이를 정상 상황으로 받아들여야 한다.

이번 글에서는 중복 메시지가 와도 비즈니스 상태가 흔들리지 않도록 만드는 멱등 컨슈머의 기준을 정리한다.

Consumer의 위치


Kafka consumer는 외부 시스템에서 들어오는 입력을 application use case로 전달하는 inbound adapter다 — 외부 메시지를 내부 유스케이스 호출로 변환하는 역할.

메시지를 역직렬화하고 use case를 호출하되, 상태 전이와 정합성 판단은 application/domain 쪽에 둔다.

@Component
@RequiredArgsConstructor
public class GitLabUserRegistrationConsumer {

    private final GitLabUserRegistrationConsumeUseCase consumeUseCase;

    @RetryableTopic(attempts = "3", backoff = @Backoff(delay = 1000, multiplier = 2.0))
    @KafkaListener(topics = "#{T(Topics).GITLAB_USER_REG_COMMAND.getValue()}")
    public void onMessage(@Payload UserRegistrationEventAvro event) {
        consumeUseCase.processRegistrationEvent(event);   // 상태 전이 책임은 UseCase에
    }

    @DltHandler
    public void onDlt(ConsumerRecord<String, UserRegistrationEventAvro> record) {
        String dtlId = record.value().getUserRegDmndDtlId().toString();
        consumeUseCase.markAsDeadLetter(dtlId, "max retries exhausted");
    }
}

멱등 상태 머신


상세 처리 상태를 WAIT → EXCN → SCS/FAIL처럼 명확한 상태 머신으로 둔다.

핵심 규칙은 단순하다. SCSFAIL은 종결 상태이며, 종결된 row는 같은 메시지가 다시 와도 더 이상 상태를 바꾸지 않는다. 상태 전이 메서드(markInProgress, markSuccess, markFailure)는 모두 종결 상태에서 들어온 호출을 silent skip하도록 짠다. 컨슈머 진입 직후 isTerminated early skip까지 더하면 같은 메시지의 재처리는 자연스럽게 흡수된다.

// 상세 처리 상태 enum
public enum GitLabUserRegistrationDetailStatus {
    WAIT, EXCN, SCS, FAIL;

    public boolean isTerminated() {
        return this == SCS || this == FAIL;
    }
}

// 엔티티 도메인 메서드 — 종결 상태에서 재전이는 silent skip
public GitLabUserRegistrationDetailStatus current() {
    return GitLabUserRegistrationDetailStatus.from(this.prgrsSttsCd);
}

public boolean isTerminated() {
    return current().isTerminated();
}

public void markInProgress() {
    if (isTerminated() || current() == EXCN) return;
    this.prgrsSttsCd = EXCN.getCode();
    this.rsn = null;
}

public void markSuccess(String msg) {
    if (isTerminated()) return;
    this.prgrsSttsCd = SCS.getCode();
    this.rsn = msg;
}

public void markFailure(String msg) {
    if (isTerminated()) return;
    this.prgrsSttsCd = FAIL.getCode();
    this.rsn = msg;
}

Race Condition 대응


중복 메시지가 거의 동시에 도착할 수 있는데, 우리는 @Version 같은 낙관락을 따로 두지 않는다. 운영 중인 테이블에 컬럼을 추가하는 DDL ALTER 비용을 피하고, 대신 다음 세 가지로 race를 흡수한다.

  1. Kafka 파티션 정합성 — 메시지 key를 상세 ID로 고정하면 같은 상세는 항상 같은 파티션, 같은 컨슈머 스레드에서 직렬로 처리된다.
  2. 컨슈머 진입 시 isTerminated early skip — 종결된 건은 비즈니스 로직 자체에 들어가지 않는다.
  3. 멱등한 상태 전이 메서드 — 들어왔다 하더라도 종결 상태에서는 no-op.

다중 인스턴스 리밸런싱 도중 짧게 겹치는 시나리오는 위 3중 가드로 흡수된다.

@Override
public void processRegistrationEvent(UserRegistrationEventAvro event) {
    GitLabUserRegistrationDetailId id = new GitLabUserRegistrationDetailId(
            event.getUserRegDmndId().toString(),
            event.getUserRegDmndDtlId().toString());

    // ① 멱등 가드 — 이미 종결된 건은 여기서 끊는다
    if (queryPort.isTerminated(id)) {
        return;
    }

    // ② EXCN 으로 전이 (REQUIRES_NEW — 짧은 트랜잭션)
    commandPort.markInProgress(id);

    try {
        // ③ 외부 API 호출 (트랜잭션 밖)
        provisionPort.registerUser(event.getToolId().toString(), event.getTargetUserId().toString());
        commandPort.markSuccess(id, "정상 등록 완료");    // REQUIRES_NEW
    } catch (Exception e) {
        commandPort.markFailure(id, e.getMessage());      // REQUIRES_NEW
    }
}

외부 API와 상태 전이 분리


외부 API 호출이 오래 걸리는 동안 DB 트랜잭션을 붙잡고 있으면 진행 상태가 보이지 않고 락 유지 시간도 길어진다.

markInProgress, markSuccess, markFailure 같은 상태 전이는 짧은 트랜잭션으로 분리하는 편이 운영상 유리하다.

// 영속 어댑터 — 상태 전이마다 REQUIRES_NEW로 별도 커밋
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markInProgress(GitLabUserRegistrationDetailId id) {
    loadDetail(id).markInProgress();         // findById → 도메인 메서드 → dirty checking flush
    refreshMasterStatus(id.getUserRegDmndId());
}

@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markSuccess(GitLabUserRegistrationDetailId id, String msg) {
    loadDetail(id).markSuccess(msg);
    refreshMasterStatus(id.getUserRegDmndId());
}

Conclusion


멱등 컨슈머의 목적은 중복을 막는 것이 아니라 중복이 와도 결과가 같게 만드는 것이다. 이 원칙이 있어야 Outbox의 at-least-once 발행을 현실적인 설계로 받아들일 수 있다.

Next Step


다음 포스트에서는 Operating in Production을 다룬다. 멀티 인스턴스, row retention, DLT 같은 운영 이슈를 정리한다.