메시지 브로커 선택은 시스템의 처리량, 일관성, 장애 복구 전략을 결정한다. "비동기 처리 = Kafka" 또는 "이벤트 = RabbitMQ" 식의 단순한 선택은 운영 단계에서 비용과 장애로 돌아온다. 면접에서도 "왜 Kafka를 썼나요?", "RabbitMQ를 써본 경험은요?", "두 개를 같이 쓴다면 어디에 어떤 걸 쓰겠어요?" 같은 질문이 단골로 나온다. 이 질문에 답하려면 두 시스템이 어떤 모델을 선택했고, 그 결과 어떤 워크로드에서 빛나고 어떤 워크로드에서 망가지는지를 구조적으로 설명할 수 있어야 한다.
이 문서는 개념 비교에서 멈추지 않고, 실제 백엔드 시스템에서 발생하는 의사결정 지점, 잘못된 사용 패턴, 로컬 실습 환경, 면접 답변 프레임까지 다룬다.
RabbitMQ와 Kafka는 둘 다 "메시지를 중개한다"는 점에서 비슷해 보이지만, 출발점이 다르다.
RabbitMQ는 메시지 브로커(Smart Broker / Dumb Consumer)다. AMQP 0-9-1 프로토콜을 기반으로 시작했고, 브로커가 라우팅, 필터링, 큐잉, 재시도, dead-letter 처리 같은 복잡한 라우팅 의사결정을 모두 담당한다. 컨슈머는 단순히 큐에서 메시지를 꺼내 ACK만 보낸다. 메시지는 컨슈머가 ACK를 보내는 순간 사라지는 것이 기본 모델이다.
Kafka는 분산 커밋 로그(Dumb Broker / Smart Consumer)다. 브로커는 파티션이라는 append-only 로그에 메시지를 쌓아둘 뿐, 어떤 컨슈머가 어디까지 읽었는지 신경 쓰지 않는다. 오프셋 관리는 컨슈머의 책임이고, 메시지는 retention 정책이 만료될 때까지 디스크에 그대로 남는다. 동일 메시지를 여러 컨슈머 그룹이 다른 속도로 다른 시점에 다시 읽을 수 있다.
이 한 줄 차이가 처리량, 순서 보장, 재처리, 라우팅 유연성 모든 곳에서 갈라진다.
Producer → Exchange(라우팅 규칙) → Queue → Consumer
direct, topic, fanout, headersProducer → Topic(Partition 0..N) → Consumer Group(각자 offset 보유)
log.retention.hours, log.retention.bytes) 만료 전까지 보존이 모델 차이로 인해 Kafka는 "이벤트 소싱", "스트림 재처리", "로그 수집" 같은 워크로드에 자연스럽고, RabbitMQ는 "작업 큐", "RPC 응답 큐", "복잡한 라우팅 토폴로지"에 자연스럽다.
| 항목 | RabbitMQ | Kafka |
|---|---|---|
| 단건 지연(latency) | 매우 낮음 (수 ms) | 낮음 (배치 가능, 수~수십 ms) |
| 처리량(throughput) | 수만 msg/s 수준 | 수십만~수백만 msg/s |
| 메시지 크기 | 작은 메시지에 유리 | 작은~중간, 배치 압축 활용 |
| 메시지 순서 | 큐 단위로 단일 컨슈머 시 보장 | Partition 단위 보장 |
| 메시지 보존 | ACK 즉시 삭제 (기본) | retention 기간 내 보존 |
Kafka의 압도적 처리량은 sequential disk write + zero-copy(sendfile) + 배치 + 압축의 조합에서 나온다. RabbitMQ는 큐 별로 별도 erlang 프로세스가 돌고 메시지를 하나씩 ack 처리하는 모델이라, 큐 하나당 단일 컨슈머의 처리량 천장이 비교적 낮다. 대신 라우팅과 ack 의미론은 훨씬 풍부하다.
acks=all + min.insync.replicas로 손실 없음 보장여기서 중요한 차이: RabbitMQ의 ack는 "이 메시지 처리 끝"이라는 단건 시그널이고, Kafka의 commit은 "이 offset 이전까지 다 처리했다"는 누적 시그널이다. 그래서 Kafka에서 메시지 한 건만 실패해서 건너뛰고 싶다면 retry topic을 거쳐 별도 흐름으로 분리하는 것이 정공법이다. 큐에서 단건만 골라서 거부하는 일이 RabbitMQ만큼 자연스럽지 않다.
RabbitMQ의 강력함은 라우팅에 있다.
direct: routing key가 정확히 일치하는 큐로topic: order.created.kr, order.*.kr, order.created.# 같은 패턴 매칭fanout: 바인딩된 모든 큐로 복제headers: 메시지 헤더 기반 매칭이런 라우팅은 Kafka에서 흉내 내려면 별도 Topic을 더 만들거나 Kafka Streams로 분기 처리를 해야 한다. 도메인 이벤트가 다양한 컨슈머 그룹에 다른 조건으로 흩어져야 한다면 RabbitMQ의 exchange 모델이 코드량을 크게 줄여준다.
반대로 "한 토픽에 들어온 모든 이벤트를 N개의 독립 시스템이 각자 자기 시점에 읽고 다시 읽을 수도 있어야 한다"는 요구사항이라면 Kafka가 자연스럽다.
x-delayed-message plugin)실제 시스템에서는 한쪽만 쓰는 경우가 드물다. 흔한 조합:
// 결제 작업을 Kafka로 던지고, 컨슈머에서 처리 실패 시 그냥 throw
@KafkaListener(topics = "payment-jobs")
public void process(PaymentJob job) {
paymentService.charge(job); // 여기서 예외가 나면?
}
문제점:
@RetryableTopic(
attempts = "3",
backoff = @Backoff(delay = 1000, multiplier = 2.0),
dltStrategy = DltStrategy.FAIL_ON_ERROR
)
@KafkaListener(topics = "payment-jobs")
public void process(PaymentJob job) {
paymentService.charge(job);
}
@DltHandler
public void handleDlt(PaymentJob job) {
alertService.notifyOps(job);
deadLetterRepository.save(job);
}
또는 처음부터 RabbitMQ를 쓰고 DLX로 격리하는 게 운영 비용이 더 낮을 수 있다.
- 매일 1억 건 이벤트를 RabbitMQ 큐에 쌓아두고 분석 잡이 다음 날 소비
- 큐 길이가 수천만 단위로 길어지며 메모리 압박, paging
- 컨슈머 그룹 추가 시 같은 데이터를 다시 읽을 방법이 없어 fanout으로 큐를 복제 → 디스크 사용량 폭증
개선 방향: 이런 워크로드는 Kafka의 본 영역. retention만 잡아두면 컨슈머가 며칠 후 새로 합류해도 처음부터 읽을 수 있다.
producer.send(new ProducerRecord<>("user-events", event)); // key 없음
키가 없으면 라운드 로빈으로 파티션 분배 → 같은 사용자 이벤트가 다른 파티션으로 가서 컨슈머가 처리할 때 순서가 뒤집힘.
producer.send(new ProducerRecord<>("user-events", String.valueOf(event.userId()), event));
userId를 partition key로 두면 같은 사용자 이벤트는 같은 파티션 → 그 파티션을 담당하는 단일 컨슈머가 순차 처리 → 순서 보장.
docker-compose.yml로 둘 다 띄워서 직접 비교해 보는 게 가장 빠르다.
version: "3.8"
services:
rabbitmq:
image: rabbitmq:3.13-management
ports:
- "5672:5672"
- "15672:15672" # management UI
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
zookeeper:
image: confluentinc/cp-zookeeper:7.6.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
kafka:
image: confluentinc/cp-kafka:7.6.0
depends_on: [zookeeper]
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
실행:
docker compose up -d
# RabbitMQ UI: http://localhost:15672 (guest/guest)
# Kafka 토픽 생성
docker compose exec kafka kafka-topics --create \
--topic demo-events --partitions 3 --replication-factor 1 \
--bootstrap-server localhost:9092
build.gradle:
implementation 'org.springframework.boot:spring-boot-starter-amqp'
설정:
@Configuration
class RabbitConfig {
@Bean Queue paymentQueue() {
return QueueBuilder.durable("payment.jobs")
.withArgument("x-dead-letter-exchange", "payment.dlx")
.build();
}
@Bean DirectExchange dlx() { return new DirectExchange("payment.dlx"); }
@Bean Queue dlq() { return new Queue("payment.dlq"); }
@Bean Binding dlqBinding() {
return BindingBuilder.bind(dlq()).to(dlx()).with("payment.jobs");
}
}
발행 / 소비:
@Service
class PaymentPublisher {
private final RabbitTemplate template;
void publish(PaymentJob job) {
template.convertAndSend("payment.jobs", job);
}
}
@Component
class PaymentWorker {
@RabbitListener(queues = "payment.jobs")
public void handle(PaymentJob job) {
// 실패 시 예외 → 재시도 후 DLQ로 이동
paymentService.charge(job);
}
}
build.gradle:
implementation 'org.springframework.kafka:spring-kafka'
발행:
@Service
class OrderEventPublisher {
private final KafkaTemplate<String, OrderEvent> kafka;
void publish(OrderEvent e) {
kafka.send("order-events", String.valueOf(e.orderId()), e);
}
}
서로 다른 컨슈머 그룹이 같은 토픽을 독립적으로 소비:
@KafkaListener(topics = "order-events", groupId = "search-indexer")
public void index(OrderEvent e) { searchIndex.upsert(e); }
@KafkaListener(topics = "order-events", groupId = "recommendation")
public void recommend(OrderEvent e) { recommender.update(e); }
같은 메시지를 두 컨슈머 그룹이 각자 자기 offset으로 읽는다는 점이 RabbitMQ와의 결정적 차이다.
session.timeout.ms, max.poll.interval.ms 튜닝 안 하면 처리 시간이 긴 잡에서 리밸런스 반복basicQos 설정 필수면접에서 "RabbitMQ vs Kafka 어떻게 선택하시나요?" 같은 질문이 나오면 다음 순서로 풀어가는 게 깔끔하다.
이 5단계는 어떤 변형 질문(왜 Kafka를 골랐나요 / RabbitMQ로 가능한가요 / 둘 다 쓴다면)에도 그대로 적용 가능하다.
이 체크리스트를 한 줄씩 답해보면 RabbitMQ냐 Kafka냐의 선택은 자연스럽게 좁혀진다. 도구를 먼저 정하고 워크로드를 끼워 맞추는 순서를 뒤집으면 거의 항상 운영 단계에서 비용을 치른다.