> ## Documentation Index
> Fetch the complete documentation index at: https://blog.nvim.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Kafka와 RabbitMQ는 event를 어떻게 다르게 전달할까요?

> Spring Boot에서 Kafka와 RabbitMQ로 process 밖에 message를 보내고, ordering, retry, dead-letter, idempotency를 함께 설계해야 하는 이유를 살펴봐요.

> 결제 완료 message를 한 번 보냈는데, 배송 준비가 두 번 시작됐어요.

Producer log에는 발행 성공이 한 번만 남아 있어요. Broker에도 message 하나만 보였고요. 그런데 consumer가 처리 도중 재시작된 뒤 같은 message를 다시 받아 버렸어요.

처음 broker를 붙이면 이런 생각을 하기 쉬워요.

> “Queue에 한 번 넣었으니 consumer도 딱 한 번 처리하겠지?”

근데요, 안전한 외부 messaging은 “한 번 보냈다”만으로 완성되지 않아요. Consumer가 일을 끝냈지만 확인 신호를 보내기 전에 죽을 수 있고, network가 끊겨 producer가 성공 여부를 모를 수도 있어요. 이 불확실성 때문에 실무에서는 **다시 전달될 수 있다는 전제**로 message를 처리해요.

[앞 글](/spring-boot/application-events-internal-messaging)에서는 Spring application event가 같은 application context 안에서 component를 느슨하게 연결하지만, message를 저장하거나 process 밖으로 전달하지는 않는다고 봤어요. 이번에는 경계를 application 밖으로 넓혀 볼게요.

* Kafka의 topic과 partition은 어떤 전달 모양을 만들까요?
* RabbitMQ의 exchange와 queue는 무엇을 나눠 맡을까요?
* Message를 다시 받았는데 business 결과는 왜 한 번만 만들어야 할까요?
* Retry와 dead-letter는 실패를 어떻게 분류해야 할까요?
* “순서가 보장돼요”라는 말은 어디까지 사실일까요?

오늘 계속 붙잡을 문장은 이것이에요.

> Broker는 message를 안전하게 옮기는 기반을 주지만, **business 작업의 한 번뿐인 결과**까지 자동으로 보장하지는 않아요.

<Note title="이 글의 기준">
  예제와 Spring Boot 설정 흐름은 Spring Boot 4.x, Spring for Apache Kafka, Spring AMQP를 기준으로 작성했어요. `KafkaTemplate`, `@KafkaListener`, `RabbitTemplate`, `@RabbitListener`의 핵심 역할은 Spring Boot 3.x에서도 같은 방향으로 읽을 수 있어요. 다만 운영 중인 프로젝트에서는 사용하는 Spring Boot가 관리하는 library version과 해당 version의 공식 문서를 함께 확인하세요.
</Note>

***

## Process가 갈라지면 호출과 처리가 같은 순간에 끝나지 않아요

같은 application 안의 직접 호출은 caller와 callee가 하나의 call stack을 공유해요.

```text theme={null}
OrderService → ShippingService
```

`ShippingService`에서 예외가 나면 `OrderService`가 바로 알 수 있죠. 반면 broker를 사이에 두면 producer의 성공과 consumer의 성공이 분리돼요.

Producer가 받은 확인은 broker가 message를 받아들였다는 뜻이에요. Shipping service가 배송 준비까지 마쳤다는 뜻은 아니에요. 두 성공 사이에 시간과 장애 경계가 생긴 것이 외부 messaging의 핵심 변화예요.

이 분리는 다음 장점을 만들어요.

* Producer는 consumer의 처리 시간을 기다리지 않아도 돼요.
* Consumer가 잠시 느려져도 broker가 message를 완충할 수 있어요.
* 여러 service가 서로의 주소와 실행 시점을 직접 몰라도 돼요.
* Consumer를 독립적으로 늘리거나 점검할 수 있어요.

대신 이제는 전달 지연, 중복 delivery, 순서 변화, schema 호환성, 쌓인 message, poison message를 운영해야 해요. 결합이 사라진 게 아니라 **호출 결합이 message 계약과 운영 결합으로 바뀐 것**이에요.

***

