본문 바로가기
1. 개발/1.8. ActiveMQ

메시지 그룹(Message Groups) 사용 시 컨슈머 리밸런싱은 어떻게 일어나나?

by 엉짱 2026. 3. 15.
반응형

메시지 그룹(Message Groups) 사용 시 컨슈머 리밸런싱은 어떻게 일어나나?

엔터프라이즈 메시징 시스템(ActiveMQ, Artemis, Amazon MQ 등)에서 큐(Queue)에 쌓인 메시지를 여러 컨슈머(Consumer)가 병렬로 처리할 때, 가장 큰 난제는 '메시지의 처리 순서(Ordering) 보장'입니다. 병렬 처리의 이점을 누리면서도 특정 연관된 메시지들(예: 동일한 주문 번호의 생성 $\rightarrow$ 결제 $\rightarrow$ 배송 메시지)의 순서를 엄격하게 지키기 위해 고안된 기술이 바로 메시지 그룹(Message Groups)입니다.

메시지 그룹은 생산자(Producer)가 메시지에 JMSXGroupID라는 특수한 헤더 속성을 부여하여 동작합니다. 브로커는 동일한 JMSXGroupID를 가진 모든 메시지를 큐에 연결된 여러 컨슈머 중 오직 단 하나의 지정된 컨슈머에게만 전송합니다.

그렇다면, 큐에 연결된 컨슈머 노드 중 하나가 다운되거나 새로운 컨슈머가 스케일 아웃(Scale-out)으로 추가되는 상황, 즉 컨슈머 리밸런싱(Consumer Rebalancing)이 발생할 때 브로커 내부에서는 그룹 할당을 어떻게 재조정할까요?

1. 최초의 그룹 할당 메커니즘 (First-Come, First-Served)

리밸런싱을 이해하려면 브로커가 그룹을 처음 어떻게 할당하는지 알아야 합니다. 브로커는 해시(Hash) 링 기반으로 미리 그룹을 나누어 두지 않습니다. 대신 도착 순서(First-Come, First-Served)에 기반한 동적 할당 방식을 주로 사용합니다.

  1. 브로커의 큐에 새로운 JMSXGroupID (예: GROUP_A)를 가진 메시지가 최초로 도착합니다.
  2. 브로커는 현재 큐에 연결된 컨슈머들을 확인하고, 가용한 컨슈머 중 하나(예: Consumer 1)를 선택하여 GROUP_A의 소유권(Ownership)을 바인딩합니다.
  3. 이후 도착하는 모든 GROUP_A 메시지는 무조건 Consumer 1로만 라우팅됩니다.

이 소유권 정보는 브로커의 메모리 내에 매핑 테이블 형태로 유지됩니다.

2. 컨슈머가 종료/다운되었을 때의 리밸런싱

GROUP_A를 전담하여 처리하던 Consumer 1이 네트워크 단절, OOM(Out of Memory), 혹은 정상적인 배포 작업 등으로 인해 브로커와의 연결이 끊어졌을 때의 동작입니다.

  1. 소유권 해제: 브로커는 Consumer 1의 세션 종료를 감지하는 즉시, 해당 컨슈머에 할당되어 있던 모든 메시지 그룹(GROUP_A 등)의 소유권을 해제(Release)합니다.
  2. 대기열 적재: 기존에 Consumer 1에게 전달되었으나 아직 처리 완료(ACK)를 받지 못한 메시지들은 다시 큐의 최상단으로 롤백(Rollback)되어 적재됩니다.
  3. 재할당 (Rebalancing): 브로커는 큐에 대기 중인 GROUP_A 메시지를 꺼내어, 살아남은 다른 가용한 컨슈머(예: Consumer 2)에게 새로운 소유권을 부여하고 메시지 전송을 재개합니다.

이 과정에서 일시적인 처리 지연은 발생하지만, 단일 컨슈머 처리 원칙이 지켜지므로 순서 보장의 정합성은 안전하게 유지됩니다.

3. 새로운 컨슈머가 추가(Scale-out)되었을 때의 딜레마

가장 주의해야 할 아키텍처적 이슈는 트래픽 대응을 위해 새로운 Consumer 3가 큐에 접속했을 때 발생합니다.

