Kafka를 도입한다는 것은 비즈니스 로직과 후속 처리 로직을 분리하겠다는 설계 선택이다.
이 선택은 다음과 같은 변화를 만든다.
Kafka는 기본적으로 at-least-once 전달 방식을 사용한다.
이 말은 곧,
메시지 유실 가능성을 줄이는 대신
중복 처리 책임을 Consumer에게 위임한다
는 의미다.
따라서 Kafka 기반 시스템에서는 다음과 같은 상황이 정상 동작 범위로 발생할 수 있다.
즉, Kafka를 사용하는 순간부터 시스템은
중복 이벤트를 예외가 아닌 전제 조건으로 두고 설계해야 한다.
Kafka 중복 이벤트는 성격이 다른 두 종류의 중복이 존재한다.
같은 eventId를 가진 이벤트가 여러 번 소비됨
발생 원인
이 경우는 기술적인 중복이다.
같은 이벤트가 여러 번 전달되었을 뿐,
비즈니스 의미는 동일하다.
같은 주문 / 같은 좋아요 / 같은 재고 차감이 두 번 반영됨
특징
이 중복은 단순 전달 문제가 아니라,
도메인 규칙이 깨지는 상황이다.
흔히 다음과 같은 원칙을 이야기한다.
Consumer는 항상 멱등해야 한다
이 문장은 어떤 의미를 가지고 있을까
이 로직 자체는 본질적으로 비멱등이다.
따라서 이 원칙의 정확한 의미는 다음에 가깝다.
같은 이벤트가 여러 번 전달되더라도
최종 비즈니스 상태는 한 번만 반영되어야 한다
즉,
이벤트 발행 시점에 producer가 eventId를 생성한다.
초기에는 Auto Increment ID를 고려했지만,
Outbox 패턴을 사용하면서 문제가 발생했다.
그래서 producer에서 ID를 먼저 생성하는 방식으로 전환했다.
| 항목 | UUID v4 | ULID |
|---|---|---|
| 유니크 보장 | 매우 높음 | 매우 높음 |
| 시간 정보 | 없음 | 포함 |
| 정렬 가능 | ❌ | ✅ |
| DB 인덱스 친화성 | ❌ | ✅ |
| 이벤트 흐름 추적 | 어려움 | 쉬움 |
| 표준화 | 매우 높음 | 상대적으로 낮음 |
Kafka + Outbox 환경에서는 다음 이유로 ULID가 특히 유리했다.
이벤트는 단순 식별자가 아니라,
흐름과 순서를 해석하기 위한 단서이기 때문이다.
이 방식은 다음과 같은 정합성 중심 도메인에 적합하다.
모든 중복 처리를 eventId 기준으로 할 필요는 없다.
경우에 따라 비즈니스 식별자 기준이 더 효율적일 수 있다.
예를 들어:
userId + productId 조합의 좋아요 이벤트| 기준 | eventId dedup | aggregateId 멱등 |
|---|---|---|
| 정합성 중요도 | 매우 높음 | 중간 |
| 처리 비용 | 높음 | 낮음 |
| 상태 복원 필요성 | 높음 | 낮음 |
| 대표 예시 | 주문, 결제, 재고 | 좋아요, 팔로우 |
추가로 좋아요, 조회수처럼 발생 빈도가 매우 높은 이벤트는 누적량만 업데이트 하는 이벤트를 설계할수 있다.
{ "delta": +1 }
Consumer는 단순 누적만 수행한다.
UPDATE product
SET like_count = like_count + :delta;
현재 시스템에서는:
👉 단건 이벤트 방식을 유지했다.
Outbox 패턴은 종종 중복 처리까지 해결해준다고 오해된다. 실제로 초기 Producer 에서 중복을 제거하거나 변화량을 계산해 보내준다면, Customer 에서 더 빠른 처리가 가능하지 않을까 라는 생각아래, Outbox 에서도 중복을 제거를 시도하기도 했었다.
하지만 Producer에서 Outbox의 역할은 명확하다.
중복 방지는 아니다.
때문에 Outbox는 원자성 보장이 중요하다. 하나의 트랜잭션을 사용하여 주문 내역은 저장되었는데 이벤트가 누락되거나, 반대로 주문은 실패했는데 이벤트만 발행되는 모순이 발생하지 않도록 해야한다.
@Transactional
public OrderInfo createOrder(CreateOrderCommand command) {
// 비즈니스 로직
Order order = Order.create(command.userId(), createOrderItems(command.orderItemRequests(), products), command.couponIssueId());
Order savedOrder = orderService.save(order);
// 한 트랜잭션으로 Outbox 저장
OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent(
savedOrder.getId(),
command.userId(),
command.couponIssueId(),
command.cardType(),
command.cardNo()
);
OutboxEvent savedOutboxEvent = outboxService.saveEvent(
"Order",
savedOrder.getId().toString(),
"OrderCreated",
orderCreatedEvent
);
return OrderInfo.from(savedOrder);
}
Outbox + Consumer 멱등성
좋은 Kafka 설계란
중복 이벤트가 와도 시스템이 흔들리지 않는 설계다
Kafka 중복 처리는 기술 문제가 아니라,
도메인 성격에 맞는 멱등성 전략을 선택하는 문제다.