## Kafka와 RabbitMQ는 먼저 떠올리는 중심이 달라요

Kafka와 RabbitMQ 모두 application 사이에 message를 전달할 수 있어요. 그래서 “Kafka는 event, RabbitMQ는 command”처럼 딱 잘라 외우면 실제 선택에서 자주 틀려요.

둘을 처음 구분할 때는 message가 들어온 뒤 broker가 무엇을 중심으로 관리하는지 보는 편이 좋아요.

```mermaid theme={null}
flowchart LR
    KP[Kafka producer]
    KT[Topic]
    KP0[Partition 0<br /><small>offset 0, 1, 2...</small>]
    KP1[Partition 1<br /><small>offset 0, 1, 2...</small>]
    KC[Consumer group]
    RP[RabbitMQ publisher]
    RE{Exchange<br /><small>routing rule</small>}
    RQ1[(Shipping queue)]
    RQ2[(Notification queue)]
    RC[Consumers]

    KP --> KT
    KT --> KP0
    KT --> KP1
    KP0 --> KC
    KP1 --> KC
    RP --> RE
    RE --> RQ1
    RE --> RQ2
    RQ1 --> RC
    RQ2 --> RC
```

Kafka 쪽에서는 topic 안의 partition과 consumer group의 읽기 위치가 보이고, RabbitMQ 쪽에서는 exchange가 message를 실제 consumer queue로 나누는 routing이 보여요.

### Kafka는 partition에 이어 붙는 기록을 중심으로 봐요

Kafka producer는 event를 topic의 한 partition에 append해요. Consumer group은 partition을 나눠 맡고, 각 consumer는 어디까지 읽었는지를 offset으로 추적해요.

같은 consumer group 안에서는 한 partition을 한 consumer가 맡아요. Consumer를 늘려 병렬 처리할 수 있지만, partition 수보다 consumer가 많으면 쉬는 consumer가 생길 수 있어요.

Kafka의 기록은 읽었다고 바로 사라지는 queue item과 같은 모델이 아니에요. Retention 정책 동안 record가 남기 때문에 다른 consumer group이 각자의 offset으로 다시 읽거나, 필요할 때 과거 record를 replay하는 흐름을 만들 수 있어요.

### RabbitMQ는 exchange가 queue로 routing하는 흐름을 중심으로 봐요

RabbitMQ publisher는 message를 exchange에 보내요. Exchange는 type, binding, routing key를 보고 하나 이상의 queue로 message를 routing해요. Consumer는 자신이 구독한 queue에서 delivery를 받아요.

여기서는 exchange가 “어느 queue에 보낼지”를 결정하고, queue가 consumer가 처리할 때까지 message를 보관해요. Direct, topic, fanout 같은 exchange type과 binding으로 routing 모양을 만들 수 있어요.

| 질문                        | Kafka에서 먼저 보는 것                        | RabbitMQ에서 먼저 보는 것                      |
| ------------------------- | -------------------------------------- | --------------------------------------- |
| Message가 어디에 놓이나요?        | Topic의 partition log                   | Exchange를 거쳐 queue                      |
| Consumer 진행 위치는 무엇인가요?    | Consumer group의 offset                 | Queue delivery와 acknowledgement 상태      |
| 같은 message를 여러 독립 팀이 읽나요? | Consumer group을 나눠 각자 읽어요              | 보통 목적별 queue를 따로 binding해요              |
| 과거 기록을 다시 읽고 싶나요?         | Retention 안의 record를 offset으로 replay해요 | 일반 queue는 처리 대기와 전달에 더 초점을 둬요           |
| Routing 규칙이 중요한가요?        | Topic과 key, partition 전략을 먼저 봐요        | Exchange, binding, routing key가 직접 드러나요 |
| 병렬성의 기본 단위는 무엇인가요?        | Partition                              | Queue의 consumer와 prefetch 설정            |

이 표는 승자를 고르는 점수표가 아니에요. Kafka도 여러 routing 구조를 만들 수 있고 RabbitMQ도 stream 기능을 제공해요. 처음 선택할 때 **장기 보관과 replay가 중심인지**, **유연한 routing과 작업 queue가 중심인지**를 묻는 출발점이에요.

