이번 장에서는 시스템 설계 면접에서 자주 마주하는 주제인, 분산 메시지 큐 설계에 대해 알아본다. 현대적 소프트웨어 아키텍처를 따르는 시스템은 잘 정의된 인터페이스를 경계로 나뉜 작고 독립적인 블록들로 구성된다.
메세지 큐는 이 블록 사이의 통신과 조율을 담당한다. 그럼 메세지 큐를 사용했을 때의 장점은 무엇일까 ?
엄밀하게 말하면 아파치 카프카(Apache Kafka)나 펄사(Pulsar)는 메시지 큐가 아니라 이벤트 스트리밍 플랫폼(event streaming platorm)이다. 하지만 메시지 큐(RocketMQ, ActiveMQ, RabbitMQ, ZeroMQ 등)와 이벤트 스트리밍 플랫폼(카프카, 펄사) 사이의 차이는 지원하는 기능이 서로 수렴하면서 점차 희미해지고 있다.
예를 들어 전형적인 메시지 큐 RabbitMQ는 옵션으로 제공되는 스트리밍 기능을 추가하면 메시지를 반복적으로 소비할 수 있는 동시에 데이터의 장기 보관도 가능하다. 그리고 그 기능은 데이터 추가(append)만 가능한 로그(1og)를 통해 구현되어 있는데, 이벤트 스트리밍 플랫폼 구현과 유사하다.
아파치 펄사는 기본적으로 카프카의 경쟁자이지만, 분산 메시지 큐로도 사용 이 가능할 정도로 유연하고 성능도 좋다.
이번 장에서는 데이터 장기 보관(long data retention), 메시지 반복 소비(re-peated consumption of messages) 등의 부가 기능을 갖춘 분산 메시지 큐를 설계해 볼 것이다. 지금 언급한 부가 기능은 통상적으로는 이벤트 스트리밍 플랫 폼에서만 이용 가능하다.
메세지 큐의 기본 기능은, 생산자는 메세지를 큐에 보내고, 소비자는 큐에서 메세지를 꺼낼 수 있으면 된다. 하지만 이 기본 기능 이외에도 성능, 메세지 전달 방식, 데이터 보관 기간 등 고려할 사항은 다양하다. 적절한 질문을 통해 요구사항을 분명히 밝히고 설계 범위를 좁혀야 한다.
RabbitMQ와 같은 전통적인 메시지 큐는 이벤트 스트리밍 플랫폼처럼 메시지 보관 문제를 중요하게 다루지 않는다. 전통적인 큐는 메시지가 소비자에 전달되기 충분한 기간 동안만 메모리에 보관한다. 처리 용량을 넘어선 메시지는 디스크에 보관하긴 하는데 이벤트 스트리밍 플랫폼이 감당하는 용량보다는 아주 낮은 수준이다. 전통적인 메시지 큐는 메시지 전달 순서도 보존하지 않는다. 생산된 순서와 소비되는 순서는 다를 수 있다. 그런 차이를 감안하면 설계는 크게 단순해질 수 있다.
메세지 큐의 기본 기능은 아래와 같다.
가장 널리 쓰이는 메시지 모델은 일대일(point-to-point)과 발행-구독(publish-subscribe) 모델이다.
일대일 모델
이 모델은 전통적인 메시지 큐에서 흔히 발견되는 모델이다. 일대일 모델에서 큐에 전송된 메시지는 오직 한 소비자가 가져갈 수 있다. 소비자가 아무리 많아도 각 메시지는 오직 한 소비자만 가져갈 수 있다. 아래 그림을 보면 메세지 A를 가져가는 것은 소비자 1뿐이다.
어떤 소비자가 메시지를 가져갔다는 사실을 큐에 알리면(acknowledge) 해당 메시지는 큐에서 삭제된다. 이 모델은 데이터 보관(data retention)을 지원하지 않는다. 반면 본 설계안은 메시지를 두 주 동안은 보관할 수 있도록 하는 지속성 계층(persistence layer)를 포함하며, 해당 계층을 통해 메시지가 반복적으로 소비될 수 있도록 한다.
비록 본 설계안은 일대일 모델도 지원할 수 있기는 하지만, 그 기능은 발행-구독 모델 쪽에 좀 더 자연스럽게 부합한다.
발행 - 구독 모델
발행-구독 모델을 설명하려면 토픽(topic)이라는 새로운 개념을 도입해야 한다. 토픽은 메시지를 주제별로 정리하는 데 사용된다. 각 토픽은 메시지 큐 서비스 전반에 고유한 이름을 가진다.
메시지를 보내고 받을 때는 토픽에 보내고 받게 된다.
이 모델에서 토픽에 전달된 메시지는 해당 토픽을 구독하는 모든 소비자에 전달된다. 아래 그림을 보면 메시지 A는 소비자 1과 2 모두에 전달된다.
본 설계안이 제시할 분산 메시지 큐는 방금 살펴본 두 가지 모델을 전부 지원 한다. 발행•구독 모델은 토픽을 통해 구현할 수 있고, 일대일 모델은 소비자 그룹(consumer group)을 통해 지원할 수 있다.
앞서 언급했듯이 메시지는 토픽에 보관된다. 토픽에 보관되는 데이터의 양이 커져서 서버 한 대로 감당하기 힘든 상황이 벌어지면 어떻게 될까?
이 문제를 해결하는 한 가지 방법은 파티션(partition), 즉 샤딩(sharding) 기법을 활용하는 것이다. 아래 그림 같이, 토픽을 여러 파티션으로 분할한 다음에 메시지를 모든 파티션에 균등하게 나눠 보낸다. 파티션은 토픽에 보낼 메시지의 작은 부분집합으로 생각하면 좋다. 파티션은 메시지 큐 클러스터 내의 서버에 고르게 분산 배치한다. 파티션을 유지하는 서버는 보통 브로커(Broker)라 부른다. 파티션을 브로커에 분산하는 것이 높은 규모 확장성을 달성하는 비결이다. 토픽의 용량을 확장하고 싶으면 파티션 개수를 늘리면 되기 때문이다.
각 토픽 파티션은 FIFO(first in, finst Out) 큐처럼 동작한다. 같은 파티션 안에서는 메시지 순서가 유지된다는 뜻이다. 파티션 내에서의 메시지 위치는 오프셋(offset)이라고 한다.
생산자가 보낸 메시지는 해당 토픽의 파티션 가운데 하나로 보내진다. 메시지에는 사용자 ID 같은 키를 붙일 수 있는데, 같은 키를 가진 모든 메시지는 같은 파티션으로 보내진다. 키가 없는 메시지는 무작위로 선택된 파티션으로 전송된다.
토픽을 구독하는 소비자는 하나 이상의 파티션에서 데이터를 가져오게 된다. 토픽을 구독하는 소비자가 어럿인 경우, 각 구독자는 해당 토픽을 구성하는 파티션의 일부를 담당하게 된다. 이 소비자들을 해당 토픽의 소비자 그룹 (consumer group)이라 부른다.
앞서 언급한 대로 본 설계안은 일대일 모델과 발행-구독 모델을 전부 지원해야 한다. 소비자 그룹 내 소비자는 토픽에서 메시지를 소비하기 위해 서로 협력 한다.
하나의 소비자 그룹은 여러 토픽을 구독할 수 있고 오프셋을 별도로 관리한다. 예를 들어, 큐 용레에 따라 과금(billing)용 그룹, 회계(accounting)용 그룹 등으로 나눌 수 있을 것이다.
같은 그룹 내의 소비자는 메시지를 병렬로 소비할 수 있다.
🚨 하지만 문제가 하나 있다. 데이터를 병렬로 읽으면 대역폭(throughput) 측면에서는 좋지만 같은 파티션 안에 있는 메시지를 순서대로 소비할 수는 없다. 가령 소비자-1과 소비자-2가 같은 파티션-1의 메시지를 읽어야 한다고 하자. 파티션-1 내의 메시지 소비 순서를 보장할 수 없게 된다.
한 가지 제약사항을 추가하면 이 문제는 해결할 수 있다. 즉, 어떤 파티션의 메시지는 한 그룹 안에서는 오직 한 소비자만 읽을 수 있도록 하는 것이다. 다만 그 경우, 그룹 내 소비자의 수가 구독하는 토픽의 파티션 수보다 크면 어떤 소비자는 해당 토픽에서 데이터를 읽지 못하게 된다. 예를 들어 위의 그림의 그룹-2에 있는 소비자-3은 토픽 B의 메시지를 수신할 수 없다. 같은 그룹 내의 소비자-4가 이미 소비하도록 되어 있기 때문이다.
이 제약사항을 도입한 후에 모든 소비자를 같은 소비자 그룹에 두면 같은 파 티션의 메시지는 오직 한 소비자만 가져갈 수 있으므로 결국 일대일 모델에 수렴하게 된다. 파티션은 가장 작은 저장 단위이므로 미리 충분한 파티션을 할당해 두면 파티션의 수를 동적으로 늘리는 일은 피할 수 있다. 처리 용량을 늘리 려면 그냥 소비자를 더 추가하면 된다.
다음 그림은 수정된 개략적 설계안이다.
클라이언트
핵심 서비스 및 저장소
브로커: 파티션들을 유지한다. 하나의 파티션은 특정 토픽에 대한 메시지의 부분 집합을 유지한다.
저장소
조정 서비스(coordination service)