Skip to content
Published on

메시지 큐 & 비동기 입문: Queue·Kafka·RabbitMQ·Redis·asyncio 한눈에

Authors

들어가며 — 왜 큐가 필요한가

서비스가 커지면 "지금 당장 처리하지 않아도 되는 일"이 늘어납니다. 회원가입 후 환영 메일 보내기, 업로드된 영상 인코딩하기, 결제 후 정산 집계하기. 이런 일을 요청 처리 중에 동기적으로 하면 사용자는 하염없이 기다립니다. 그래서 우리는 일을 큐에 넣고, 뒤에서 천천히 처리합니다.

메시지 큐는 이 아이디어를 시스템으로 만든 것입니다. 생산자(producer)가 메시지를 넣고, 소비자(consumer)가 꺼내 처리합니다. 이 사이에 큐가 끼어들면서 생산과 소비가 분리(decouple)되고, 속도 차이를 흡수(buffering)하며, 소비자가 죽어도 메시지가 남아 재처리(reliability)됩니다.

문제는 "메시지 큐"라는 한 단어 아래 성격이 꽤 다른 도구들이 있다는 것입니다. Kafka, RabbitMQ, Redis는 모두 큐로 불리지만 설계 철학이 다릅니다. 이 글은 이들을 하나씩 대조하고, 마지막으로 큐와 자주 헷갈리는 파이썬 asyncio까지 정리합니다.

이 개념들을 눈으로 확인하고 싶다면, 이 사이트에 새로 만든 메시지 큐 놀이터에서 인터랙티브하게 시각화해 볼 수 있습니다.

평범한 FIFO 큐부터

가장 단순한 큐는 선입선출(FIFO) 자료구조입니다. 먼저 들어간 것이 먼저 나옵니다. 프로그래밍 언어의 표준 라이브러리에도 들어 있는 그 큐입니다.

from collections import deque

q = deque()
q.append("job-1")   # enqueue
q.append("job-2")
print(q.popleft())  # dequeue -> job-1
print(q.popleft())  # dequeue -> job-2

이 큐의 핵심 성질은 세 가지입니다. 순서가 보존되고, 한 메시지는 한 소비자에게만 가며, 꺼내면 사라집니다. 개념은 명확하지만 한계도 명확합니다. 이 deque는 한 프로세스의 메모리 안에만 존재합니다. 프로세스가 죽으면 큐도 사라지고, 다른 서버의 소비자와 공유할 수도 없습니다.

분산 메시지 큐 시스템들은 바로 이 한계를 넘어섭니다. 큐를 네트워크 너머로 옮기고, 디스크에 저장하고, 여러 생산자와 소비자가 붙을 수 있게 합니다. 다만 그 방법이 저마다 다릅니다.

Kafka — 지울 수 없는 로그

Kafka는 사실 전통적인 의미의 "큐"가 아닙니다. Kafka는 **추가 전용 로그(append-only log)**입니다. 메시지를 큐에서 꺼내 없애는 것이 아니라, 로그 파일 끝에 계속 덧붙이고, 소비자는 "내가 어디까지 읽었는지"만 기억합니다.

핵심 개념을 정리하면 이렇습니다.

  • 토픽(topic)과 파티션(partition): 토픽은 메시지의 분류이고, 각 토픽은 여러 파티션으로 쪼개집니다. 파티션이 병렬성의 단위입니다.
  • 오프셋(offset): 각 파티션 안에서 메시지의 순번입니다. 소비자는 오프셋을 저장해 두고, 원하면 과거 오프셋으로 되돌아가 **재생(replay)**할 수 있습니다.
  • 컨슈머 그룹(consumer group): 같은 그룹에 속한 소비자들이 파티션을 나눠 가집니다. 파티션 하나는 그룹 안에서 한 소비자에게만 배정됩니다. 그래서 소비자를 늘리면 처리량이 늘지만, 소비자 수가 파티션 수를 넘으면 남는 소비자는 놀게 됩니다.
  • 파티션 단위 순서 보장: 순서는 파티션 안에서만 보장됩니다. 토픽 전체의 전역 순서는 보장되지 않습니다.