***

## Spring Boot는 producer와 listener 기반을 자동 설정해요

Spring Boot에서 Kafka support가 classpath에 있으면 `spring.kafka.*` 설정을 바탕으로 `KafkaTemplate`과 listener container 기반을 준비해요.

주문 결제 완료 event를 보낼 때는 business 순서를 나타내는 key를 함께 고르는 것이 중요해요.

```java title="src/main/java/com/example/order/OrderPaidKafkaPublisher.java" lines theme={null}
package com.example.order;

import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;

@Component
public class OrderPaidKafkaPublisher {

    private final KafkaTemplate<String, OrderPaidMessage> kafkaTemplate;

    public OrderPaidKafkaPublisher(
            KafkaTemplate<String, OrderPaidMessage> kafkaTemplate
    ) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publish(OrderPaidMessage message) {
        this.kafkaTemplate.send(
                "orders.paid",
                message.orderId().toString(),
                message);
    }
}
```

여기서 `orderId`를 key로 사용하면 같은 주문의 record가 같은 partition으로 가도록 만드는 기준이 돼요. 실제로 `OrderPaidMessage`를 전송하려면 사용하는 serializer와 consumer deserializer, trusted type 범위, 실패 처리도 설정해야 해요.

Consumer는 `@KafkaListener`로 listener endpoint를 만들 수 있어요.

```java title="src/main/java/com/example/shipping/ShippingKafkaListener.java" lines theme={null}
package com.example.shipping;

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

import com.example.order.OrderPaidMessage;

@Component
public class ShippingKafkaListener {

    private final ShippingService shippingService;

    public ShippingKafkaListener(ShippingService shippingService) {
        this.shippingService = shippingService;
    }

    @KafkaListener(
            topics = "orders.paid",
            groupId = "shipping-service"
    )
    public void on(OrderPaidMessage message) {
        this.shippingService.prepare(
                message.eventId(),
                message.orderId());
    }
}
```

Spring Boot는 설정을 읽어 listener container를 준비하고, container가 Kafka consumer poll과 listener 호출을 관리해요. 개발자가 작성한 method가 broker를 직접 polling하는 것은 아니에요.

RabbitMQ support가 classpath에 있으면 Spring Boot는 connection factory, `RabbitTemplate`, listener container 기반을 자동 설정해요. Publisher는 exchange와 routing key를 지정해 message를 보낼 수 있어요.

```java title="src/main/java/com/example/order/OrderPaidRabbitPublisher.java" lines theme={null}
package com.example.order;

import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;

@Component
public class OrderPaidRabbitPublisher {

    private final RabbitTemplate rabbitTemplate;

    public OrderPaidRabbitPublisher(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void publish(OrderPaidMessage message) {
        this.rabbitTemplate.convertAndSend(
                "orders",
                "orders.paid",
                message);
    }
}
```

Rabbit consumer는 queue를 구독해요.

```java title="src/main/java/com/example/shipping/ShippingRabbitListener.java" lines theme={null}
package com.example.shipping;

import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

import com.example.order.OrderPaidMessage;

@Component
public class ShippingRabbitListener {

    private final ShippingService shippingService;

    public ShippingRabbitListener(ShippingService shippingService) {
        this.shippingService = shippingService;
    }

    @RabbitListener(queues = "shipping.orders.paid")
    public void on(OrderPaidMessage message) {
        this.shippingService.prepare(
                message.eventId(),
                message.orderId());
    }
}
```

여기서 publisher가 `shipping.orders.paid` queue 이름을 몰라도 된다는 점이 중요해요. Publisher는 `orders` exchange에 `orders.paid` routing key로 보냈고, broker의 binding이 shipping queue와 notification queue 같은 실제 목적지를 결정해요.

<Warning title="Java 객체가 자동으로 안전한 message 계약이 되지는 않아요">
  Producer와 consumer가 같은 class를 복사해 쓴다고 schema 호환성이 보장되는 것은 아니에요. JSON, Avro, Protobuf처럼 wire format을 정하고, field 추가·삭제 규칙과 schema version, unknown field 처리, 민감정보 범위를 함께 관리해야 해요.
