Contents

Log Based vs Queue Based Message Brokers

대규모의 웹 애플리케이션을 MSA로 구성하는 예시에서 이제 메시지 브로커를 빼 놓고 이야기 하기는 섭섭한 존재가 되었다.

메시지 브로커의 종류와 그에 따른 특징을 한번 알아봤다.

https://medium.com/towardsdev/message-brokers-queue-based-vs-log-based-66d1140f0f28

큐 기반 브로커

큐 기반 시스템들은 당연하게도 큐 자료구조에 기반한다.

큐란 무엇인가? FIFO - 먼저 들어간 원소들이 먼저 나오게 되는 일종의 ‘줄서기’와 같은 자료구조이다.

큐 기반 시스템에서는 간단하게는 상호작용이 가능한 3개의 요소들이 존재한다.

  • 메시지를 큐로 발행하는 Producer
  • 메시지를 보내는 Queue
  • 메시지를 큐로부터 수신하는 Consumer

이런 원시적인 형태의 시스템은 유용할 수 있지만 실제로 어플리케이션을 설계할 때를 생각해보면 그렇지 않다. 이벤트 드리븐 아키텍쳐에서는 보다 복잡한 토폴로지가 요구된다.

보통은 동일한 이벤트가 다른 하위 시스템으로 독립적으로 발행될 수 있어야 한다. 즉 이벤트는 한번 생산되고 난 후에 여러 컨슈머에게 접근가능한 상태여야 한다.

때문에 RabbitMQ로 대표되는 큐 기반 브로커들에게는 토픽의 개념이 널리 사용되게 된다.

  • Producer는 메시지를 토픽으로 보낸다.
  • 메시지는 토픽으로부터 큐들에게 분배된다.
  • 구현에 따라 다르지만 RabbitMQ의 경우 토픽은 단순한 라우팅 룰이다. (어던 메시지가 어떤 큐에 들어가야 할지를 결정한다)

왜 다수의 컨슈머들이 하나의 큐에서 전달되는 동일한 메시지를 받도록 할 수 없을까?

이게 큐 기반 브로커들과 로그 기반 브로커들의 가장 큰 차이점이다.

메시지가 컨슈머로 전달되고 ‘컨슈머가 제대로 받았음’을 다시 확인받았을 때 큐로부터 메시지가 제거되고 다른 컨슈머들에게 필요가 없는 상태로 전환된다.

이 구조를 통해 메시지 전달에 있어서 신뢰성이 보장이 된다. 즉 컨슈머가 상태 없이 유지되는 동시에 큐로부터 항상 첫 번째 메시지를 받도록 하는 것이다.

컨슈머가 재시작하는 경우에는 종료하기 전에 인지하지 못했던 첫 번째 메시지를 즉시 받게 된다.

가장 유명한 큐 기반 브로커들에는 RabbitMQ, ZeroMQ, ActiveMQ, Amazon SQS, Redis Pubsub (실제로 메시지 브로커는 아니지만)이 있다.

로그 기반 브로커

이름에서 추측할 수 있듯이, 로그 기반 메시지 브로커는 메시지들을 저장하기 위해서 로그를 사용한다는 점이다.

로그는 영속적인 저장소이기 때문에, 다수의 컨슈머들이 로그로부터 메시지를 병렬적으로 읽을 수 있다.

  • 각각의 컨슈머는 처리 속도가 다를 수 있고, 로그의 서로 다른 부분을 읽는다.
  • 이는 각각의 컨슈머가 독립적이고 브로커로부터 느슨한 결합을 유지할 수 있도록 한다.
  • 또한 다수의 컨슈머들이 하나의 로그에 대해 작업할 때 부가적인 엔티티를 생성하지 않아도 되게 한다. (큐 기반 브로커들이 별도로 행해야 헀던 것과는 대조된다)

하지만 이런 접근은 다른 복잡한 문제를 이끌었다.

바로 컨슈머들의 오프셋 (또는 커서)가 어딘가엔 저장되어야 한다는 점이다.

컨슈머의 오프셋을 컨슈머 내부 어딘가에 저장하는 것은 그리 좋은 생각이 아니다.

  • 만약 컨슈머가 fail 상태로 빠지거나 다른 컨슈머로 대체되었을 때 새롭게 생성된 컨슈머가 이전 인스턴스의 커서에 대한 접근이 확보되어야 하기 때문이다.
  • 이에 대한 보장이 되지 않는다면 컨슈머는 우리가 원하지 않았던 위치의 로그로부터 메시지를 읽게 될 것이다.

