Kafka vs RabbitMQ, 무엇을 언제 선택할까

결론부터 말하면, Kafka와 RabbitMQ는 더 나은 쪽을 고르는 문제가 아니다. 다루는 메시지가 “지금 누군가에게 전달할 명령”인지 “나중에 다시 읽을 기록”인지에 따라 답이 갈리는, 태생이 다른 두 도구다. 나는 채팅 서비스를 만들며 이 둘을 실제로 번갈아 붙여 봤고, 그 과정에서 처음 세운 선택을 한 번 뒤집었다. 이 글은 그 2주간의 기록이다.

설계 회의 — “요즘은 당연히 Kafka 아니에요?”

사내 고객 상담용 채팅 서비스를 새로 만드는 프로젝트였다. 구조는 단순했다. 웹소켓 서버 인스턴스가 여러 대 뜨고, 사용자는 그중 아무 서버에나 붙는다. 문제는 같은 채팅방에 있는 두 사람이 서로 다른 서버에 붙어 있을 때다. A 서버로 들어온 메시지를 B 서버에 붙은 사람에게도 보내야 하니, 서버 사이에 메시지를 전달할 브로커가 필요했다.

설계 회의에서 브로커 이야기가 나오자마자 “요즘은 당연히 Kafka 아니에요?”라는 말이 나왔다. 나도 반쯤은 동의했다. 처리량이 압도적이고, 이력이 남고, 나중에 분석 파이프라인을 붙이기도 좋다. 이력서에 한 줄 더 넣고 싶은 마음도 솔직히 없지 않았다. 그래서 첫 주는 Kafka로 PoC를 만들었다.

1주 차 — Kafka로 만들다가 막힌 세 지점

토픽 하나(chat-messages)를 만들고 파티션 키를 채팅방 ID로 잡았다. 같은 방의 메시지는 같은 파티션에 들어가니 방 안에서의 순서는 보장된다. 여기까지는 교과서대로였다. 막힌 건 그다음이었다.

첫째, 웹소켓 서버가 “자기 방 메시지만” 받을 방법이 없었다. 웹소켓 서버 A에는 방 1, 3, 7의 사용자가 붙어 있고, B에는 방 2, 3, 9의 사용자가 붙어 있다. A는 방 1, 3, 7의 메시지만 받으면 된다. 그런데 Kafka의 소비 단위는 파티션이다. 방이 수만 개인데 파티션을 방 수만큼 만들 수는 없으니, 결국 파티션 하나에 수백 개 방의 메시지가 섞여 들어온다. 웹소켓 서버는 파티션 전체를 읽은 뒤 자기 방이 아닌 메시지를 버려야 했다. 서버가 5대면 같은 메시지를 5번 읽고 4번 버리는 구조가 된다.

이걸 피하려고 웹소켓 서버마다 컨슈머 그룹을 따로 두고 모든 파티션을 구독하게 했다. 동작은 했다. 하지만 이건 브로드캐스트를 위해 Kafka의 컨슈머 그룹 모델을 거꾸로 쓰는 것에 가까웠다.

둘째, 리밸런스 순간의 정지가 채팅에서는 그대로 보였다. 웹소켓 서버 한 대를 배포로 내렸다 올리면 컨슈머 그룹 리밸런스가 일어난다. 그 몇 초 동안 파티션 할당이 재조정되면서 메시지 소비가 멈춘다. 배치 처리라면 몇 초는 아무것도 아니지만, 채팅에서는 “메시지를 보냈는데 상대방 화면에 3초 뒤에 뜬다”는 체감으로 나타났다. 협력형 리밸런스(cooperative sticky)로 바꿔 줄일 수는 있었지만, 없앨 수는 없었다.

셋째, 지연이 생각보다 컸다. 프로듀서의 linger.ms와 배치 크기를 0에 가깝게 줄였는데도 메시지 한 건이 브로커를 거쳐 다른 서버에 도착하기까지 p99 기준 수십 ms가 걸렸다. Kafka는 원래 배치로 묶어 디스크에 순차 기록하는 구조라 “한 건을 최대한 빨리”라는 요구와는 방향이 다르다. 튜닝으로 줄이는 건 도구를 원래 목적과 반대로 쓰는 일이었다.

주말에 정리해 보니 세 문제의 뿌리는 하나였다. 나는 “메시지를 쌓아 두고 나중에 읽는” 도구로 “지금 이 사람에게 즉시 꽂아 넣는” 일을 하려 하고 있었다.

근본 차이 — 삭제하는 큐 vs 보관하는 로그

이 지점에서 두 도구의 철학 차이를 다시 봤다. RabbitMQ는 전통적인 메시지 브로커다. 큐에 넣은 메시지를 소비자가 가져가면 큐에서 사라진다. 한 번 처리되면 끝인 “전달 후 삭제” 모델이다. Kafka는 브로커라기보다 분산 로그(distributed log)에 가깝다. 메시지가 소비된 뒤에도 보관 기간 동안 디스크에 그대로 남고, 소비자는 자신의 오프셋(offset)을 기준으로 같은 데이터를 몇 번이고 다시 읽을 수 있다.