</Warning>

***

## Message는 식별 가능한 과거 사실로 만들어요

Process 밖으로 나가는 event에는 consumer가 중복과 schema를 판단할 수 있는 정보가 필요해요.

```java title="src/main/java/com/example/order/OrderPaidMessage.java" lines theme={null}
package com.example.order;

import java.time.Instant;
import java.util.UUID;

public record OrderPaidMessage(
        UUID eventId,
        long orderId,
        long paymentId,
        Instant occurredAt,
        int schemaVersion
) {
}
```

각 field에는 역할이 있어요.

| Field           | 필요한 이유                                   |
| --------------- | ---------------------------------------- |
| `eventId`       | 같은 event가 다시 왔는지 식별해요                    |
| `orderId`       | 같은 주문의 partition key나 business 조회 기준이 돼요 |
| `paymentId`     | 결제라는 원인 사실을 식별해요                         |
| `occurredAt`    | 처리 시각이 아니라 사건이 발생한 시각을 남겨요               |
| `schemaVersion` | Consumer가 해석할 수 있는 계약인지 판단할 단서가 돼요       |

Event payload에는 consumer가 당시 사실을 처리하는 데 필요한 값만 넣어요. JPA Entity 전체를 serialize하거나 access token과 password 같은 secret을 넣으면 persistence model과 보안 범위가 service 밖으로 퍼져요.

이름도 이미 일어난 사실로 지어요.

```text theme={null}
좋은 event 이름: OrderPaid, ShipmentPrepared
명령에 가까운 이름: PrepareShipment, SendEmail
```

`OrderPaid`는 여러 consumer가 각자 반응할 수 있는 사실이에요. 반면 `PrepareShipment`는 특정 receiver에게 원하는 행동을 전달하는 command에 가까워요. 둘 다 message가 될 수 있지만, 이름이 의도를 드러내야 retry와 책임 범위를 판단하기 쉬워요.

***

## 왜 같은 message를 다시 받을까요?

Consumer가 message를 받은 뒤 DB에 배송 정보를 저장한다고 해 볼게요.

```mermaid theme={null}
sequenceDiagram
    participant B as Broker
    participant C as Shipping consumer
    participant D as Shipping DB

    B->>C: OrderPaid 전달
    C->>D: 배송 준비 저장
    D-->>C: commit 성공
    Note over C: acknowledgement 전 process 종료
    B->>C: OrderPaid 다시 전달
    C->>D: 같은 배송 준비 다시 저장
```

첫 처리는 DB까지 성공했지만 broker는 처리 완료를 확인하지 못했어요. 안전하게 버리지 않으려면 다시 전달하는 쪽을 택하게 돼요.

Kafka에서는 listener container가 record 처리와 offset commit을 연결해요. RabbitMQ에서는 consumer acknowledgement가 delivery 완료를 알려요. 세부 설정은 다르지만 둘 다 **business side effect 완료와 broker 진행 표시 사이에 빈틈**이 생길 수 있어요.

그래서 흔히 사용하는 at-least-once 전달은 이렇게 읽어야 해요.

> Message를 잃지 않기 위해 한 번 이상 전달될 수 있어요. 따라서 consumer는 중복 delivery를 견뎌야 해요.

Kafka의 exactly-once 기능도 모든 외부 business 결과를 자동으로 한 번만 만들지는 않아요. Kafka record를 읽고 다른 Kafka topic에 쓰는 경계와, 일반 DB 변경이나 email·결제 API 호출의 경계는 같지 않기 때문이에요.

***

## Idempotency는 중복 요청이 같은 결과 하나로 모이게 만들어요

멱등성(idempotency)은 같은 작업을 여러 번 시도해도 business 결과가 한 번 적용된 것과 같게 만드는 성질이에요.

가장 단순한 방법 중 하나는 처리한 `eventId`를 consumer DB에 기록하고 unique constraint로 중복을 막는 것이에요.

```sql theme={null}
create table processed_event (
    consumer_name varchar(100) not null,
    event_id uuid not null,
    processed_at timestamp with time zone not null,
    primary key (consumer_name, event_id)
);
```

