gofillus 기술·운영 저널

Spring 트랜잭션과 Kafka 이벤트를 안전하게 연결하는 방법

영차에서 DB 변경과 Kafka 이벤트 발행이 어긋날 수 있는 지점을 확인하고, Spring의 트랜잭션 경계와 Outbox Relay로 발행 유실을 줄인 과정을 정리했다. 중복 이벤트를 멱등하게 처리하는 기준도 함께 다룬다.

배경 — DB는 저장됐는데 이벤트는 사라질 수 있다

영차는 여러 업무 영역이 이벤트로 연결되는 구조다. 한 영역에서 소속이나 상태를 바꾸면 다른 영역이 그 변경을 받아 읽기 사본을 갱신하거나 후속 작업을 실행한다.

문제는 DB 트랜잭션과 Kafka 발행이 같은 자원이 아니라는 데서 시작한다.

DB 트랜잭션       Kafka 발행
     │                │
     └──── 같은 원자 작업이 아님

DB 저장이 성공한 직후 애플리케이션이 종료되면 데이터는 남았지만 이벤트는 발행되지 않을 수 있다. 반대로 Kafka 발행이 먼저 성공한 뒤 DB 트랜잭션이 롤백되면 소비자는 존재하지 않는 변경을 받는다.

업무 데이터와 이벤트가 항상 같은 순간에 처리되어야 한다는 뜻은 아니다. 다만 어떤 이벤트는 유실되면 다음 변경으로 복구할 수 없고, 후속 서비스의 상태가 영원히 낡은 채로 남을 수 있다. 이 경우에는 “저장 성공 후 발행”이라는 설명만으로 충분하지 않다.

선택 — 이벤트를 언제 보낼 것인가

트랜잭션 안에서 바로 발행하기

가장 단순한 코드는 저장 직후 Kafka producer를 호출하는 방식이다.

@Transactional
public void changeStatus(ChangeCommand command) {
    entity.changeStatus(command.status());
    repository.save(entity);

    kafkaTemplate.send("domain.changed", Event.from(entity));
}

하지만 이 코드는 DB 커밋 전에 이벤트를 보낸다. 이후 검증 실패나 커밋 오류가 발생하면 소비자는 롤백된 변경을 받은 셈이 된다.

커밋 이후에 발행하기

Spring 이벤트를 커밋 이후에 처리하면 롤백된 데이터를 알리는 문제를 피할 수 있다.

