메시지 큐, 큐와 로그부터 순서 보장까지

메시지 큐를 사용하는 알림 기능을 일부 고치는 일을 맡게 되었습니다. 코드와 설계 문서를 읽다 보니 익숙하지 않은 말이 계속 나왔습니다. 한쪽은 파티션과 오프셋으로, 다른 쪽은 익스체인지와 라우팅 키로, 또 다른 쪽은 스트림과 컨슈머로 비슷한 일을 설명하고 있었습니다.
그때그때 필요한 부분만 찾아 읽으니 각 제품이 무엇을 하는지는 알겠는데, 서로 무엇이 다른지는 알기 어려웠습니다. 그래서 개념을 하나씩 익히면서 정리했고, 그 내용을 글로 옮겼습니다.
메시지 큐를 가르는 기준은 무엇이고, 그 기준이 무엇을 결정할까요?
이 글에서 다루는 내용
특정 제품의 설정값보다, 서로 다른 메시지 큐를 이해하는 데 필요한 몇 가지 기준부터 잡아봅니다. 브로커가 무엇을 기억하는지에서 시작해 큐와 로그의 차이, 전달 보장과 멱등성, 순서 보장의 단위, 팬아웃, 밀렸을 때 생기는 일까지 순서대로 짚고, 마지막에 "큐를 넣으면 느슨해진다"는 흔한 기대가 어디까지 맞는지 확인합니다.
- 메시지 큐가 대신해 주는 일
- 큐와 로그: 브로커가 무엇을 기억하나
- 전달 보장: 같은 메시지가 두 번 올 때
- 순서 보장의 단위
- 한 메시지를 여럿이 받을 때
- 밀릴 때 생기는 일
- 큐를 넣으면 느슨해지나
- 용어 사전
- References
메시지 큐가 대신해 주는 일
서비스 A가 서비스 B에 무언가를 시킨다고 해 보겠습니다. HTTP로 직접 부르면 세 가지가 A의 부담이 됩니다. B가 지금 떠 있어야 하고, B가 처리하는 동안 A가 기다려야 하며, B가 실패하면 A가 재시도를 책임져야 합니다.
가운데에 브로커(broker)를 두면 이 세 가지를 브로커가 대신 처리합니다. A는 메시지를 브로커에 맡기고 다음 작업으로 넘어갑니다. B는 준비됐을 때 메시지를 가져가 처리하고, 실패하면 브로커가 다시 전달합니다. A와 B가 동시에 살아 있지 않아도 됩니다.
broker는 원래 거래를 중개하는 사람을 가리키는 말입니다. 중개인은 물건을 자기가 만들지도 쓰지도 않고, 사는 쪽과 파는 쪽 사이에서 거래만 성사시킵니다. 메시지 큐의 브로커도 하는 일이 같습니다. 메시지를 만들지도 소비하지도 않고 맡아 두었다가 넘겨줍니다.
중개인을 두면 양쪽이 서로를 직접 알 필요가 없어진다는 점도 같습니다. 발행하는 쪽은 누가 받을지 모르고, 받는 쪽은 누가 보냈는지 몰라도 됩니다.
다만 Kafka에서 "브로커 3대"처럼 말할 때는 이 역할이 아니라 서버 한 대씩을 가리킵니다.
여기서 브로커가 하는 일을 한 단어로 줄이면 기억입니다. 메시지를 기억하고, 전달 상태나 소비자가 읽은 위치 같은 정보를 관리합니다. 제품마다 다른 것도 결국 무엇을 얼마나 오래 기억하느냐입니다. 무엇을 기억하고 언제 버리는지가 다르면 재처리 가능 여부, 비용이 무엇에 비례하는지, 순서를 어디까지 보장하는지가 함께 달라집니다.
큐와 로그: 브로커가 무엇을 기억하나
먼저 소비한 메시지가 사라지는지, 그대로 남는지부터 보겠습니다.
큐 모델에서 메시지는 소비자가 처리를 마쳤다고 알리면(ack) 브로커에서 지워집니다. 소비자는 별도의 읽기 위치를 관리하지 않습니다. 우편함에 비유하면 편지를 꺼내 간 뒤에는 우편함이 비어 있는 상태입니다. RabbitMQ의 기본 큐와 Amazon SQS가 여기에 해당합니다.
로그 모델에서 메시지는 보관 기간 동안 그 자리에 남습니다. 소비자가 읽어도 지워지지 않고, 대신 소비자마다 "어디까지 읽었는지"를 나타내는 위치가 있습니다. 이 위치를 오프셋(offset)이라고 부릅니다. 장부에 비유하면 기록은 계속 쌓이고 각자 책갈피를 꽂아 두는 방식입니다. Kafka가 대표적입니다.
같은 메시지 세 건을 넣고 첫 건을 처리했을 때, 두 모델에 남는 것이 다릅니다.
| 큐 모델 | 로그 모델 | |
|---|---|---|
| 소비한 메시지 | ack하면 사라진다 | 보관 기간 동안 남는다 |
| 읽은 위치 | 브로커가 전달 여부로 관리 | 소비자별 오프셋으로 관리 |
| 같은 메시지 다시 읽기 | 사라진 뒤에는 불가능 | 오프셋을 되감으면 가능 |
| 새 소비자가 붙으면 | 붙은 시점 이후만 받는다 | 보관된 과거부터 읽을 수 있다 |
| 저장 비용이 비례하는 것 | 처리되지 않고 밀린 양 | 보관 기간 × 유입량 |
| 대표 | RabbitMQ 기본 큐, SQS | Kafka |
두 모델의 차이는 재처리할 때 분명해집니다. 소비자 코드에 버그가 있어 지난 사흘치를 잘못 처리했다면, 로그 모델에서는 오프셋을 사흘 전으로 되감아 다시 읽으면 됩니다. 큐 모델에서는 이미 지워진 메시지를 되살릴 방법이 없으므로 원본 데이터를 가진 쪽에서 다시 발행해야 합니다. 기존 이벤트 흐름에 분석용 서비스를 새로 붙일 때도 마찬가지입니다. 로그 모델에서는 보관된 과거부터 읽어 초기 데이터를 채울 수 있지만, 큐 모델에서는 붙은 순간부터의 메시지만 받습니다.
비용이 어디에 비례하는지도 다릅니다. 로그 모델은 소비자가 잘 따라오고 있어도 보관 기간만큼 디스크를 씁니다. 반대로 큐 모델은 소비자가 멈추면 밀린 양이 그대로 브로커의 부담이 되고, 소비가 원활하면 거의 비어 있습니다.
물론 모든 제품이 큐와 로그 중 하나로 깔끔하게 나뉘지는 않습니다. RabbitMQ는 로그 성격의 Streams를 따로 제공하고, NATS JetStream은 스트림에 메시지를 남기면서도 소비자를 일회용으로 만들었다 버릴 수 있게 해 두 성격 사이에 있습니다. JetStream을 켜지 않은 NATS Core는 아예 어느 쪽도 아닙니다. 구독자가 없으면 메시지는 그냥 사라지고 아무것도 남지 않습니다. 그래서 제품 자체를 큐나 로그로 외우기보다, 실제로 메시지를 어떻게 보관하고 소비하는지 보는 편이 낫습니다.
Recap
가장 먼저 볼 질문은 "소비한 메시지를 나중에 다시 읽어야 하는가"입니다. 제품 문서를 읽을 때도 "소비하면 사라지는가"와 "읽은 위치를 어떻게 관리하는가" 두 가지를 먼저 찾으면 나머지 기능도 왜 필요한지 이해하기 쉬워집니다.
전달 보장: 같은 메시지가 두 번 올 때
메시지가 정확히 한 번 도착한다고 믿고 코드를 쓰면 언젠가 틀립니다. 전달 보장은 보통 세 단계로 이야기합니다.
- at-most-once: 많아야 한 번. 중복은 없지만 유실될 수 있습니다.
- at-least-once: 적어도 한 번. 유실은 없지만 중복될 수 있습니다.
- exactly-once: 정확히 한 번.
차이를 이해하려면 먼저 ack를 언제 보내는지 보면 됩니다. 메시지를 받자마자 ack하고 처리에 들어가면, 처리 도중 소비자가 죽었을 때 그 메시지는 이미 브로커에서 지워졌으므로 사라집니다. 이것이 at-most-once입니다. 반대로 처리를 끝낸 뒤에 ack하면 유실은 없지만, 처리는 성공했는데 ack가 전달되기 전에 죽으면 브로커는 아직 처리되지 않았다고 보고 다시 줍니다. 이것이 at-least-once이고, 대부분의 시스템이 기본값으로 삼는 쪽입니다.
특히 주의할 때는 처리는 성공했는데 ack를 보내지 못한 경우입니다. 브로커 입장에서는 처리에 실패한 것과 구별되지 않습니다.
exactly-once는 무엇을 보장하나
응답이 오지 않았을 때, 보낸 쪽은 상대가 처리하지 못한 것인지 처리했지만 응답만 유실된 것인지 알 수 없습니다. 결국 다시 보내면 중복될 수 있고, 보내지 않으면 유실될 수 있습니다.
그래서 제품 문서에서 exactly-once라고 할 때는 대개 "전달 횟수"보다 "처리 결과가 한 번만 반영되는가"를 봐야 합니다. Kafka의 경우 프로듀서 멱등성 설정과 트랜잭션을 조합하면 Kafka에서 읽어 Kafka로 쓰는 구간 안에서 결과가 한 번만 반영되도록 만들 수 있습니다. 다만 이 보장은 Kafka 내부에서 읽고 쓰는 경우에 한정됩니다. 소비자가 외부 데이터베이스나 결제 API를 호출하는 부분까지 exactly-once로 만들어 주지는 않습니다.
그래서 멱등하게 만듭니다
보통은 중복이 올 수 있다고 가정하고, 같은 메시지를 두 번 처리해도 문제가 없도록 만듭니다.
메시지마다 고유한 키를 싣고 소비자가 처리한 키를 기록해 두었다가 이미 본 키면 건너뜁니다. 혹은 처리 자체를 여러 번 해도 같은 결과가 나오는 연산으로 짭니다. "포인트를 10점 더한다"는 두 번 실행하면 틀리지만 "사용자 상태를 ACTIVE로 바꾼다"는 몇 번을 해도 같습니다. 데이터베이스를 쓴다면 메시지 키를 유니크 제약이 걸린 컬럼에 넣어 중복 삽입이 실패하게 두는 방법도 자주 씁니다.
같은 문제를 아예 다른 방향에서 푸는 방법도 있습니다. 메시지에 데이터를 싣지 않고 "무언가 바뀌었다"는 신호만 보낸 뒤, 받는 쪽이 최신 상태를 따로 조회하는 방식입니다. 이러면 신호가 두 번 오든 세 번 오든 결과가 같으므로 중복 자체가 문제가 되지 않습니다. 대신 메시지 한 건마다 조회가 한 번씩 따라붙습니다.
순서 보장의 단위
실무에서 순서 보장은 보통 전체 메시지가 아니라 특정 범위 안에서 제공됩니다. 전체를 하나의 순서로 묶을수록 병렬 처리하기 어려워지기 때문입니다.
이를 보장하는 단위는 제품마다 다릅니다. Kafka에서는 파티션이 그 단위입니다. 한 토픽은 여러 파티션으로 나뉘고, 같은 파티션에 들어간 메시지끼리는 순서가 지켜지지만 파티션이 다르면 아무 보장이 없습니다. 메시지를 어느 파티션에 넣을지는 보통 키의 해시로 정합니다. 주문 ID를 키로 쓰면 같은 주문의 이벤트들은 같은 파티션에 모이므로 그 주문 안에서는 순서가 지켜집니다. SQS의 FIFO 큐도 메시지 그룹 ID 단위로 같은 이야기를 합니다.
그리고 파티션은 순서뿐 아니라 소비자가 어디서 다시 읽을지를 기록하는 단위이기도 합니다.
순서만 정하는 것은 아닙니다
파티션 수는 동시에 일할 수 있는 소비자 수를 제한합니다. Kafka에서 한 파티션은 한 컨슈머 그룹 안의 한 소비자에게만 할당됩니다. 파티션이 6개인 토픽에 소비자를 10개 붙이면 4개는 아무것도 받지 못합니다. 결국 파티션 수보다 소비자를 더 늘려도 처리량은 늘지 않습니다.
키를 무엇으로 잡느냐에 따라 부하가 고르게 나뉘지 않을 수 있습니다. 너무 많은 메시지가 같은 키를 공유하면 특정 파티션에 트래픽이 몰릴 수 있습니다. 예를 들어 법인 ID를 키로 삼았는데 한 법인이 전체 트래픽의 절반을 차지하면 그 파티션만 밀립니다.
소비자가 어디서부터 다시 읽을지도 여기서 정해집니다. 오프셋은 파티션마다 따로 매겨지므로, 소비자는 자기가 맡은 파티션마다 어디까지 읽었는지를 따로 들고 있어야 합니다.
Recap
순서 요구사항을 받으면 "무엇과 무엇 사이의 순서가 지켜져야 하는가"를 먼저 좁힙니다. 대개 필요한 것은 전역 순서가 아니라 한 주문, 한 사용자, 한 문서 안에서의 순서입니다. 필요한 순서의 범위를 알면 어떤 값을 파티션 키로 삼을지도 판단하기 쉬워집니다. 파티션 수는 나중에 바꾸기 번거로우므로 처음 설계할 때 확장 가능성도 함께 고려하는 편이 낫습니다.
한 메시지를 여럿이 받을 때
하나의 메시지를 여러 소비자가 받아야 할 때는 두 경우를 구분해야 합니다. 여러 소비자가 일을 나눠 갖는 경우와, 서로 다른 서비스가 같은 메시지를 각자 다 받아야 하는 경우입니다. 주문이 들어왔을 때 재고 서비스와 알림 서비스가 각자 받아야 하는 것은 후자이고, 알림 서비스의 인스턴스 3대가 부하를 나누는 것은 전자입니다.
Kafka는 이 둘을 컨슈머 그룹 하나로 표현합니다. 같은 그룹에 속한 소비자들은 파티션을 나눠 가지므로 메시지가 분배되고, 그룹이 다르면 같은 메시지를 각자 받습니다. 알림 서비스 인스턴스들은 같은 그룹으로 묶고, 재고 서비스는 다른 그룹 이름을 쓰면 됩니다. 컨슈머 그룹을 어떻게 묶느냐에 따라 두 경우를 모두 처리할 수 있습니다.
RabbitMQ는 익스체인지 종류로 라우팅을 정합니다. fanout 익스체인지는 연결된 모든 큐에 복사해 넣고, 큐 하나에 붙은 소비자 여럿은 그 큐의 메시지를 나눠 갖습니다. NATS는 subject라는 계층형 주소에 와일드카드로 구독하고, 나눠 갖기가 필요하면 큐 그룹으로 묶습니다. 용어는 다르지만 "복사할지 나눌지"를 정한다는 점에서는 같습니다.
밀릴 때 생기는 일
발행 속도가 소비 속도보다 빠른 상태가 이어지면 어딘가에 쌓입니다. Kafka처럼 로그를 사용하는 시스템에서는 발행된 위치와 소비자가 읽은 위치의 차이를 컨슈머 랙(consumer lag)으로 볼 수 있습니다. 랙이 일정하면 따라가고 있는 것이고, 계속 벌어지면 처리량이 모자란 것입니다.
랙이 벌어지면 소비자를 늘리거나 메시지 하나의 처리 시간을 줄이게 되는데, 소비자를 늘리는 쪽은 앞에서 본 대로 파티션 수보다 많이 늘려도 소용이 없습니다. 밀린 메시지를 일부 버리는 편이 나은 경우도 있습니다. 실시간 알림처럼 오래된 메시지가 가치를 잃는 종류라면, 끝까지 처리하는 것보다 버리고 최신부터 따라가는 편이 낫습니다.
한 가지 더 알아 둘 것은 항상 실패하는 메시지 하나가 뒤의 멀쩡한 메시지까지 막을 수 있다는 점입니다. 그래서 재시도 횟수에 상한을 두고, 그 이상 실패한 메시지는 데드 레터 큐(dead letter queue)라는 별도 저장소로 빼 두고 나머지 메시지를 계속 처리합니다. 재시도 사이에 간격을 두는 것도 함께 씁니다.
큐를 넣으면 느슨해지나
메시지 큐를 도입하는 이유로 흔히 "서비스 간 결합을 끊는다"가 꼽힙니다. 절반은 맞습니다.
끊기는 것은 시간 결합입니다. 상대가 지금 떠 있지 않아도 되고, 상대의 처리가 끝날 때까지 기다릴 필요도 없습니다. 이것만으로도 큐를 쓸 이유는 충분합니다.
끊기지 않는 것은 스키마 결합입니다. 메시지에 필드를 추가하거나 의미를 바꾸면 그 메시지를 읽는 쪽이 깨집니다. HTTP 응답 형식을 바꿨을 때와 똑같습니다. HTTP라면 코드에서 호출 지점을 찾아 누가 이 API를 쓰는지 셀 수 있지만, 메시지는 발행하는 쪽이 구독자가 누구인지 모르는 것이 정상입니다. 무엇이 깨질지 알기 어려워집니다.
그래서 큐를 쓰는 시스템에서는 메시지 형식을 API 명세처럼 관리해야 합니다. 필드를 지우는 대신 새 필드를 더하고, 버전을 올릴 때 구 버전을 한동안 함께 발행하고, 어떤 서비스가 어떤 메시지를 구독하는지를 어딘가에 적어 둡니다. 그러지 않으면 서비스끼리 동시에 떠 있어야 하는 문제는 해결해도, 어떤 서비스가 어떤 메시지에 의존하는지는 오히려 파악하기 어려워집니다.
용어 사전
| 용어 | 뜻 |
|---|---|
| 브로커(broker) | 메시지를 맡아 두었다가 소비자에게 전달하는 중간 서버 |
| 프로듀서 / 컨슈머 | 메시지를 보내는 쪽 / 받아서 처리하는 쪽 |
| 토픽(topic) | 메시지를 주제별로 나누는 이름. 발행과 구독의 단위 |
| 파티션(partition) | 토픽을 나눈 조각. Kafka에서 순서 보장과 병렬 처리의 단위 |
| 오프셋(offset) | 로그 안에서 소비자가 읽은 위치 |
| ack | 소비자가 브로커에 처리 완료를 알리는 신호. 이 시점이 전달 보장을 정한다 |
| 컨슈머 그룹 | 메시지를 나눠 갖는 소비자 묶음. 그룹이 다르면 같은 메시지를 각자 받는다 |
| 멱등성(idempotency) | 같은 처리를 여러 번 해도 결과가 같은 성질 |
| 컨슈머 랙(lag) | 발행된 마지막 위치와 소비자가 읽은 위치의 차이 |
| 데드 레터 큐(DLQ) | 정해진 횟수만큼 재시도해도 실패한 메시지를 옮겨 두는 저장소 |
| 백프레셔(backpressure) | 소비가 발행을 따라가지 못할 때 속도를 조절하거나 버리는 대응 |
| 익스체인지(exchange) | RabbitMQ에서 메시지를 어느 큐로 보낼지 정하는 라우팅 장치 |
| subject | NATS에서 메시지의 주소 역할을 하는 계층형 이름 |
References
- Apache Kafka 4.3 Design: Message Delivery Semantics
- Apache Kafka 4.3 Introduction: Main Concepts and Terminology
- Apache Kafka 4.3 Design: Client Groups
- KIP-98: Exactly Once Delivery and Transactional Messaging
- RabbitMQ: Consumer Acknowledgements and Publisher Confirms
- RabbitMQ: Streams and Superstreams
- NATS Docs: JetStream
- NATS Docs: Subjects
- Amazon SQS Developer Guide: Amazon SQS FIFO queues