중요한 점은 중복 표시와 business 변경을 같은 local transaction에 넣는 것이에요.

```java theme={null}
@Transactional
public void prepare(UUID eventId, long orderId) {
    boolean firstDelivery = this.processedEventRepository.tryInsert(
            "shipping-service",
            eventId);

    if (!firstDelivery) {
        return;
    }

    this.shipmentRepository.createFor(orderId);
}
```

`tryInsert`와 배송 저장이 따로 commit되면 또 다른 빈틈이 생겨요. 중복 표시는 저장됐는데 배송 저장이 rollback되면, 다음 delivery를 이미 처리한 event로 오해할 수 있죠.

상황에 따라 event table 대신 business unique key를 직접 사용할 수도 있어요.

```text theme={null}
한 주문에는 배송 준비가 하나뿐이에요
→ shipment.order_id에 unique constraint
```

이 방식은 기술적인 delivery 횟수보다 business 규칙을 직접 보호한다는 장점이 있어요. 외부 결제 API처럼 local transaction으로 묶을 수 없는 호출은 상대 API가 제공하는 idempotency key와 상태 조회 기능까지 함께 사용해야 해요.

<Tip title="Idempotency를 `if (exists)` 한 줄로 끝내지 마세요">
  조회 뒤 insert 사이에는 다른 consumer가 들어올 수 있어요. Unique constraint, atomic insert, transaction처럼 동시에 실행돼도 하나만 성공하게 만드는 DB 경계가 필요해요.
</Tip>

***

## Producer 쪽에도 DB와 message 발행 사이의 빈틈이 있어요

Consumer만 중복을 처리하면 끝일까요? Producer가 주문 DB를 commit하고 broker에 message를 보내는 사이에도 장애가 날 수 있어요.

### DB를 먼저 commit하면

```text theme={null}
주문 결제 상태 commit → process 종료 → message 미발행
```

DB에는 결제 완료인데 배송 service는 영원히 모를 수 있어요.

### Message를 먼저 보내면

```text theme={null}
message 발행 → 주문 transaction rollback
```

Consumer는 실제로 존재하지 않는 결제 완료를 처리할 수 있어요.

서로 다른 system의 transaction을 평범한 `@Transactional` 하나로 묶을 수는 없어요. 이 빈틈을 줄이는 대표적인 방법이 transaction outbox예요.

```mermaid theme={null}
flowchart LR
    A[Order transaction]
    O[(Order table)]
    X[(Outbox table)]
    R[Outbox relay]
    B[Broker]
    C[Consumer]

    A -->|같이 commit| O
    A -->|같이 commit| X
    X --> R
    R -->|재시도 가능한 발행| B
    B --> C
```

Order 변경과 발행할 message를 같은 DB transaction에 저장하고, 별도 relay가 outbox를 읽어 broker에 발행해요. Relay가 같은 row를 다시 발행할 수 있으므로 consumer idempotency는 여전히 필요해요.

Outbox가 모든 팀에 무조건 필요한 것은 아니에요. Message 유실이 business에 미치는 영향, 재구성 가능성, 운영 복잡도를 보고 선택해야 해요. 다만 “DB commit과 broker send를 어느 순서로 호출할까?”만 고민하고 있다면 이미 두 system 사이의 일관성 경계를 만난 거예요.

***

## Retry는 실패를 성공으로 바꾸는 게 아니라 시간을 다시 주는 일이에요

Listener에서 예외가 났다고 모든 message를 같은 방식으로 재시도하면 문제가 더 커질 수 있어요.

| 실패 종류           | 예                                   | 보통의 대응                               |
| --------------- | ----------------------------------- | ------------------------------------ |
| 일시적인 실패         | 짧은 network 단절, DB connection 부족     | 간격을 두고 제한된 횟수만 retry해요               |
| 영구적인 message 실패 | 필수 field 누락, 해석할 수 없는 schema        | 즉시 격리하고 수정·재처리 경로를 만들어요              |
| Business 거절     | 이미 취소된 주문, 허용되지 않은 상태 전이            | 정상적인 거절 결과로 기록하고 무한 retry하지 않아요      |
| Consumer bug    | `NullPointerException`, 잘못된 mapping | 배포 수정 전 반복 실행을 제한하고 dead-letter로 보내요 |

