본문으로 건너뛰기
Previous
Next
메시지 큐·Kafka·NATS 완벽 가이드 | 분산 시스템 메시징 비교

메시지 큐·Kafka·NATS 완벽 가이드 | 분산 시스템 메시징 비교

메시지 큐·Kafka·NATS 완벽 가이드 | 분산 시스템 메시징 비교

이 글의 핵심

메시지 큐(RabbitMQ, Redis), Kafka, NATS의 동작 원리와 실전 활용법. 비동기 처리, 이벤트 스트리밍, 고성능 메시징을 실무 예제로 이해하고 적용하는 완벽 가이드.

옛날에 우리 팀이 배포한 날, 결제 이벤트가 한 건 사라진 적이 있었다. 대시보드엔 “프로듀서 성공” 찍혀 있고, DB엔 기록이 없고, 유저는 돈 냈는데 포인트만 안 쌓인 상태. 밤새 로그 뒤지다 보니, 컨슈머가 메시지는 받았는데 (아직 ack 전에) OOM으로 죽었고, 그 사이 큐/브로커 쪽에선 “전달은 완료로 보이거나”, 반대로 “재시도”로 떠서 중복이 한번 더 간 건 아닌가 싸움이 난 케이스였다. 결론은 뻔하다. 딱 한 번(exactly-once)만 됩니다 같은 말, 브로커 UI에 떠도 믿지 말고, 앱 레이어에선 at-least-once가 현실이다. 다만 “중복이 올 수 있음”을 전제로 멱등 키·사이드이펙트를 짜야 한다.

RabbitMQ, Redis Queue, Kafka, NATS… 이름만 봐도 머리 아픈 건 다 똑같다. 다만 “작업을 나눠 처리한다”는 그림(메시지 큐)이랑, “이벤트를 로그처럼 쌓는다”는 그림(Kafka)이랑, “초고속 pub/sub” 그림(NATS)은 부담이 다르다. LinkedIn 쪽이 RabbitMQ에 디스크 병목 나서 커밋 로그 = Kafka 쪽으로 간 건 그래서 유명한 이야기다. 느린 소비자가 브로커에 부담을 주느냐(Kafka는 pull + 로그) vs 큐에 밀렸다 터지느냐(전통 큐) 정도의 트레이드오프로 이해하면 된다.

솔직히 말하면, exactly-once를 “시스템이 보장”한다고 쓰는 건 PR 문구에 가깝다. 프로듀서가 네트워크 끊기고 재시도하면, 컨슈머는 같은 메시지를 두 번 볼 수 있고, offset 커밋이 처리 뒤에 가면 장애 나면 다시 읽는다. 그래서 실무에선 (1) at-least-once 전달, (2) 소비 쪽 멱등, (3) 바깥 DB에 “이 주문_id 처리됨” 유니크 제약 같은 식이 정답에 가깝다. “한 번만”이 필요하다는 요구는 대부분 멱등 + 중복 키로 끝낸다. 이거 고집하다가 밤에 pager 울리는 것보다 낫다.

RabbitMQ 쪽은 AMQP, Exchange, Binding, Queue… 튜토리얼대로 basic_ack 잘 쓰고, 큐 쌓이면 메모리 터진다. Redis Queue는 LPUSH / BROP 정도로 단순하고 빠른 대신, 기본은 인메모리고 영속이 애매해서 “유실되면?”을 직접 짜야 한다. 둘 다 비슷한 질문: 소비가 끝나기 전에 프로세스가 죽으면 메시지는 다시 갈지, 갈 땐 두 번 갈지. 정답은 at-least-once + 멱등 쪽에 두는 팀이 오래 산다.

import pika
connection = pika.BlockingConnection(pika.ConnectionParameters("localhost"))
channel = connection.channel()
channel.queue_declare(queue="tasks", durable=True)
channel.basic_publish(
    exchange="",
    routing_key="tasks",
    body="Hello World",
    properties=pika.BasicProperties(delivery_mode=2),
)

def callback(ch, method, properties, body):
    print(f"Received {body}")
    ch.basic_ack(delivery_tag=method.delivery_tag)

channel.basic_consume(queue="tasks", on_message_callback=callback)
channel.start_consuming()

Kafka는 토픽·파티션·offset. 같은 user_id 키로 보내면 파티션 안에서는 순서가 잡힌다. enable_auto_commit=True 켜고 process 중에 죽으면 “어디까지 했는지”랑 “실제 부작용”이 어긋난다. 그래서 운영 잘하는 팀은 수동 커밋이거나, 처리 끝난 뒤 커밋이 가능한 구조를 찾다가 결국 “어차피 멱등”으로 돌아온다. at-least-once가 현실이라는 말, 여기서 한 번 더 나온다.

from kafka import KafkaConsumer
import json

consumer = KafkaConsumer(
    "orders",
    bootstrap_servers=["localhost:9092"],
    auto_offset_reset="earliest",
    enable_auto_commit=False,  # 처리 후에 커밋 쪽이 안전에 가깝다
    group_id="order-processor",
    value_deserializer=lambda m: json.loads(m.decode("utf-8")),
)
for message in consumer:
    try:
        process_order(message.value)
        consumer.commit()
    except Exception as e:
        # 커밋 안 하면 재시도 — 중복은 멱등으로
        log_error(e)

NATS는 가볍고 빠른 대신, 코어는 기본이 at-most-once에 가깝다는 얘기가 많다(메모리, 재시작, 네트워크). 그래서 “진짜로 못 잃는 돈”이면 JetStream 쓰고 ack 모드 읽는 쪽을 본다. JetStream 써도, 앱이 이중으로 처리하려는 건 여전히 멱등 문제다. “JetStream = 영속”이라고 착각하면 위의 결제 이야기가 또 난다.

import asyncio
from nats.aio.client import Client as NATS

async def main():
    nc = NATS()
    await nc.connect("nats://localhost:4222")

    async def message_handler(msg):
        print(f"{msg.subject}: {msg.data.decode()}")

    await nc.subscribe("orders.*", cb=message_handler)
    await nc.publish("orders.new", b"Order #123")
    await asyncio.sleep(1)
    await nc.close()

asyncio.run(main())

정리하자면, 벤치 숫자로 싸울수록 늙는다. 지연은 NATS < RabbitMQ·Kafka 대략 그런 그림이고, 처리량은 Kafka / NATS가 잘 보이는 편인데, 그거보다 “유실났을 때 누가 울 것인가”를 먼저 정하는 게 맞다. 백로그(큐 depth), 컨슈머 랙, DLQ, 파티션별 지연, 재처리율—이거 대시보드에 없으면, 메시징 쓰는 척만 하는 것이다. 하이브리드(실시간은 NATS, 작업은 Rabbit, 로그/분석은 Kafka) 괜찮다. 대신 각각에서 기대하는 전달 시맨틱을 팀이 한 문장으로 말할 수 있어야 한다. 난 그 한 문장을 “최소 한 번은 온다. 중복은 감수하고 막는다”로 맞추는 편이다.

git addgit commitgit push 끝에 npm run deploy—배포 파이프라인도 결국 “한 번만”이 아니라 재시도니까, 메시징이랑 같은 이야기다.

같이 보면 이해가 빨라지는 내부 글: C++ 메시지 큐 · RabbitMQ·Kafka #50-7, Kafka 완벽 가이드, C++ Kafka / librdkafka

키워드: 메시지큐, Kafka, NATS, RabbitMQ, Redis, 분산시스템, 마이크로서비스