구분RabbitMQKafka
모델메시지 브로커(큐)분산 로그
소비 후큐에서 삭제보관 기간 동안 유지
재처리(replay)기본 어려움오프셋으로 재소비
라우팅풍부(exchange + 바인딩)단순(파티션 키)
소비 단위큐(바인딩으로 자유롭게 구성)파티션
순서 보장큐 단위파티션 단위
강점낮은 지연(latency)높은 처리량(throughput)

내가 1주 차에 막힌 세 지점은 전부 이 표의 “소비 단위”와 “강점” 행에서 나온 것이었다. 웹소켓 서버가 자기 방만 받고 싶다는 건 라우팅 문제고, 리밸런스와 지연은 처리량 우선 설계의 대가였다.

2주 차 — RabbitMQ로 갈아타자 구조가 단순해졌다

RabbitMQ로 바꾸고 나서 가장 먼저 한 일은 토픽 익스체인지 하나를 만드는 것이었다. 라우팅 키는 room.{roomId}. 웹소켓 서버는 기동할 때 자기 전용 큐를 하나 만들고, 사용자가 방에 입장할 때마다 그 방의 라우팅 키로 바인딩을 추가한다. 방을 나가면 바인딩을 뺀다. 이제 서버 A의 큐에는 방 1, 3, 7의 메시지만 들어온다. 1주 차에 그렇게 고민하던 “자기 방만 받기”가 바인딩 한 줄로 끝났다.

// 서버 기동 시: 인스턴스 전용 큐 (서버가 내려가면 자동 삭제)
Queue queue = new Queue("ws." + instanceId, false, true, true);
amqpAdmin.declareQueue(queue);

// 사용자가 방에 입장할 때: 그 방의 라우팅 키를 내 큐에 바인딩
public void onJoin(String roomId) {
    Binding b = BindingBuilder.bind(queue)
            .to(chatExchange)
            .with("room." + roomId);
    amqpAdmin.declareBinding(b);
}

// 메시지 발행: 방 ID를 라우팅 키로
rabbitTemplate.convertAndSend("chat.exchange", "room." + roomId, message);

서버가 내려가면 전용 큐는 자동 삭제(auto-delete)되니 리밸런스 같은 건 없다. 남은 서버들은 아무 영향을 받지 않고, 새로 뜬 서버는 자기 큐를 만들어 다시 바인딩하면 된다. 배포 중에 메시지가 멈추는 구간이 사라졌다.

지연도 달라졌다. 같은 조건에서 서버 간 전달 지연이 p99 기준 한 자릿수 ms로 떨어졌다. RabbitMQ는 메시지 한 건이 들어오면 즉시 소비자에게 밀어 넣는(push) 구조라 “한 건을 최대한 빨리”에 맞는 도구였다.

운영에서 만난 함정 셋

갈아탄 뒤가 전부 순탄했던 건 아니다. RabbitMQ에는 RabbitMQ의 함정이 있었다.

큐가 쌓이면 브로커 메모리가 먼저 죽는다. 웹소켓 서버 하나가 GC로 잠깐 멈춘 사이 그 서버 큐에 메시지가 수만 건 쌓였고, 브로커 메모리 알람이 울리며 발행 자체가 막혔다(memory alarm이 걸리면 퍼블리셔가 블록된다). Kafka는 디스크에 쌓으니 소비자가 느려도 브로커는 멀쩡한데, RabbitMQ는 소비자가 느리면 브로커가 같이 아프다. 큐에 x-max-length로 상한을 두고, 오래된 메시지부터 버리게 했다. 채팅 서버 큐에 쌓인 1분 전 메시지는 어차피 이미 의미가 없었다.

prefetch를 안 잡으면 한 소비자가 다 끌어간다. 기본 설정에서는 브로커가 소비자에게 메시지를 무제한으로 밀어 넣는다. 같은 큐를 여러 스레드가 소비할 때 한 스레드가 수천 건을 쥐고 있는 동안 나머지는 놀았다. prefetch를 작게(우리는 50) 잡자 분배가 고르게 됐다.

“보냈다”와 “브로커가 받았다”는 다르다. 브로커를 재시작한 날, 재시작 직전 몇 초 사이에 보낸 메시지가 사라졌다는 문의가 왔다. convertAndSend는 기본적으로 브로커의 응답을 기다리지 않는다. 보내는 쪽은 성공한 줄 알지만 브로커가 받기 전에 커넥션이 끊기면 그 메시지는 어디에도 없다. 퍼블리셔 컨펌(publisher confirm)을 켜서 브로커의 ack를 받은 뒤에야 “보냈다”로 처리하게 바꿨고, 실시간 전달용 큐와 별개로 DB 저장 경로에는 메시지 영속화(deliveryMode = PERSISTENT)와 수동 ack를 적용했다. 채팅 화면에 뜨는 메시지 한 건은 잃어도 되지만, 상담 기록 한 건은 잃으면 안 됐기 때문이다.