Retry에는 backoff가 필요해요. 장애가 난 DB를 쉬지 않고 초당 수천 번 다시 호출하면 복구를 늦출 수 있어요. 횟수와 최대 대기 시간도 정해야 message 하나가 전체 처리를 막지 않아요.

Spring Kafka에서는 `DefaultErrorHandler`와 `DeadLetterPublishingRecoverer`로 실패한 record를 dead-letter topic에 보낼 수 있어요. `@RetryableTopic`은 retry topic을 거치는 non-blocking retry 흐름을 만들 수 있지만, record가 다른 topic으로 이동하므로 원래 topic의 ordering 보장을 그대로 유지할 수 없다는 tradeoff가 있어요.

Spring AMQP에서는 listener retry를 설정할 수 있고, 시도가 끝나 reject된 message는 RabbitMQ queue에 설정된 dead-letter exchange로 routing할 수 있어요. Retry가 기본적으로 항상 켜져 있다고 가정하면 안 되고, requeue 설정을 잘못 두면 같은 poison message가 끝없이 반복될 수 있어요.

Dead-letter는 쓰레기통이 아니에요. 왜 실패했는지 볼 수 있는 원본 식별자와 error 정보, 쌓인 개수에 대한 metric과 alert, 수정 뒤 안전하게 replay하는 runbook이 있어야 해요.

<Warning title="Dead-letter로 보냈다는 사실은 처리 완료가 아니에요">
  Main queue나 topic의 진행은 계속될 수 있지만 business 작업은 아직 실패한 상태예요. Dead-letter가 계속 쌓이는데 alert가 없다면 장애를 보이지 않는 곳으로 옮긴 것뿐이에요.
</Warning>

***

## “순서 보장”에는 항상 범위를 붙여야 해요

Kafka는 같은 partition 안에서 record 순서를 유지해요. Topic 전체의 모든 record가 하나의 전역 순서를 갖는다는 뜻은 아니에요.

```mermaid theme={null}
flowchart LR
    A1[order 101<br />Paid]
    A2[order 101<br />Cancelled]
    B1[order 202<br />Paid]
    P0[Partition 0]
    P1[Partition 1]

    A1 --> P0
    A2 --> P0
    B1 --> P1
```

같은 `orderId`를 key로 사용하면 같은 주문의 event가 같은 partition으로 갈 수 있어요. 대신 hot key가 생기거나 partition 수를 바꿀 때의 분배, retry topic으로 이동할 때의 순서 변화를 함께 봐야 해요.

RabbitMQ queue는 enqueue와 delivery에서 FIFO를 지향하지만, 실제 처리 완료 순서는 달라질 수 있어요.

* 같은 queue에 active consumer가 여러 개면 처리 시간 차이로 완료 순서가 바뀔 수 있어요.
* Message priority를 사용하면 높은 우선순위가 먼저 나갈 수 있어요.
* Nack과 requeue, connection 종료에 따른 redelivery가 순서를 흔들 수 있어요.
* Prefetch가 크면 한 consumer가 여러 message를 미리 받아 처리 중일 수 있어요.

따라서 설계 문서에 “순서가 보장돼요”라고만 쓰지 말고 범위를 완성해야 해요.

> 같은 `orderId`를 key로 한 record는 같은 Kafka partition 안에서 순서대로 읽어요. Consumer는 한 주문의 상태 전이를 version으로 한 번 더 검증해요.

또는 이렇게요.

> `shipping.orders` queue는 consumer concurrency를 1로 제한해 delivery 순서를 유지해요. 처리량보다 전체 순서가 더 중요한 이유를 metric으로 확인해요.

순서가 정말 business 규칙이라면 consumer도 현재 aggregate version이나 허용된 상태 전이를 검사해야 해요. Broker 순서만 믿으면 늦게 도착한 과거 event가 최신 상태를 덮을 수 있어요.

***

## Kafka와 RabbitMQ는 이 질문으로 선택해 보세요

### Kafka 쪽으로 기우는 질문

