메시징과 이벤트 깊게 보기: 큐, 로그, 전달 보장, 그리고 이벤트 기반 아키텍처
이 글은 메시징/이벤트 시리즈 1편입니다.
- 2편: Kafka vs RabbitMQ vs SQS: 세 메시징 시스템을 같은 표 위에 올려놓기
- 3편: Kafka 깊게 보기: 분산 로그, 파티션, 컨슈머 그룹, exactly-once
- 4편: RabbitMQ 깊게 보기: AMQP, exchange, ack, prefetch, DLX
서비스가 커지면 "API를 호출한다"만으로는 부족한 순간이 옵니다. 주문이 들어오면 결제도 하고, 재고도 줄이고, 알림도 보내고, 정산 로그도 남겨야 하는데, 이걸 전부 한 요청 안에서 동기로 처리하면 한 군데가 느려질 때 전체가 같이 느려지고, 한 군데가 죽으면 주문 자체가 실패합니다.
그래서 등장하는 게 메시징입니다. "주문이 일어났다"는 사실 하나를 어딘가에 던져두고, 나머지는 각자 알아서 처리하게 만드는 거죠. 이 시리즈는 그 메시징의 대표 주자인 Kafka, RabbitMQ, SQS를 비교하는 게 목표인데, 도구를 비교하기 전에 먼저 이 도구들이 공통으로 다루는 개념을 정리해야 비교가 의미를 갖습니다. 1편은 그 토대입니다.
한눈에 보면
- 메시지는 운반 단위, 이벤트는 "무언가 일어났다"는 과거 사실입니다. command/event/document로 나눠 보면 설계가 또렷해집니다.
- 메시징 모델은 크게 **point-to-point(큐)**와 publish-subscribe(토픽) 둘입니다.
- 브로커의 본질은 결합을 끊는 것입니다 — 시간적, 공간적으로 보내는 쪽과 받는 쪽을 분리합니다.
- 전달 보장은 at-most / at-least / exactly-once 세 가지이고, 현실은 대부분 at-least-once + 멱등성입니다.
- 순서와 **백프레셔(소비 지연)**는 도구 선택을 가르는 핵심 축이고, 여기서 큐 vs 로그라는 근본 차이가 나옵니다.
- 이벤트 기반 아키텍처에는 notification / state transfer / event sourcing / CQRS 같은 결이 다른 패턴들이 있습니다.
메시지와 이벤트는 같은 말이 아니다
흔히 섞어 쓰지만, 구분하면 설계가 분명해집니다. 메시지는 전송되는 데이터 한 덩어리이고, 그 메시지가 담는 의도에 따라 보통 세 가지로 나눕니다.
- Command(명령): "이걸 해라". 받는 쪽이 정해져 있고, 행동을 요청합니다. 예:
ChargePayment - Event(이벤트): "이게 일어났다". 이미 벌어진 과거의 사실이고, 누가 받을지는 보내는 쪽이 신경 쓰지 않습니다. 예:
OrderPlaced - Document(문서): 그냥 데이터 전달. 의도는 약하고 상태나 결과를 옮깁니다.
이 중 이벤트 기반 아키텍처의 주인공은 당연히 이벤트입니다. 이벤트의 성질을 정확히 잡는 게 중요합니다.
- 과거형이고 불변이다.
OrderPlaced는 이미 일어난 일이라 취소하거나 수정할 수 없습니다. 잘못됐으면OrderCancelled라는 새 이벤트를 또 발행합니다. - 발행자는 소비자를 모른다. 주문 서비스는 "주문이 일어났다"만 알리고, 그걸 결제가 듣든 알림이 듣든 아무도 안 듣든 상관하지 않습니다. 이 무관심이 곧 느슨한 결합입니다.
Command와 Event의 차이가 사소해 보이지만 결합도를 가릅니다. ChargePayment(명령)를 보내는 주문 서비스는 "결제 서비스가 존재하고 이 일을 해야 한다"를 압니다. OrderPlaced(이벤트)를 발행하는 주문 서비스는 그걸 모릅니다. 후자가 더 느슨하고, 그래서 확장에 강합니다. 새 소비자를 붙일 때 발행자를 건드리지 않아도 되니까요.
두 가지 메시징 모델
브로커가 메시지를 전달하는 방식은 근본적으로 두 가지입니다.
flowchart LR
subgraph P2P["Point-to-Point (Queue)"]
Pp[Producer] --> Q[(Queue)]
Q --> C1[Consumer A]
Q -. 경쟁 .-> C2[Consumer B]
end
subgraph PS["Publish-Subscribe (Topic)"]
Pp2[Producer] --> T((Topic))
T --> S1[Subscriber A]
T --> S2[Subscriber B]
T --> S3[Subscriber C]
end- Point-to-point(큐): 메시지 하나는 딱 한 소비자가 가져갑니다. 소비자가 여럿이면 서로 경쟁(competing consumers)해서 일을 나눠 갖습니다. 작업 분배에 적합합니다. "이메일 발송 작업을 워커 5대가 나눠서 처리" 같은 그림이죠.
- Publish-subscribe(토픽): 메시지 하나가 구독한 모든 소비자에게 전달됩니다. 같은
OrderPlaced하나를 결제·알림·정산이 각자 한 부씩 받습니다. 이벤트 브로드캐스트에 적합합니다.
여기서 미리 짚어둘 게 있습니다. RabbitMQ와 SQS는 출발이 큐(작업 분배)에 가깝고, Kafka는 출발이 로그/구독(여러 소비자가 각자 읽기)에 가깝습니다. 이 출발점 차이가 2편 비교의 뼈대가 됩니다.
브로커는 왜 필요한가: 결합을 끊는다
보내는 쪽이 받는 쪽을 직접 호출하면 둘은 단단히 묶입니다. 받는 쪽이 죽어 있으면 보내는 쪽도 실패하고, 받는 쪽이 느리면 보내는 쪽도 느려집니다. 브로커는 그 사이에 끼어 세 가지 결합을 끊습니다.
- 시간적 분리: 소비자가 지금 살아 있지 않아도 됩니다. 메시지는 브로커에 쌓여 있다가 소비자가 돌아오면 처리됩니다.
- 공간적 분리: 발행자는 소비자가 어디 몇 개 있는지 몰라도 됩니다.
- 속도 분리(버퍼링): 생산 속도와 소비 속도가 달라도 됩니다. 트래픽이 튀면 브로커가 완충 역할을 합니다.
이 마지막이 **백프레셔(backpressure)**와 연결됩니다. 초당 1만 건이 들어오는데 소비자가 초당 1천 건만 처리할 수 있다면, 동기 호출에서는 시스템이 무너집니다. 브로커가 있으면 그 차이가 큐 길이(또는 consumer lag)로 쌓일 뿐, 시스템은 버팁니다. 대신 "쌓인 게 줄지 않으면 결국 터진다"는 신호를 그 길이로 읽어야 합니다. 메시징을 도입한다고 처리 용량이 늘어나는 건 아닙니다. 갑작스러운 부하를 시간 위로 펴주는 것이지, 평균 처리량 부족을 해결해주지는 않습니다.
전달 보장: 세 가지 의미
메시징에서 가장 많이 오해받는 주제입니다. "메시지가 잘 전달된다"는 사실 세 가지로 갈립니다.
| 보장 수준 | 의미 | 위험 |
|---|---|---|
| at-most-once | 많아야 한 번 (중복 없음) | 유실 가능 |
| at-least-once | 적어도 한 번 (유실 없음) | 중복 가능 |
| exactly-once | 정확히 한 번 | 구현이 어렵고 비쌈 |
왜 exactly-once가 어려운지는 한 장면이면 됩니다. 소비자가 메시지를 처리한 뒤 "처리했음(ack)"을 브로커에 알리기 직전에 죽었다고 해봅시다. 브로커 입장에서는 처리가 끝났는지 알 수 없습니다.
- 안전하게 다시 보내면 → 이미 처리됐을 수도 있으니 중복(at-least-once)
- 안전하게 안 보내면 → 처리 안 됐을 수도 있으니 유실(at-most-once)
네트워크가 끊길 수 있는 분산 환경에서 "정확히 한 번"은 공짜로 얻을 수 없습니다. 그래서 현실의 답은 대부분 이렇습니다.
at-least-once로 받고(유실은 막고), 소비자를 멱등하게 만들어 중복을 흡수한다.
멱등성(idempotency)은 "같은 메시지를 두 번 처리해도 결과가 같다"는 성질입니다. 예를 들어 결제라면, 메시지에 paymentId를 싣고 "이 id를 이미 처리했으면 건너뛴다"를 보장하면 됩니다.
처리 전: SELECT 1 FROM processed WHERE id = :paymentId
있으면: 이미 처리됨 → skip (ack만)
없으면: 결제 수행 + INSERT processed(id) 를 같은 트랜잭션으로이 패턴(메시지 id + 처리 기록 테이블)은 어떤 브로커를 쓰든 백엔드에서 거의 항상 등장합니다. 실제로 던플 시세 수집 파이프라인에서도 SQS 메시지를 멱등 워커로 처리해서 중복 실행을 흡수했습니다. Kafka가 말하는 "exactly-once"도 마법이 아니라, 멱등 프로듀서와 트랜잭션으로 이 문제를 특정 범위 안에서 해결한 것입니다(3편에서 다룹니다).
순서: 어디까지 보장되나
"보낸 순서대로 처리된다"는 직관은 분산 시스템에서 쉽게 깨집니다. 소비자가 여럿이고 각자 다른 속도로 처리하면, A가 먼저 와도 B가 먼저 끝날 수 있습니다.
그래서 메시징 시스템은 보통 전역 순서가 아니라 부분 순서를 보장합니다.
- Kafka는 파티션 단위로만 순서를 보장합니다. 같은 키(예: 같은
userId)를 같은 파티션으로 보내면 그 사용자의 이벤트는 순서가 지켜집니다. 파티션이 다르면 전역 순서는 없습니다. - RabbitMQ는 단일 큐 + 단일 소비자면 순서가 지켜지지만, 소비자를 늘려 병렬 처리하는 순간 순서 보장이 약해집니다.
- SQS는 Standard 큐가 순서를 보장하지 않고, FIFO 큐가 메시지 그룹 단위로 순서를 보장합니다.
여기서 설계 원칙 하나가 나옵니다. 순서가 필요한 단위로 키를 잡아라. 전체 순서가 필요한 경우는 생각보다 드뭅니다. 보통 "한 사용자 안에서", "한 주문 안에서" 순서면 충분하고, 그 단위를 파티션 키나 메시지 그룹으로 매핑하면 순서와 병렬성을 동시에 얻습니다.
큐와 로그: 가장 근본적인 갈림길
지금까지의 개념을 한 곳으로 모으는 질문이 있습니다. 소비된 메시지는 사라지는가, 남는가?
flowchart TB
subgraph Queue["큐 모델 (RabbitMQ, SQS)"]
direction LR
q1[msg] --> q2[msg] --> q3[msg]
q3 --> cons[Consumer]
cons -. ack 후 삭제 .-> gone[(사라짐)]
end
subgraph Log["로그 모델 (Kafka)"]
direction LR
l0[0] --- l1[1] --- l2[2] --- l3[3] --- l4[4]
cgA[Consumer A<br/>offset=2]
cgB[Consumer B<br/>offset=4]
end- 큐 모델(RabbitMQ, SQS): 소비자가 메시지를 가져가서 ack하면 큐에서 지워집니다. 메시지는 "처리되면 끝나는 작업"입니다. 한 번 소비되면 다시 읽을 수 없습니다.
- 로그 모델(Kafka): 메시지는 추가 전용 로그에 계속 남고, 각 소비자는 자기 **offset(어디까지 읽었는지)**을 들고 독립적으로 읽습니다. 소비해도 사라지지 않으니, 새 소비자가 과거부터 다시 읽거나(replay), 같은 데이터를 여러 소비자가 각자 다른 속도로 읽을 수 있습니다.
이 차이가 모든 것을 가릅니다.
| 관점 | 큐 모델 | 로그 모델 |
|---|---|---|
| 소비 후 | 삭제 | 보존 (retention 기간) |
| 재처리 | 불가 (다시 넣어야 함) | offset 되감으면 replay |
| 소비자 추가 | 일을 나눠 가짐 | 각자 처음부터 읽을 수 있음 |
| 적합한 일 | 작업 분배, 태스크 처리 | 이벤트 스트림, 다수 구독, 분석 |
"주문 알림 이메일 보내기" 같은 작업은 큐가 자연스럽고, "주문 이벤트를 결제·정산·추천·분석이 각자 소비" 같은 그림은 로그가 자연스럽습니다. 2편에서 Kafka·RabbitMQ·SQS를 비교할 때, 이 큐냐 로그냐가 가장 큰 분기점이 됩니다.
이벤트 기반 아키텍처 패턴
마지막으로, 이벤트를 "어떻게 쓰느냐"에도 결이 다른 패턴들이 있습니다. 이걸 뭉뚱그리면 설계가 흐려집니다.
1. Event Notification (이벤트 알림)
"무언가 일어났다"만 얇게 알립니다. 이벤트에는 식별자 정도만 담고, 자세한 데이터가 필요하면 소비자가 다시 조회합니다.
{ "type": "OrderPlaced", "orderId": "1024", "at": "2026-04-20T10:00:00Z" }- 장점: 이벤트가 가볍고 결합이 약함.
- 단점: 소비자가 추가 조회를 해야 함(되묻는 트래픽).
2. Event-Carried State Transfer (상태 전달)
이벤트에 필요한 데이터를 다 실어 보냅니다. 소비자는 되묻지 않아도 됩니다.
{
"type": "OrderPlaced",
"orderId": "1024",
"userId": "u-7",
"items": [{ "sku": "A1", "qty": 2 }],
"total": 39000
}- 장점: 소비자가 자급자족, 발행 서비스 부하 감소.
- 단점: 이벤트가 무거워지고, 스키마 변경 관리가 중요해짐.
3. Event Sourcing (이벤트 소싱)
현재 상태를 저장하는 대신, 상태를 바꾼 이벤트들의 나열을 진실의 원천으로 삼습니다. 현재 상태는 이벤트를 처음부터 재생해서 만듭니다.
OrderPlaced → ItemAdded → PaymentCompleted → OrderShipped
(이 순서를 재생하면 현재 주문 상태가 나온다)- 장점: 완전한 이력, 과거 어느 시점이든 복원, 감사(audit)에 강함.
- 단점: 복잡도가 크게 올라가고, 로그 모델(Kafka의 보존·replay)과 궁합이 좋음.
4. CQRS
명령(쓰기)과 조회(읽기) 모델을 분리합니다. 이벤트 소싱과 자주 같이 쓰이는데, 쓰기는 이벤트로 남기고 읽기용 뷰는 그 이벤트를 소비해서 따로 만듭니다.
이 패턴들은 점점 무거워지는 순서입니다. 대부분의 서비스는 1~2번이면 충분하고, 3~4번은 이력·감사·복잡한 도메인이 정말 필요할 때만 꺼내는 카드입니다. "이벤트를 쓴다 = 이벤트 소싱을 해야 한다"는 흔한 오해인데, 전혀 그렇지 않습니다.
정리하면
- 메시지는 운반 단위, 이벤트는 과거의 불변 사실입니다. Command와 Event의 차이가 결합도를 가릅니다.
- 메시징 모델은 큐(작업 분배)와 토픽(브로드캐스트) 둘이고, 브로커의 본질은 시간·공간·속도 결합을 끊는 것입니다.
- 전달 보장은 세 가지지만 현실의 답은 at-least-once + 멱등성입니다. exactly-once는 공짜가 아닙니다.
- 순서는 보통 부분 순서(파티션/그룹 단위)로 보장하고, "순서가 필요한 단위로 키를 잡는" 게 핵심입니다.
- 가장 근본적인 갈림길은 **큐(소비 후 삭제) vs 로그(보존·replay)**이고, 이게 다음 편 비교의 뼈대입니다.
- 이벤트 기반 아키텍처에는 notification → state transfer → event sourcing → CQRS로 무거워지는 결이 있고, 대부분은 앞쪽이면 충분합니다.
다음 편에서는 이 개념들을 좌표축 삼아 Kafka, RabbitMQ, SQS를 같은 표 위에 올려놓고, 어떤 상황에 무엇이 맞는지 정리합니다.
