Post

Kafka 도입기: ApplicationEvent에서 신뢰할 수 있는 이벤트 파이프라인으로

Kafka 도입기: ApplicationEvent에서 신뢰할 수 있는 이벤트 파이프라인으로

ApplicationEvent의 한계

지난 주 ApplicationEvent로 트랜잭션을 분리했습니다. 그런데 이 구조의 한계가 명확해졌습니다.

1. 메모리 기반 - 서버 재시작 시 유실

이벤트가 메모리에만 존재합니다. 이벤트를 발행하고 핸들러가 처리하기 전에 서버가 재시작되면 이벤트는 사라집니다.

2. 순서 보장 없음

비동기 핸들러가 여러 개일 때 실행 순서를 보장할 수 없습니다.

3. 재시도 없음

핸들러가 실패하면 이벤트가 손실됩니다. 재시도 로직을 직접 구현해야 합니다.

Kafka를 선택한 이유

메시지 브로커 선택지는 여러 가지입니다 (RabbitMQ, ActiveMQ 등). Kafka를 선택한 핵심 이유는 재생(Replay) 기능입니다.

  • 내구성: 메시지가 디스크에 저장됩니다. 컨슈머가 다운돼도 재시작 후 이어서 처리할 수 있습니다.
  • 재생: 과거 메시지를 다시 처리할 수 있습니다. 버그 수정 후 재처리, 새 서비스 투입 시 히스토리 처리에 유용합니다.
  • 순서 보장: 같은 파티션 내에서 순서가 보장됩니다.

문제: 메시지 발행과 DB 저장의 원자성

Kafka 도입 전에 해결해야 할 근본적인 문제가 있었습니다.

1
2
3
4
5
6
7
@Transactional
public void order(OrderCommand command) {
    Order order = orderService.create(command);
    // DB 트랜잭션 커밋
    // ...
    kafkaTemplate.send("order-created", event); // 여기서 실패하면?
}

DB에 주문이 저장됐는데 Kafka 발행이 실패하면 이벤트가 유실됩니다. 반대로 Kafka 발행은 됐는데 DB 커밋이 실패하면 유령 이벤트가 됩니다.

DB 트랜잭션과 Kafka 발행은 원자적으로 처리돼야 합니다. 그러나 두 시스템은 같은 트랜잭션을 공유할 수 없습니다.

해결: Transactional Outbox Pattern

핵심 아이디어: 이벤트를 Kafka에 직접 발행하지 않고, 같은 DB 트랜잭션 안에 outbox 테이블에 저장합니다.