그림으로 보면 이렇습니다.

  토픽: orders
  ┌─────────────────────────────────────────┐
  │ 파티션 0: [o0][o1][o2][o3] ...  <- 소비자 A
  │ 파티션 1: [o0][o1][o2] ...      <- 소비자 B
  │ 파티션 2: [o0][o1][o2][o3][o4]  <- 소비자 C
  └─────────────────────────────────────────┘
     각 소비자는 자기 오프셋을 기억한다

Kafka가 강력한 이유는 로그가 남기 때문입니다. 메시지가 소비 즉시 사라지지 않으니, 새 소비자가 나중에 붙어 처음부터 다시 읽을 수 있습니다. 이벤트 소싱, 스트림 처리, 여러 시스템이 같은 이벤트를 각자 소비하는 구조에 잘 맞습니다. 대신 순서가 파티션 단위라는 점, 그리고 "같은 키는 같은 파티션으로 보내야 순서가 지켜진다"는 점을 항상 염두에 둬야 합니다.

RabbitMQ — 똑똑한 라우팅

RabbitMQ는 AMQP 프로토콜을 구현한 전통적인 메시지 브로커입니다. Kafka가 로그라면, RabbitMQ는 똑똑한 우체국입니다. 핵심은 생산자가 큐에 직접 넣지 않는다는 것입니다. 생산자는 **익스체인지(exchange)**에 메시지를 보내고, 익스체인지가 규칙에 따라 큐로 라우팅합니다.

라우팅 방식은 익스체인지 타입으로 정해집니다.

  • direct: 라우팅 키가 정확히 일치하는 큐로 보냅니다.
  • fanout: 라우팅 키를 무시하고 연결된 모든 큐로 복사해 보냅니다. 브로드캐스트입니다.
  • topic: 라우팅 키를 패턴으로 매칭합니다. 예를 들어 order.* 또는 order.# 같은 패턴으로 부분 일치를 시킵니다.