일반적인 라운드 로빈(Round-Robin) 큐라면 즉시 새 컨슈머에게 메시지가 분배되겠지만, 메시지 그룹 환경에서는 기본적으로 적극적인 리밸런싱(Proactive Rebalancing)이 일어나지 않습니다. 이미 Consumer 1Consumer 2가 기존에 생성된 수많은 그룹의 소유권을 꽉 쥐고 있다면, 새로 들어온 Consumer 3는 기존 그룹의 메시지를 전혀 받지 못하고 유휴 상태(Idle)로 놀게 됩니다. Consumer 3는 오직 완전히 새로운 JMSXGroupID를 가진 메시지가 큐에 도착했을 때만 해당 신규 그룹의 소유권을 얻어 작업을 시작할 수 있습니다.

만약 브로커가 새 컨슈머에게 부하를 나누기 위해 기존에 Consumer 1이 소유하던 GROUP_A를 강제로 뺏어서 Consumer 3에게 넘긴다면 어떻게 될까요?
Consumer 1이 이미 가져간 GROUP_A의 1번 메시지를 아직 처리 중인데, 강제 리밸런싱으로 인해 Consumer 3GROUP_A의 2번 메시지를 동시에 처리하게 되는 순서 역전(Out-of-Order) 현상이 발생할 위험이 극도로 높아집니다. 브로커는 데이터의 정합성을 최우선으로 하므로 이 위험을 피하기 위해 기존 그룹을 강제로 재할당하지 않는 것입니다.

4. 메시지 그룹의 올바른 종료와 자발적 리밸런싱 유도

이러한 한계를 극복하고 트래픽 분산 효과를 제대로 누리기 위해서는, 애플리케이션 레벨에서 특정 비즈니스 트랜잭션이 끝났을 때 해당 그룹을 명시적으로 닫아주는(Close) 설계가 반드시 필요합니다.

  • JMSXGroupSeq 속성 활용: 생산자가 메시지를 보낼 때, 해당 비즈니스 로직의 마지막 메시지(예: 배송 완료 이벤트)에 JMSXGroupSeq = -1 (또는 브로커 설정에 따른 특정 값) 속성을 추가하여 전송합니다.
  • 브로커의 소유권 초기화: 브로커가 이 특별한 시퀀스 값이 포함된 메시지를 컨슈머에게 전달하고 정상적인 ACK를 받으면, 브로커는 내부 메모리에서 해당 JMSXGroupID의 매핑 정보를 즉시 삭제합니다.
  • 자원 최적화: 소유권이 삭제되었으므로 메모리 누수(Memory Leak)를 방지할 수 있습니다. 나중에 우연히 동일한 그룹 ID의 메시지가 다시 유입되더라도, 그때는 완전히 새로운 그룹으로 취급되어 큐에 연결된 모든 컨슈머 중 하나(새로 추가된 컨슈머 포함)에게 자유롭게 새로 할당될 수 있습니다.

5. 핵심 요약 및 아키텍처 고려사항

  1. 단절 시 자동 복구: 컨슈머가 죽으면 해당 그룹은 즉시 다른 살아있는 컨슈머로 재할당되어 처리가 이어집니다.
  2. 추가 시 지연 배분: 새 컨슈머가 추가되어도 기존에 이미 매핑이 완료된 활성 그룹들은 즉각적으로 리밸런싱되지 않으며, 새로운 그룹 ID가 유입될 때부터 배분이 시작됩니다.
  3. 명시적 종료 필수: 메모리 관리와 원활한 스케일 아웃 트래픽 분산을 위해 JMSXGroupSeq=-1을 활용하여 완료된 논리적 그룹을 브로커에서 지속적으로 해제해 주어야 합니다.

메시지 그룹은 순서 보장이라는 강력한 무기를 제공하지만, 브로커 내부의 소유권(Ownership) 바인딩 메커니즘을 제대로 이해하지 못하면 특정 노드에만 부하가 집중되는 병목 현상을 초래할 수 있습니다. 시스템 설계 시 메시지의 생명주기(Lifecycle)와 그룹의 종료 시점을 명확히 정의하는 것이 엔터프라이즈 메시징 최적화의 첫걸음입니다.

반응형