1
2
3
4
5
6
7
8
9
10
CREATE TABLE outbox (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id BIGINT NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload JSON NOT NULL,
    created_at DATETIME NOT NULL,
    published BOOLEAN DEFAULT FALSE,
    published_at DATETIME
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Transactional
public OrderResult order(OrderCommand command) {
    // 핵심 비즈니스: 재고 차감 + 주문 생성
    productService.deductStock(command.getProductId(), command.getQuantity());
    Order order = orderService.create(command);

    // Outbox에 이벤트 저장 (같은 트랜잭션)
    outboxRepository.save(new OutboxEvent(
        "ORDER",
        order.getId(),
        "ORDER_CREATED",
        objectMapper.writeValueAsString(OrderCreatedPayload.from(order))
    ));

    // 여기까지가 하나의 트랜잭션
    return OrderResult.from(order);
}

주문 생성과 이벤트 저장이 같은 트랜잭션입니다. 주문이 롤백되면 이벤트도 롤백됩니다. 원자성이 보장됩니다.

Outbox 발행 스케줄러

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Component
@RequiredArgsConstructor
public class OutboxPublisher {

    private final OutboxRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    @Scheduled(fixedDelay = 1000) // 1초마다
    public void publishPendingEvents() {
        List<OutboxEvent> pendingEvents = outboxRepository.findByPublishedFalse();

        for (OutboxEvent event : pendingEvents) {
            try {
                // ProducerRecord에 eventId 헤더를 추가해 Consumer가 멱등성 체크에 활용합니다
                ProducerRecord<String, String> record = new ProducerRecord<>(
                    event.getEventType().toLowerCase(),
                    String.valueOf(event.getAggregateId()),
                    event.getPayload()
                );
                record.headers().add("eventId",
                    String.valueOf(event.getId()).getBytes(StandardCharsets.UTF_8));

                // 동기 전송 (.get()으로 결과 대기)
                kafkaTemplate.send(record).get(); // 발행 성공 확인

                event.markAsPublished();
                outboxRepository.save(event);
            } catch (Exception e) {
                log.error("Outbox 발행 실패: id={}", event.getId(), e);
                // 다음 스케줄에서 재시도
            }
        }
    }
}

.get()으로 동기 확인을 하는 이유가 있습니다. 발행 성공이 확인된 후에만 published = true로 표시해야 합니다. 비동기로 처리하면 발행 실패를 감지하기 어렵습니다.

Consumer: 멱등성 보장

Outbox 패턴은 At Least Once 전송을 보장합니다. 즉, 같은 메시지가 여러 번 전달될 수 있습니다. 컨슈머는 멱등하게 처리해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@KafkaListener(topics = "order_created", groupId = "coupon-service")
public void handleOrderCreated(ConsumerRecord<String, String> record) {
    String eventId = record.headers().lastHeader("eventId").value().toString();

    // 이미 처리된 이벤트인지 확인
    if (eventHandledRepository.existsByEventId(eventId)) {
        log.info("이미 처리된 이벤트: {}", eventId);
        return;
    }

    OrderCreatedPayload payload = objectMapper.readValue(
        record.value(), OrderCreatedPayload.class
    );

    // 실제 처리
    couponService.markAsUsed(payload.getCouponId());

    // 처리 완료 기록
    eventHandledRepository.save(new EventHandled(eventId));
}

event_handled 테이블에 처리한 이벤트 ID를 저장해서 중복 처리를 방지합니다.

Partition Key로 순서 보장

같은 주문의 이벤트는 같은 파티션에서 처리돼야 순서가 보장됩니다.

1
2
3
4
5
kafkaTemplate.send(
    "order-created",
    String.valueOf(order.getId()), // Partition Key = 주문 ID
    payload
);

같은 orderId를 가진 메시지는 항상 같은 파티션으로 라우팅됩니다.

DLQ (Dead Letter Queue) 처리

처리를 반복해도 실패하는 메시지는 DLQ로 보냅니다.

1
2
3
4
5
6
7
8
9
10
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<String, String> template) {
    DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(template);

    // 3번 재시도 후 DLQ로 전송
    FixedBackOff backOff = new FixedBackOff(1000L, 3);

    DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff);
    return handler;
}

DLQ에 쌓인 메시지는 별도로 모니터링하고 처리합니다.

At Least Once + Idempotency = Exactly Once Semantics

Kafka는 기본적으로 At Least Once를 보장합니다. 완벽한 Exactly Once를 달성하려면:

1
2
3
4
5
6
7
Outbox Pattern (발행 누락 없음)
    +
At Least Once (발행 중복 가능)
    +
Consumer 멱등성 (중복 처리 방지)
    =
Exactly Once Semantics

각 요소가 역할을 분담해서 전체적으로 정확히 한 번 처리를 달성합니다.

정리

ApplicationEvent의 한계를 Kafka + Transactional Outbox Pattern으로 해결했습니다.

항목ApplicationEventKafka + Outbox
내구성메모리, 재시작 시 유실디스크 저장
원자성AFTER_COMMIT으로 부분 보장DB와 같은 트랜잭션
순서 보장없음파티션 키로 보장
재시도없음자동 재시도 + DLQ
복잡도낮음높음

복잡도가 높아진 만큼 신뢰성이 올라갔습니다. 이벤트 유실이 치명적인 비즈니스 로직에는 Outbox Pattern이 적합합니다.

This post is licensed under CC BY 4.0 by the author.