* 여러 독립 consumer group이 같은 event stream을 각자 읽어야 하나요?
* Retention 동안 과거 event를 다시 읽거나 새 consumer가 처음부터 따라와야 하나요?
* Key별 순서를 유지하면서 partition 단위로 처리량을 늘리고 싶나요?
* Stream processing과 event log가 system의 중요한 축인가요?

### RabbitMQ 쪽으로 기우는 질문

* Message를 exchange에서 여러 queue로 유연하게 routing하는 것이 핵심인가요?
* 작업 queue와 request-reply 같은 messaging pattern이 중심인가요?
* Consumer별 queue, acknowledgement, prefetch를 직접 조절하고 싶나요?
* 처리된 일반 message를 오래 replay하는 것보다 대기 작업을 안정적으로 전달하는 일이 중요한가요?

### 제품보다 먼저 답해야 하는 공통 질문

* Message를 잃어도 되는 구간이 있나요?
* 중복 delivery를 어떤 unique key와 transaction으로 막나요?
* 순서의 단위는 전체인가요, 고객인가요, 주문인가요?
* 어떤 exception을 retry하고 몇 번 뒤 격리하나요?
* Dead-letter를 누가, 어떤 절차로 다시 처리하나요?
* Producer DB 변경과 발행 사이의 빈틈은 어떻게 복구하나요?
* Schema를 누가 소유하고 어떤 변경까지 호환되나요?
* Lag, queue depth, retry, dead-letter, 처리 시간을 어디서 관측하나요?

Broker 선택보다 이 답이 먼저예요. 같은 Kafka나 RabbitMQ를 써도 이 계약이 없으면 운영 결과는 완전히 달라져요.

***

## Test도 method 호출보다 전달 경계를 증명해야 해요

Listener method를 직접 호출하는 unit test는 business logic을 빠르게 확인할 수 있어요. 하지만 broker 설정과 serialization, routing, acknowledgement, retry는 증명하지 못해요.

| 확인하려는 계약                                       | 어울리는 test                                          |
| ---------------------------------------------- | -------------------------------------------------- |
| Message payload와 key를 올바르게 만들어요                | Publisher unit test                                |
| 같은 event를 두 번 받아도 결과는 하나예요                     | Consumer idempotency integration test              |
| Kafka topic에서 listener까지 전달돼요                  | 실제 Kafka를 사용한 integration test                     |
| Rabbit exchange와 binding이 올바른 queue로 routing해요 | 실제 RabbitMQ를 사용한 integration test                  |
| 일시적 예외 뒤 정해진 횟수만 retry해요                       | Listener container와 error handler integration test |
| 시도 소진 뒤 dead-letter로 이동해요                      | Broker를 포함한 실패 경로 test                             |
| Outbox relay가 장애 뒤 미발행 row를 다시 보내요             | DB와 broker를 함께 둔 restart test                      |

Kafka와 RabbitMQ를 실제로 띄우는 integration test에는 Testcontainers를 사용할 수 있어요. 중요한 것은 container를 썼다는 사실이 아니라, 실제 broker에서 topic·exchange·queue·binding과 실패 경로가 원하는 대로 움직이는지 확인하는 것이에요.

고정된 `sleep` 뒤 결과를 확인하는 test는 느리고 흔들려요. Timeout 안에서 DB 상태, consumed message, dead-letter destination처럼 관측 가능한 결과가 생길 때까지 기다리는 방식을 사용하세요.

***

## 운영에서는 “처리됐다”보다 밀린 곳을 먼저 봐요

HTTP API는 한 요청의 latency와 error를 바로 보기 쉬워요. Messaging은 producer와 consumer가 분리돼 있어 “발행 성공”만 보면 실제 적체를 놓칠 수 있어요.

Kafka에서는 consumer lag, partition별 처리량, rebalance, retry와 DLT record 수를 봐야 해요. RabbitMQ에서는 ready message, unacknowledged message, consumer 수, redelivery, queue별 처리율과 dead-letter queue depth를 봐야 하고요.

공통으로 남길 만한 식별자는 다음과 같아요.