생산자, 익스체인지, 바인딩, 큐, 소비자의 관계는 이렇습니다.

  생산자 --> [익스체인지] --바인딩(라우팅 키)--> [큐] --> 소비자
                  ├── direct  : 키 정확히 일치
                  ├── fanout  : 전부 복사
                  └── topic   : 패턴 매칭 (order.*, order.#)

RabbitMQ의 또 다른 핵심은 **확인 응답(ack)**입니다. 소비자가 메시지를 처리하고 ack를 보내야 큐가 그 메시지를 지웁니다. 만약 소비자가 처리 도중 죽어서 ack를 못 보내면, 브로커는 그 메시지를 다른 소비자에게 다시 전달합니다. 덕분에 "처리 중 유실"을 막을 수 있습니다.

Kafka와의 결정적 차이는 소비 후 메시지가 사라진다는 점입니다. RabbitMQ는 과거 메시지를 되돌려 재생하는 데는 맞지 않습니다. 대신 복잡한 라우팅, 작업 분배, 우선순위 큐, 지연 큐 같은 세밀한 메시지 흐름 제어에 강합니다.

Redis — 가볍고 다재다능한 세 가지 방식

Redis는 원래 인메모리 데이터 저장소이지만, 메시징에도 널리 쓰입니다. 흥미로운 점은 Redis가 서로 다른 세 가지 메시징 방식을 제공한다는 것입니다.

1. 리스트 기반 큐 (LPUSH / BRPOP). 가장 단순한 작업 큐입니다. 한쪽에서 리스트에 밀어 넣고, 다른 쪽에서 꺼냅니다. BRPOP은 블로킹 방식이라 메시지가 없으면 소비자가 대기합니다.

# 생산자
LPUSH tasks "job-1"
LPUSH tasks "job-2"

# 소비자 (메시지가 올 때까지 최대 5초 대기)
BRPOP tasks 5

2. Pub/Sub (발행/구독). 채널에 발행하면 그 순간 구독 중인 모든 소비자에게 전달됩니다. 단, 이것은 fire-and-forget입니다. 구독자가 없으면 메시지는 그냥 사라지고, 저장되지 않습니다. 실시간 알림처럼 "지금 듣고 있는 사람에게만" 보내는 데 맞습니다.

# 구독자
SUBSCRIBE news

# 발행자 (구독 중인 모두에게 전달, 저장 안 됨)
PUBLISH news "hello"

3. Streams (스트림). 리스트의 단순함과 Kafka의 지속성·컨슈머 그룹을 절충한 것이 Redis Streams입니다. 메시지가 로그처럼 쌓이고, 컨슈머 그룹으로 나눠 소비하며, 처리 확인(ack)도 됩니다. Kafka만큼 크지 않은 규모에서 비슷한 패턴을 원할 때 유용합니다.

# 스트림에 추가
XADD mystream * event "signup" user "alice"

# 컨슈머 그룹 생성 후 읽기
XGROUP CREATE mystream g1 0
XREADGROUP GROUP g1 consumer1 COUNT 1 STREAMS mystream >

Redis의 매력은 "이미 캐시로 쓰고 있는 그 Redis"에 큐를 얹을 수 있는 가벼움입니다. 다만 세 방식의 성질이 다르므로, 유실을 허용하는지(Pub/Sub) 아닌지(리스트, Streams)를 분명히 하고 골라야 합니다.

언제 무엇을 쓰나 — 비교표

지금까지의 내용을 한 표로 정리하면 다음과 같습니다.

항목FIFO 큐KafkaRabbitMQRedis
모델인메모리 자료구조파티션 append-only 로그AMQP 브로커리스트/Pub-Sub/Streams
소비 후 메시지사라짐남음 (재생 가능)사라짐 (ack 후)방식에 따라 다름
순서 보장전체파티션 단위큐 단위리스트·Streams는 보존
라우팅없음키 → 파티션익스체인지(강력)단순
강점단순함대용량 스트림, 재생복잡한 라우팅, 작업 분배가벼움, 다목적
대표 용도프로세스 내 버퍼이벤트 소싱, 로그 파이프라인마이크로서비스 작업 큐캐시 겸용 큐, 실시간 알림

간단한 선택 가이드는 이렇습니다.

  • 한 프로세스 안의 임시 버퍼면 표준 라이브러리 큐로 충분합니다.
  • 이벤트를 오래 보관하고 여러 소비자가 재생·재처리해야 하면 Kafka입니다.
  • 복잡한 라우팅과 작업 분배, 확실한 ack 기반 처리가 필요하면 RabbitMQ입니다.
  • 이미 Redis를 쓰고 있고 가벼운 큐나 실시간 알림이면 Redis가 실용적입니다.

비동기와 큐는 다르다 — 파이썬 asyncio

여기서 자주 생기는 오해를 짚어야 합니다. "비동기 처리"와 "메시지 큐"는 종종 함께 등장하지만 같은 것이 아닙니다. 메시지 큐는 프로세스와 서버를 가로지르는 인프라이고, asyncio는 한 프로세스 안에서 동시성을 다루는 프로그래밍 모델입니다.

파이썬 asyncio의 핵심은 단일 스레드 이벤트 루프입니다. 스레드를 늘리지 않고, 하나의 스레드가 여러 작업 사이를 오가며 처리합니다. 비결은 I/O 대기 시간입니다. 네트워크 응답이나 디스크를 기다리는 동안 그 작업을 잠시 멈추고(await), 그 틈에 다른 작업을 진행합니다.

import asyncio

async def fetch(name, delay):
    print(f"start {name}")
    await asyncio.sleep(delay)   # 이 동안 다른 코루틴이 실행됨
    print(f"done {name}")
    return name

async def main():
    # 세 작업을 동시에 진행 (합산 대기 시간이 아니라 최댓값에 가깝게)
    results = await asyncio.gather(
        fetch("a", 2),
        fetch("b", 1),
        fetch("c", 3),
    )
    print(results)

asyncio.run(main())

여기서 몇 가지 용어를 정리합시다.

  • 코루틴(coroutine): async def로 정의한 함수. 중간에 멈췄다가 재개할 수 있습니다.
  • await: "여기서 기다리되, 그 사이 이벤트 루프가 다른 일을 하게 양보하라"는 지점입니다.
  • gather: 여러 코루틴을 동시에 스케줄링해 함께 진행시킵니다.

가장 중요한 구분은 **동시성(concurrency)과 병렬성(parallelism)**입니다. asyncio는 동시성을 줍니다. 여러 작업이 겹쳐 진행되지만, 실제로 같은 순간에 실행되는 것은 한 스레드뿐입니다. 반면 병렬성은 여러 CPU 코어에서 진짜로 동시에 실행되는 것입니다.

이 차이가 실무에서 의미하는 바는 분명합니다. asyncio는 I/O 바운드 작업(네트워크, 디스크, DB 대기)에 강합니다. 기다리는 시간이 많을수록 이득이 큽니다. 하지만 CPU 바운드 작업(무거운 계산)에는 도움이 안 됩니다. 계산 중에는 양보할 틈이 없어 이벤트 루프가 막혀 버립니다. CPU 바운드에는 멀티프로세싱이나 별도 워커가 필요합니다.

큐와 asyncio는 함께 쓰인다

둘이 다르다고 해서 무관한 것은 아닙니다. 오히려 함께 쓰일 때가 많습니다. 전형적인 구조는 이렇습니다. 웹 서버가 요청을 받으면 무거운 일을 직접 하지 않고 메시지 큐에 넣습니다. 그리고 별도의 워커 프로세스가 큐에서 일을 꺼내 처리하는데, 그 워커 안에서 여러 메시지를 asyncio로 동시에 다룹니다.

  요청 --> 웹 서버 --(메시지)--> [큐: Kafka/RabbitMQ/Redis]
                                      |
                                      v
                                  워커 프로세스
                                  └ asyncio 로 여러 메시지 동시 처리

즉, 큐는 "일을 나중으로, 다른 프로세스로 넘기는" 층이고, asyncio는 "한 프로세스 안에서 대기 시간을 효율적으로 쓰는" 층입니다. 층위가 다르므로 둘은 경쟁하지 않고 보완합니다.

흔한 함정들

마지막으로 실무에서 자주 걸리는 지점을 짚습니다.

  • 정확히 한 번(exactly-once)이라는 환상: 대부분의 큐는 기본적으로 "적어도 한 번(at-least-once)"을 보장합니다. 즉 같은 메시지가 두 번 올 수 있습니다. 그래서 소비자는 **멱등(idempotent)**하게, 같은 메시지를 여러 번 처리해도 결과가 같도록 설계해야 합니다.
  • 순서에 대한 과신: Kafka의 순서는 파티션 단위입니다. 전역 순서가 필요하면 파티션을 하나로 두거나 키 설계를 신중히 해야 하는데, 이는 병렬성을 희생합니다.
  • Pub/Sub에 지속성 기대: Redis Pub/Sub은 저장하지 않습니다. 놓친 메시지는 사라집니다. 유실이 문제라면 리스트나 Streams, 혹은 다른 브로커를 써야 합니다.
  • asyncio 안에서 블로킹 호출: 이벤트 루프 안에서 동기 블로킹 함수(예: 일반 time.sleep이나 블로킹 DB 드라이버)를 부르면 루프 전체가 멈춥니다. async 대응 라이브러리를 쓰거나 별도 스레드로 넘겨야 합니다.
  • 죽은 편지(dead letter) 무시: 계속 실패하는 메시지를 무한 재시도하면 큐가 막힙니다. 실패한 메시지를 따로 모으는 데드레터 큐를 두는 것이 안전합니다.

마치며

메시지 큐는 하나의 개념이 아니라 스펙트럼입니다. 프로세스 안의 단순한 FIFO 큐에서 시작해, 지울 수 없는 로그(Kafka), 똑똑한 라우팅(RabbitMQ), 가볍고 다재다능한 도구(Redis)로 이어집니다. 그리고 이들과 결이 다르지만 자주 함께 쓰이는 asyncio는 한 프로세스 안에서 대기 시간을 효율적으로 다루는 동시성 모델입니다.

핵심은 "무엇이 옳은가"가 아니라 "무엇이 이 문제에 맞는가"입니다. 재생이 필요하면 Kafka, 라우팅이 필요하면 RabbitMQ, 가벼움이 필요하면 Redis, 프로세스 내 동시성이면 asyncio. 이 대응을 머릿속에 넣어 두면 대부분의 선택이 쉬워집니다.

개념을 직접 움직여 보고 싶다면 메시지 큐 놀이터에서 각 방식이 어떻게 다르게 동작하는지 시각화해 보세요.

참고 자료