때문에 로그 기반 브로커는 추가적인 커서 저장소가 사용된다. 별도의 서비스가 이 목적을 달성하기 위해 사용될 수도 있지만, 별도의 서비스를 둘 경우에는 별도의 유지보수 및 모니터링에 대한 비용이 필요하다.

아마도 더 좋은 방법은 커서들을 브로커 내부에 저장하는 것이다. (카프카가 이 방법을 사용한다)

  • 카프카는 컨슈머들의 오프셋을 __consumer_offsets라고 불리는 내부 토픽에 저장한다.
  • 아파치 펄서 또한 비슷하게 BookKeeper에 다른 데이터처럼 커서를 저장한다.

로그라는 저장소에서 오는 내구성은 또 다른 이점을 제공한다.

  • 모든 소비사자 모든 위치에서 메시지를 읽을 수 있다.
  • 또는 처음부터 전체 이벤트 로그를 재생할 수도 있다.

전체 로그를 가지고 있게 되는 것은 이벤트 일관성 측면에서 이벤트 중심 시스템에 유용할 수 있다.

로그 기반 브로커를 대표하는 예시로는 아파치 카프카, 아파치 펄서, 아마존 키네시스가 있다.

메시지의 순서

두 가지 브로커 종류에 따라 가장 큰 차이점으로는 메시지에 대한 순서가 필요할 때 드러난다.

메시지 순서에는 여러 가지가 있을 수 있다. 가령 프로듀서가 이벤트 1을 발행한 후 이벤트 2를 발행하면 브로커에 저장된 다음 다음 컨슈머에게 동일한 순서로 전달되어야 하는 경우가 있다.

  • 예를 들어 주문 서비스가 ‘주문 완료’ 이벤트를 발행한 다음 ‘주문 취소’ 이벤트를 발행하는 경우.
  • ‘주문 취소’ 이벤트가 먼저 처리된다면 컨슈머가 이 일련의 이벤트들을 제대로 처리할 수 없다.

그러나 별도의 서비스에서 생성된 관련된 메시지 내에 내재된 순서가 있다면 어떨까? 예를 들어 주문, 결제, 주문 처리 서비스가 있다고 가정해보자.

  • 주문 서비스는 ‘주문 완료’, ‘주문 취소’ 이벤트를 발행한다.
  • 결제 서비스는 ‘주문 결제’ 이벤트를 발행한다.
  • 주문 처리 서비스는 이런 종류의 이벤트를 소비한다.
    • ‘주문 결제’ 이벤트에서 주문 처리를 시작한다.
    • ‘주문 취소’ 이벤트에서 주문 처리를 중지한다.
  • 이 경우 동일한 주문에 대해 ‘주문 취소’ 이벤트가 발생하기 전에 ‘주문 결제’ 이벤트가 먼저 발생한다면 어떨까?

큐 기반 브로커에서의 해결책

메시지 토폴로지를 유연하게 구축할 수 있는 것이 큐 기반 브로커의 장점이기 때문에, 거의 모든 라우팅 로직을 달성할 수 있다. 상기 예시의 경우 두 개의 (또는 이벤트, 서비스 단위로) 구분된 큐를 구성하고 단일 쿼리가 모든 큐에 대한 처리를 달성하도록 구성할 수 있다.

이런 토폴로지 구성은 큐의 가장 큰 기능 중 하나이다.

로그 기반 브로커에서의 해결책

로그 기반 브로커에서는 처리가 조금 더 어렵다.

모든 이벤트가 별도의 토픽에 게시되면 개별적으로 처리되고 순서를 유지할 방법이 없기 때문에 큐와 같이 구성해서는 안된다.

또한 일부 브로커는 컨슈머가 여러 토픽의 메시지를 소비할 수 있도록 허용하지만 이 경우에는 도움이 되지 않는다.

또 다른 전략은 전체 시스템에 대해 단일 토픽을 지정하고 모든 이벤트를 이 토픽으로 보내는 것이다. (!)

  • 이 방법은 순서에 대한 문제는 해결할 수 있지만, 항상 최선의 해결책은 아니다.
  • 컨슈머들은 그 중 오직 일부에만 관심이 있음에도 불구하고 시스템에서 발행되는 모든 이벤트들을 읽게 된다.
  • 전체 시스템에 대해 하나의 큰 토픽을 가지게 되는 것은 잠재적으로 큰 문제가 될 수 있다.