* `eventId`
* Message type과 schema version
* Topic·partition·offset 또는 exchange·routing key·queue
* Consumer group이나 consumer 이름
* Delivery attempt
* Business key인 `orderId`
* Trace ID와 발생·처리 시각

Payload 전체를 무조건 log에 남기지는 마세요. 개인정보와 secret이 섞일 수 있고, 큰 message가 log 비용과 검색성을 망칠 수 있어요. 식별자와 안전한 metadata로 원본을 추적할 수 있게 만드는 편이 좋아요.

운영자가 답할 수 있어야 하는 질문은 이것이에요.

> 지금 message는 발행 전, broker 대기, consumer 처리, retry, dead-letter 중 어디에 있고, business 결과는 몇 번 만들어졌나요?

## 자, 정리해볼까요?

<Info title="오늘 우리가 배운 것">
  * Broker를 사이에 두면 producer의 발행 성공과 consumer의 business 처리 성공이 서로 다른 시점과 장애 경계로 나뉘어요.
  * Kafka는 topic의 partition log와 consumer group의 offset을 중심으로 읽고, RabbitMQ는 exchange의 routing과 queue의 delivery·acknowledgement를 중심으로 읽으면 차이가 잘 보여요.
  * Spring Boot는 `KafkaTemplate`, `@KafkaListener`, `RabbitTemplate`, `@RabbitListener`가 동작할 기반을 자동 설정하지만 message 계약과 운영 정책까지 대신 결정하지는 않아요.
  * At-least-once 전달에서는 같은 message가 다시 올 수 있어요. Consumer는 `eventId`, business unique key, local transaction으로 idempotency를 만들어야 해요.
  * Producer DB 변경과 broker 발행 사이의 빈틈에는 transaction outbox 같은 durable 경계가 필요할 수 있어요.
  * Retry는 일시적 실패에 다시 시간을 주는 정책이에요. 영구 실패와 bug를 무한 반복하지 말고 dead-letter, alert, replay runbook으로 연결해야 해요.
  * Kafka의 순서는 partition 안에서 보장되고, RabbitMQ의 실제 처리 순서는 consumer concurrency, priority, redelivery, prefetch에 따라 달라질 수 있어요.
  * Broker 이름보다 유실 허용 범위, 중복 처리, 순서 단위, schema 호환성, 관측과 복구 책임을 먼저 정해야 해요.
</Info>

다음 글에서는 message처럼 요청 thread 밖에서 오래 실행되는 일을 더 넓게 볼게요. Spring Batch와 scheduling을 비교하면서 대량 작업의 chunk 처리, 실패 뒤 재시작, 중복 실행 방지, 운영 상태를 어떻게 설계하는지 이어서 살펴볼 거예요.


## Related topics

- [Spring Boot 공식 문서 - Apache Kafka Support](https://docs.spring.io/spring-boot/reference/messaging/kafka.html)
- [Spring Boot 공식 문서 - AMQP](https://docs.spring.io/spring-boot/reference/messaging/amqp.html)
- [Spring for Apache Kafka 공식 문서 - Receiving Messages](https://docs.spring.io/spring-kafka/reference/kafka/receiving-messages.html)
- [Spring for Apache Kafka 공식 문서 - Handling Exceptions](https://docs.spring.io/spring-kafka/reference/kafka/annotation-error-handling.html)
- [Spring for Apache Kafka 공식 문서 - Non-Blocking Retry](https://docs.spring.io/spring-kafka/reference/retrytopic/how-the-pattern-works.html)
- [Spring AMQP 공식 문서 - AmqpTemplate](https://docs.spring.io/spring-amqp/reference/amqp/template.html)
- [Spring AMQP 공식 문서 - Handling Exceptions](https://docs.spring.io/spring-amqp/reference/amqp/receiving-messages/async-annotation-driven/error-handling.html)
- [Apache Kafka 공식 문서 - Introduction](https://kafka.apache.org/documentation/#intro_concepts_and_terms)
- [RabbitMQ 공식 문서 - Exchanges](https://www.rabbitmq.com/docs/exchanges)
- [RabbitMQ 공식 문서 - Queues, Ordering, Acknowledgements](https://www.rabbitmq.com/docs/queues)