@Transactional
public void changeStatus(ChangeCommand command) {
    entity.changeStatus(command.status());
    repository.save(entity);
    publisher.publishEvent(new StatusChanged(entity.id()));
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void publish(StatusChanged event) {
    kafkaTemplate.send("domain.changed", event);
}

이 방식은 DB 커밋 뒤에 발행한다는 점에서 더 안전하다. 그러나 커밋과 Kafka 발행 사이에 애플리케이션이 종료되면 이벤트는 사라진다. AFTER_COMMIT은 순서를 바로잡아 주지만, 발행 기록을 보존하지는 않는다.

Outbox에 먼저 기록하기

유실되면 안 되는 이벤트는 업무 데이터와 함께 Outbox 테이블에 기록한다. 실제 Kafka 발행은 별도 Relay가 맡는다.

업무 트랜잭션
   ├─ 업무 데이터 저장
   └─ Outbox 이벤트 저장
           │ 같은 DB 트랜잭션
           ▼
       Outbox Relay
           │ 재시도
           ▼
          Kafka

업무 데이터와 Outbox 기록은 같은 DB 트랜잭션에 들어간다. 둘 다 커밋되거나 둘 다 롤백된다. 애플리케이션이 커밋 직후 종료돼도 발행할 이벤트가 DB에 남아 있으므로 Relay가 다음 실행에서 다시 시도할 수 있다.

적용 — Spring Outbox Relay 만들기

이벤트 봉투를 먼저 고정한다

Outbox에 저장하는 값은 나중에 다시 발행할 수 있어야 한다. 따라서 이벤트 타입, 스키마 버전, 발생 시각, 추적 ID, 본문을 함께 저장한다.

public record OutboxMessage(
    UUID id,
    String eventType,
    int schemaVersion,
    Instant occurredAt,
    String traceId,
    String payload,
    Instant publishedAt,
    int retryCount
) {}

실제 저장 모델에서는 이벤트 처리 상태와 재시도 시각도 관리한다. 중요한 것은 Relay가 애플리케이션 메모리의 객체가 아니라 DB에 저장된 이벤트를 기준으로 동작해야 한다는 점이다.

업무 변경과 Outbox 기록을 한 트랜잭션에 넣는다

@Transactional
public void changeStatus(ChangeCommand command) {
    var entity = loadEntity(command.entityId());
    entity.changeStatus(command.status());

    var event = StatusChangedEvent.of(
        entity.id(),
        entity.status(),
        traceIdProvider.current()
    );

    outboxRepository.save(
        OutboxMessage.create("STATUS_CHANGED", event)
    );
}

이 메서드 안에서는 Kafka를 직접 호출하지 않는다. DB 트랜잭션이 성공한 뒤 Relay가 이벤트를 가져간다. 업무 요청의 응답 시간이 브로커 상태에 직접 묶이지 않는다는 장점도 있다.

Relay는 한 번에 조금씩 가져간다

@Scheduled(fixedDelay = 5000)
public void relay() {
    var messages = outboxRepository.findPending(100);

    for (var message : messages) {
        try {
            kafkaTemplate.send(
                topicOf(message.eventType()),
                message.id().toString(),
                message.payload()
            ).get();

            outboxRepository.markPublished(message.id());
        } catch (Exception e) {
            outboxRepository.markRetry(message.id(), e.getMessage());
        }
    }
}

실제 운영에서는 단순한 전체 조회를 사용하지 않는다. 미발행 상태와 다음 재시도 시각을 조건으로 잡고, 여러 인스턴스가 동시에 같은 이벤트를 가져가지 않도록 잠금이나 점유 시간을 둔다. 발행 성공 후 markPublished 전에 프로세스가 종료될 수 있으므로 같은 이벤트가 다시 발행될 가능성은 남는다.

Outbox는 발행을 “정확히 한 번” 보장하는 기능이 아니다. 일반적으로는 적어도 한 번 발행하고, 소비자가 중복을 흡수하도록 설계한다.

소비자는 이벤트 성격에 따라 멱등 처리한다

모든 이벤트를 같은 방식으로 처리하면 불필요한 저장소와 복잡성이 생긴다.

이벤트 종류소비 방식중복 처리
현재 상태 변경읽기 사본 갱신같은 키 upsert, 최신 값 우선
감사 기록사건 누적이벤트 ID 처리 이력으로 중복 제거
알림 발송외부 부수효과이벤트 ID와 발송 상태로 재발송 방지
상태 종료·파기조건부 삭제·이관조건부 작업으로 자연 멱등

현재 상태를 복제하는 소비자는 같은 이벤트를 두 번 받아도 최종 값이 같아야 한다. 감사 기록이나 알림처럼 한 번의 이벤트가 별도의 결과를 쌓는 경우에는 고유 이벤트 ID를 사용한다.

실수한 것들

AFTER_COMMIT이면 유실되지 않는다고 생각했다

커밋 이후에 실행되므로 롤백된 데이터를 발행하지 않는다는 점은 맞다. 하지만 커밋과 리스너 실행 사이에 프로세스가 종료되면 발행할 기록이 남지 않는다.

커밋 이후 이벤트는 유실되어도 다음 변경으로 복구할 수 있는 알림이나 캐시 갱신에 사용하고, 유실되면 안 되는 상태 변경은 Outbox에 기록하도록 구분했다.

Outbox를 쓰면 중복 발행이 없다고 생각했다

Relay가 Kafka 발행에는 성공했지만 성공 표시를 저장하기 전에 종료될 수 있다. 다음 실행은 같은 Outbox 메시지를 다시 발행한다.

이 문제는 Outbox의 결함이 아니라 적어도 한 번 발행하는 구조의 특성이다. 소비자에서 이벤트 ID를 확인하거나, 상태 갱신을 멱등하게 만드는 방식으로 해결해야 한다.

이벤트마다 처리 이력 테이블을 만들려고 했다

읽기 사본을 최신 값으로 덮어쓰는 이벤트는 중복 이벤트가 결과를 바꾸지 않는다. 이런 이벤트까지 모두 처리 이력을 기록하면 저장량과 정리 작업만 늘어난다.

누적 기록과 외부 발송처럼 중복 결과가 실제 문제를 만드는 이벤트만 별도 중복 제거 대상으로 두었다.

실패한 Outbox를 계속 재시도했다

브로커가 잠시 불안정한 경우에는 재시도가 도움이 된다. 하지만 잘못된 스키마나 유효하지 않은 payload는 계속 실패한다.

재시도 횟수와 다음 실행 시각을 기록하고, 일정 횟수를 넘긴 이벤트는 별도 실패 상태로 보낸다. 운영자는 실패 건수와 가장 오래 대기 중인 이벤트를 확인할 수 있어야 한다.

결과

항목직접 발행Outbox 적용 뒤
DB 롤백 후 이벤트이미 발행될 수 있음업무 데이터와 함께 롤백
커밋 직후 프로세스 종료이벤트 유실 가능Outbox 기록이 남음
Kafka 일시 장애업무 요청까지 실패 가능Relay가 재시도
중복 발행고려하지 않기 쉬움소비자 멱등 처리를 전제로 설계
실패 확인로그 검색 필요대기·재시도·실패 상태로 관찰

Outbox가 데이터베이스와 Kafka를 하나의 분산 트랜잭션으로 묶어 주는 것은 아니다. 대신 같은 DB 안에서 “업무 변경이 성공하면 발행할 기록도 반드시 남는다”는 보장을 만든다. 그 뒤의 발행과 소비는 재시도와 멱등성으로 완성한다.

남는 것

Spring의 @Transactional은 DB 작업의 원자성을 보장한다. Kafka까지 같은 원자성으로 묶어 주지는 않는다. 이벤트가 업무 데이터의 후속 알림인지, 다른 서비스의 상태를 바꾸는 핵심 신호인지 먼저 구분해야 한다.

영차에서 정한 기준은 다음과 같다.

  1. DB 커밋 전에 외부 이벤트를 보내지 않는다.
  2. 유실되면 안 되는 이벤트는 업무 변경과 함께 Outbox에 기록한다.
  3. Relay는 실패를 숨기지 않고 재시도 상태로 남긴다.
  4. 소비자는 현재 상태와 누적 사건을 서로 다른 방식으로 멱등 처리한다.
  5. 중복·지연·실패를 정상적인 분산 시스템의 상태로 취급한다.

이 구조를 적용하면 이벤트 발행 코드가 조금 늘어난다. 대신 “DB는 바뀌었는데 다른 서비스는 영원히 모르는” 구간을 찾고 줄일 수 있다. Spring에서 트랜잭션을 제대로 다룬다는 것은 @Transactional을 붙이는 데서 끝나지 않는다. 트랜잭션 밖에 있는 메시지 브로커와의 경계까지 설계해야 한다.


← 목록으로