때문에 어떤 방법으로 이벤트를 그 종류로 묶어서 각 그룹에 대해 분리된 토픽을 구성했다고 가정해보자.

  • 동일한 집계에 대해 관련이 있는 이벤트라면, 하나의 토픽에 넣는다.
  • 이벤트들이 서로 의존되는 엔티티에 관련이 있다면, 동일한 토픽에 넣을 가치가 충분하다.
  • 하나의 이벤트가 다수의 메시지로 분리되지 않는 다수의 엔티티에 관련이 있다면, 이벤트 핸들링의 후 순위 스테이지에서 이를 처리할 수 있을 것이다.

로그 기반 토폴로지들에서 발생할 수 있는 모든 문제점들에 더해. 시스템 구조가 변경되거나 오류가 발생한 경우 토폴로지 구성을 변경하기가 더 어렵다.

반면 큐 기반 토폴로지는 이런 면에서는 조금 더 유리하다.

스케일링

스케일링은 쉬워보이기도 하면서 동시에 복잡하기 때문에 항상 헷갈린다.

로그 기반 브로커는 스케일링이 훨씬 쉽다.

보통 카프카를 스케일 아웃하기 위해서는 토픽에 대한 파티션 개수를 늘린다.

  • 이 경우 관계되어 있는 이벤트들의 순서를 지키기 위해서는 이벤트들이 파티션 키를 가지도록 구성하면 된다.
  • 동일한 파티션 키를 가지는 이벤트들은 동일한 파티션으로 가서 올바른 순서로 처리될 것이기 때문이다.

큐 기반 브로커에서 스케일링을 하기는 더 복잡하다.

큐 기반 브로커들은 동일한 큐에 대해 여러 개의 컨슈머를 붙일 수 있도록 허용하지만, 이는 메시지 순서 처리에 혼동을 줄 것이다.

  • 다수의 컨슈머가 동일한 쿼리에 대하 리스닝할 때, 컨슈머 간 경쟁 상태에 돌입될 수 있다.
    • 첫 번째 컨슈머가 이벤트를 받고 있을 때 동안 두 번째 컨슈머가 첫 번째 컨슈머가 첫 번째 컨슈머가 메시지를 처리하는 것을 기다리지 않는 것이다.
  • 독립적인 태스크 큐에서는 오히려 좋을 수도 있지만, 관계된 일련의 이벤트들을 처리할 때에는 달가운 상황이 아니다.
  • 컨슈머들은 병렬적으로 일할 수 있으며, 이는 곧 첫 번째 이벤트가 두 번째 이벤트보다 먼저 처리될 것을 보장해주지 않는다는 것을 의미하기도 한다.

분명하게 단일 큐로 이 문제를 해결할 수 있을 것 같지는 않다. 그렇기 때문에 분리된 큐들이 필요하고, 이벤트는 보다 높은 레벨에 있어서 분배되어야 한다.

RabbitMQ의 Consistent Hashing Exchange Type은 이런 종류의 문제를 해결하기 위해 제시된 좋은 해결책이다.

  • 로그 기반 브로커에서의 파티션 키 메커니즘처럼 메시지의 라우팅 키에 의거해서 메시지들을 큐들에 분배하는 전략이다.
  • 하지만 이런 비슷한 종류의 해결책으로도 이 문제를 명쾌하게 해결하지는 못한다.

결론

큐 기반 브로커들은:

  • 이론적으로는 보다 적은 latency를 가진다. (AMQP와 같은 큐에 최적화된 프로토콜을 사용하기 때문에)
  • 실제로는 최신 로그 기반 브로커가 강력한 캐시 시스템을 사용하기 때문에 성능에 있어서의 차이는 크지 않다.
  • 보다 유연한 메시지 토폴로지를 구성하는 것이 가능하다.
  • 때문에 보다 복잡한 메시지 라우팅이나 우선순위 처리가 가능하다.

로그 기반 브로커들은:

  • 메시지들이 영속적으로 디스크에 저장되기 때문에 이벤트들의 기록이나 전체 순서를 재생해야 하는 기능을 제공할 수 있다.
  • 스케일에 보다 용이하다.