# application.yml — 브로커 ack를 받을 때까지 발행 완료로 보지 않는다
spring:
  rabbitmq:
    publisher-confirm-type: correlated
    publisher-returns: true
    listener:
      simple:
        acknowledge-mode: manual   # 처리 완료 후 직접 ack
        prefetch: 50

“전달 후 삭제”는 이력이 없다는 뜻이다. 서비스 오픈 두 달 뒤 “상담 품질 분석을 위해 채팅 이력을 다시 읽고 싶다”는 요구가 왔다. RabbitMQ 큐에는 아무것도 남아 있지 않았다. 다행히 메시지를 DB에 저장하는 경로는 처음부터 따로 두었기 때문에, 분석 파이프라인은 DB 변경 이벤트를 Kafka 토픽으로 흘려보내는 방식으로 붙였다. 결과적으로 실시간 전달은 RabbitMQ, 이력 축적과 분석은 Kafka로 나뉜 구조가 됐다. 처음부터 “둘 중 하나”를 고를 문제가 아니었던 셈이다.

성능 격차는 좁혀지고 있다, 그래도 성격은 안 바뀐다

흔히 “Kafka가 빠르다”고 하지만 정확히는 측정 기준이 다르다. Kafka는 디스크 순차 기록과 배치 처리로 초당 수십만 건 규모의 처리량에 강하고, RabbitMQ는 메시지 하나하나를 빠르게 전달하는 낮은 지연에 강하다. 내 PoC에서 Kafka의 지연이 컸던 것도 Kafka가 느려서가 아니라, 처리량을 위해 설계된 구조에 지연 우선 요구를 얹었기 때문이다.

운영 난이도도 과거와 달라졌다. Kafka는 4.0부터 ZooKeeper를 완전히 제거하고 브로커 내부 쿼럼이 메타데이터를 관리하는 KRaft 모드만 지원해 구성 요소가 줄었다. RabbitMQ도 쿼럼 큐와 로그 기반 스트림을 더하며 신뢰성과 처리량을 보강했다. 둘 다 “못 하는 것”이 줄어드는 추세라, 절대 성능보다 워크로드 성격으로 판단하는 편이 안전하다.

그래서, 상황별 선택 기준

상황추천이유
대용량 이벤트·로그 수집, 분석 파이프라인Kafka처리량, 디스크 보관
이벤트 재처리·이력 보관 필요Kafka오프셋 재소비
여러 시스템이 같은 데이터를 각자 소비Kafka컨슈머 그룹별 독립 오프셋
수신자별로 다른 조건의 메시지 분배RabbitMQ익스체인지·바인딩 라우팅
실시간 알림, 채팅, 요청-응답RabbitMQ건당 낮은 지연, push 전달
작업 큐(워커 분산)RabbitMQack 기반 재전달, prefetch
단일 애플리케이션 내부의 후속 처리둘 다 과함스프링 이벤트로 충분

마지막 행이 중요하다. 단일 애플리케이션 안에서 “주문 저장 후 알림 발송” 정도의 후속 처리라면 둘 다 과하다. 그 수준에서는 스프링 기본 이벤트와 @TransactionalEventListener로 충분하고, 트랜잭션 경계만 잘 맞추면 된다. 메시지 큐는 서비스 간 통신이나 유실 방지가 필요해지는 순간 비로소 꺼내는 도구다.

다시 한다면 다르게 할 것

1주일을 Kafka PoC에 쓴 게 아깝지는 않다. 두 도구의 차이를 문서로 읽는 것과 직접 부딪히는 것은 달랐고, 그 경험 덕에 두 달 뒤 분석 요구가 왔을 때 Kafka를 어디에 붙여야 할지 바로 그릴 수 있었다.

다만 다시 한다면 설계 회의에서 “당연히 Kafka”라는 말이 나왔을 때 한 가지 질문을 먼저 던졌을 것이다. “이 메시지는 소비된 뒤에도 다시 읽어야 하나?” 답이 “아니오”면 RabbitMQ부터 검토하고, “예”면 Kafka부터 검토한다. 라우팅이 복잡하면 RabbitMQ 쪽에 무게가 실리고, 소비자가 여럿이고 각자 속도가 다르면 Kafka 쪽에 무게가 실린다. 유행이나 이력서가 아니라 메시지의 성격이 도구를 고른다. 이 한 문장을 얻는 데 2주가 걸렸다.

참고: Apache Kafka 공식 문서, RabbitMQ 공식 문서, Spring AMQP Reference

“Kafka vs RabbitMQ, 무엇을 언제 선택할까”에 대한 2개의 생각

댓글 남기기