[Book - 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2] 4. 분산 메시지 큐
메시지 큐를 사용하며 어떤 이득을 얻을 수 있을까?
- 결합도 완화(decoupling)
- 메시지 큐를 사용하면 컴포넌트 사이의 강한 결합이 사라지므로 각각을 독립적으로 갱신 가능
- 규모 확장성 개선
- 메시지 큐에 데이터를 생산하는 producer와 큐에서 메시지를 소비하는 consumer 시스템 규모를 트래픽 부하에 맞게 독립적으로 늘릴 수 있음
- 예를 들어 트래픽이 많이 몰리는 시간에는 더 많은 consumer를 추가하여 처리 용량을 늘릴 수 있음
- 가용성 개선
- 시스템의 특정 컴포넌트에 장애가 발생해도 다른 컴포넌트는 큐와 계속 상호작용을 이어갈 수 있음
- 성능 개선
- 메시지 큐를 사용하면 비동기 통신이 쉽게 가능
- producer는 응답을 기다리지 않고도 메시지를 보낼 수 있고
- consumer는 읽을 메시지가 있을 때만 메시지를 소비
- 서로를 기다릴 필요 X
메시지 큐 vs 이벤트 스트리밍 플랫폼
- 메시지 큐: RocketMQ, ActiveMQ, RabbitMQ, ZeroMQ 등
- 스트리밍 기능을 추가하면 메시지를 반복적으로 소비할 수 있는 동시에 데이터의 장기 보관도 가능
- 데이터 추가만 가능한 로그를 통해 구현되어 있음 (이벤트 스트리밍 플랫폼과 유사)
- 설계가 단순해 질 수 있음
- 스트리밍 기능을 추가하면 메시지를 반복적으로 소비할 수 있는 동시에 데이터의 장기 보관도 가능
- 이벤트 스트리밍 플랫폼: Kafka, Pulsar
- 데이터 장기 보관, 메시지 반복 소비 등의 부가 기능 (이벤트 스트리밍 플랫폼에서만 이용 가능)
- 설계는 좀 더 까다로움
1단계: 문제 이해 및 설계 범위 확정
메시지 큐의 기본 기능
- producer: 메시지를 큐에 전송
- consumer: 큐에서 메시지를 꺼냄
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
메시지의 형태(format)와 평균 크기를 알려주실 수 있나요?
text만 지원하면 되나요? multimedia도 지원해야 하나요?
>> text 형태 메시지만 지원
>> 메시지의 크기는 수 KB 수준
메시지는 반복적으로 소비될 수 있어야 하나요?
>> 네, 하나의 메시지를 여러 소비자가 수신하는 것이 가능해야 함 (부가 기능)
>> 전통적인 분산 메시지 큐는 한 소비자라도 받아간 메시지는 지워버림
>> 따라서 같은 메시지를 여러 소비자에게 반복해야 전달할 수 없음
>> (이벤트 스트리밍 플랫폼 사용하면 될 듯?)
메시지는 큐에 전달된 순서대로 소비되어 햐나요?
>> 생산되 순서 그대로 소비 (부가 기능)
>> 전통적인 분산 메시지 큐는 보통 소비 순서 보증X
데이터의 지속성 보장? 기간은?
>> 2주 가정 (부가 기능)
>> 전통적인 분산 메시지 큐는 지속성 보관 보증X
지원해야 하는 생산자와 소비자 수는 어느 정도?
>> 많을 수록 좋음
어떤 메시지 전달 방식을 지원?
>> '최소 한 번' 방식은 반드시 지원
>> 이상적이기로는 그'최대ㅐ 한 번', '최소 한 번', '정확히 한 번' 모두 지원
>> 사용자가 설정 가능
목표로 삼아야 할 대역폭과 end-to-end 지연 시간은?
>> 로그 수집 등을 위해 사용할 수도 있어야 하므로 높은 수준의 대역폭 제공
>> 일반적인 메시지 큐가 지원하는 전통적 사용법도 지원해야 하니 낮은 전송 지연 필수
기능 요구사항
- producer는 MQ에 메시지 송신
- consumer는 MQ를 통해 메시지를 수신
- 메시지 반복 수신, 단 한 번만 수신하도록 설정 기능
- 오래된 이력 데이터 삭제 가능
- 메시지 크기 = KB 수준
- 메시지가 생산된 순서대로 소비자에게 전달
- 메시지 전달 방식은 최소 한 번, 최대 한 번, 정확히 한 번 가운데 설정 기능
비기능 요구사항
- 높은 대역폭과 낮은 전송 지연 가운데 하나를 설정으로 선택 가능하게 하는 기능
- 규모 확장성
- 이 시스템은 특성상 분산 시스템일 수 밖에 없으므로 메시지 양이 급증해도 처리 가능해야 함
- 지속성 및 내구성
- 데이터는 디스크에 지속적으로 보관되어야 하며 여러 노드에 복제되어야 함
전통적 MQ와 다른 점
RabbitMQ와 같은 전통적인 MQ는 이벤트 스트리밍 플랫폼처럼 메시지 보관 문제를 중요하게 다루지 않는다.
전통적인 큐는 메시지가 consumer에 전달되기 충분한 기간 동안만 메모리에 보관한다.
처리 용량을 넘어선 메시지는 디스크에 보관하긴 하는데 이벤트 스트리밍 플랫폼이 감당하는 용량보다는 아주 낮은 수준이다.
전통적인 MQ는 전달 순서도 보존하지 않는다.
2단계: 개략적 설계안 제시 및 동의 구하기
MQ의 기본 기능부터 살펴보자.
- producer는 메시지를 MQ에 발행
- consumer는 큐를 subscribe하고 구독한 메시지를 소비
- MQ는 producer와 consumer 사이의 결합을 느슨하게 하는 서비스로, 독립적인 운영 및 규모 확장을 가능하게 하는 역할 담당
- producer와 consumer는 모두 클라이언트/서버 모델 관점에서 보면 클라이언트고 서버 역할을 하는 것은 MQ이며, 이 클라이언트와 서버는 네트워크를 통해 통신
메시지 모델
가장 널리 쓰이는 메시지 모델은 p2p(일대일)와 pub-sub(발행-구독) 모델이다.
일대일 모델
전통적인 메시지 큐에서 흔히 발견되는 모델이다.
일대일 모델에서 큐에 전송된 메시지는 오직 한 소비자만 가져갈 수 있다.
소비자가 아무리 많아도 각 메시지는 오직 한 소비자만 가져갈 수 있다.
어떤 consumer가 메시지를 가져갔다는 사실을 큐에 알리면 해당 메시지는 큐에서 삭제된다.
이 모델이 데이터 보관을 지원하지 않는다.
반면 본 설계안은 메시지를 두 주 동안은 보관할 수 있도록 하는 지속성 계층(persistence layer)을 포함하면, 해당 계층을 통해 메시지가 반복적으로 소비될 수 있도록 한다.
발행-구독 모델
topic이라는 새로운 개념을 도입해야 한다.
topic은 메시지를 주제별로 정리하는 데 사용된다.
각 topic은 MQ 서비스 전반에 고유한 이름을 가진다.
메시지를 보내고 받을 때는 topic에 보내고 받게 된다.
topic에 전달된 메시지는 해당 topic을 구독하는 모든 consumer에 전달된다.
토픽, 파티션, 브로커
메시지는 topic에 보관된다.
topic에 보관되는 데이터의 양이 커져서 서버 한 대로는 감당하기 힘든 상황이 벌어지면 어떻게 될까?
파티션, 즉 샤딩 기법을 활용하는 것이다.
topic을 여러 파티션으로 분할한 다음에 메시지를 모든 피티션에 균등하게 나눠 보낸다. (파티션은 topic에 보낼 메시지의 작은 부분집합)
파티션은 메시지 큐 클러스터 내의 서버에 고르게 분산 배치한다.
파티션을 유지하는 서버는 보통 브로커라 부른다.
파티션을 브로커에 분산하는 것이 높은 규모 확장성을 달성하는 비결이다.
topic의 용량을 확장하고 싶으면 파티션 개수를 늘리면 된다.
각 topic 파티션은 FIFO 큐처럼 동작한다. (같은 파티션 안에서는 메시지 순서가 유지)
파티션 내에서의 메시지 위치는 offset이라고 한다.
producer가 보낸 메시지는 해당 topic의 파티션 가운데 하나로 보내진다.
이때 키가 없는 메시지는 무작위로 선택된 파티션으로 전송된다.
topic을 구독하는 consumer는 하나 이상의 파티션에서 데이터를 가져오게 된다.
topic을 구독하는 consumer가 여럿인 경우, 각 구독자는 해당 topic을 구성하는 파티션의 일부를 담당하게 된다.
이 consumer들을 해당 topic의 consumer group이라 부른다.
아래 그림은 브로커와 피티션을 갖춘 MQ 클러스터다.
소비자 그룹
소비자 그룹 내 소비자는 topic에서 메시지를 ㅅ비하기 위해 서로 협력한다.
하나의 소비자 그룹은 여러 topic을 구독할 수 있고 offset을 별도로 관리한다.
예를 들어, 큐 용례에 따라 과금용 그룹, 회계용 그룹 등으로 나눌 수 있다.
같은 그룹 내의 소비자는 메시지를 병렬로 소비할 수 있다.
- 소비자 그룹 1 = topic A 구독
- 소비자 그룹 2 = topic A, B 구독
- topic A = 그룹 1, 2가 구독
- 해당 topic 내 메시지는 그룹 1, 2 내의 소비자에게 전달
- 발행-구독 모델 지원
데이터를 병렬로 읽으면 대역폭 측면에서는 좋지만,
같은 파티션 안에 있는 메시지를 순서대로 소비할 수는 없다.
어떤 파티션의 메시지는 한 그룹 안에서는 오직 한 소비자만 읽을 수 있도록 하면 해결할 수 있다.
다만 그 경우, 그룹 내 소비자의 수가 구독하는 topic의 파티션 수보다 크면 어떤 소비자는 해당 topic에서 데이터를 읽지 못하게 된다.
예를 들어 그룹 2에 있는 소비자 3은 topic B의 메시지를 수신할 수 없다.
같은 그룹 내의 소비자 4가 이미 소비하도록 되어 있기 때문이다.
모든 소비자를 같은 소비자 그룹에 두면 같은 파티션의 메시지는 오직 한 소비자만 가져갈 수 있으므로 결국 일대일 모델에 수렵하게 된다.
파티션은 가장 작은 저장 단위이므로 미리 충분한 파티션을 할당해 두면 파티션의 수를 동적으로 늘리는 일은 피할 수 있다.
처리 용량을 늘리려면 그냥 소비자를 더 추가하면 된다.
개략적 설계안
- 클라이언트
- producer
- 메시지를 특정 topic으로 전송
- consumer group
- topic 구독
- 메시지 소비
- producer
- 핵심 서비스 및 저장소
- 브로커
- 파티션들을 유지
- 하나의 파티션은 특정 topic에 대한 메시지의 부분 집합을 유지
- 저장소
- 데이터 저장소: 메시지는 파티션 내 데이터 저장소에 보관
- 상태 저장소: 소비자 상태는 이 저장소에 유지
- 메타데이터 저장소: 토픽 설정, 토픽 속성(property) 등은 이 저장소에 유지
- 조정 서비스
- 서비스 탐색: 어떤 브로커가 살아있는지 알려줌
- 리더 선출: 브로커 가운데 하나는 컨트롤러 역할을 담당해야 하며, 한 클러스터에는 반드시 활성 상태 컨트롤러가 하나 있어야 한다. 이 컨트롤러가 파티션 배치를 책임진다.
- ZooKeeper나 etcd가 보통 컨트롤러 선출을 담당하는 컴포넌트로 널리 이용된다.
- 브로커
3단계: 상세 설계
데이터의 장기 보관 요구사항을 만족하면서 높은 대역폭을 제공하기 위해 세 가지 중요한 결정을 내렸다.
- 회전 디스크
- 높은 순차 탐색 성능과 현대적 운영체제가 제공하는 적극적 디스크 캐시 전략을 잘 이용하는 디스크 기반 자료 구조 활용
- 메시지가 생산자로부터 소비자에게 전달되는 순간까지 아무 수정 없이도 전송이 가능하도록 하는 메시지 자료 구조를 설계하고 활용
- 전송 데이터의 양이 막대한 경우에 메시지 복사에 드는 비용을 최소화하기 위함
- 일괄 처리를 우선하는 시스템을 설계
- 소규모의 I/O가 많으면 높은 대역폭을 지원하기 어려움
- 생산자는 메시지를 일괄 전송
- MQ는 그 메시지들을 더 큰 단위로 묶어 보관
- 소비자도 가능하면 메시지를 일괄 수신
데이터 저장소
메시지를 어떻게 지속적으로 저장할지 우선 MQ의 트래픽 패턴부터 살펴보자.
- 읽기와 쓰기가 빈번하게 일어난다.
- 갱신/삭제 연산은 발생하지 않는다.
- 전통적인 MQ는 메시지가 신속하게 전달되지 못해 큐가 제때 비워지지 않는 경우를 제외하면 메시지를 지속적으로 보관하지 않는다.
- 순차적인 읽기/쓰기가 대부분이다.
선택지 1: DB
- RDB
- topic별로 테이블 생성
- topic에 보내는 메시지는 해당 테이블에 새로운 레코드로 추가
- NoSQL
- topic별로 collection 생성
- topic에 보내는 메시지는 하나의 문서가 됨
DB라면 데이터 저장 요구사항을 맞출 수는 있다.
하지만 이상적인 방법일 수는 없다.
읽기/쓰기 연산이 동시에 대규모로 빈번하게 발생하는 상황을 잘 처리하는 DB는 설계하기 어렵다.
따라서 오히려 병목이 될 수도 있다.
선택지 2: 쓰기 우선 로그(Write-Ahead Log, WAL)
WAL은 새로운 항목이 추가되기만 하는 일반 파일이다.
MySQL의 복구 로그(redo log)가 WAL로 구현되어 있고, 주키펃도 해당 기술을 활용한다.
지속성을 보장해야 하는 메시지는 디스크에 WAL로 보관할 것을 추천한다.
WAL에 대한 접근 패턴은 읽기/쓰기 전부 수차적이다.
접근 패턴이 순차적일 때 디스크는 아주 좋은 성능을 보인다.
회전식 디스크 기반 저장장치는 큰 용량을 저렴한 가격에 제공한다.
새로운 메시지는 파티션 꼬리 부분에 추가되며, offset은 그 결과로 점진적으로 증가한다.
가장 쉬운 방법은 로그 파일 줄 번호를 offset으로 사용하는 것이다.
하지만 파일의 크기도 무한정 커질 수는 없으니, 세그먼트 단위로 나누는 것이 바람직하다.
활성 세그먼트에만 메시지를 기록하고, 크기 한계에 도달하면 해당 세그먼트가 비활성화가 되며 새 활성 세그먼트가 생성된다.
비활성 세그먼트는 읽기 요청만 처리한다.
같은 파티션에 속한 세그먼트 파일은 파티션 폴더 아래에 저장된다.
디스크 성능 관련 유의사항
데이터 장기 보관에 대한 요구사항으로 인해 디스크 드라이브를 활용하여 다량의 데이터를 보관한다.
회전식 디스크가 정말로 늘지는 것은 데이터 접근 패턴이 random일 때다.
순차적 데이터 접근 패턴을 적극 활용하는 디스크 기반 자료 구조를 사용하면,
RAID로 구성된 현대적 디스크 드라이브에서 수백 MB/sec 수준의 일기/쓰기 성능을 달성하는 것은 어렵지 않다.
현대적 운영체제는 디스크 데이터를 메모리에 아주 적극적으로 캐시한다.
WAL도 OS가 제공하는 디스크 캐시 기능을 적극적으로 활용한다.
메시지 자료 구조
메시지 구조는 높은 대역폭 달성의 열쇠다.
메시지 자료 구조는 생산자, MQ, 소비자 사이의 계약이다.
메시지 키
메시지의 키는 파티션을 정할 때 사용된다.
키가 주어지지 않은 메시지의 파티션은 무작위적으로 결정된다.
키가 정해진 경우 파티션은 hash(key) % numPartitions의 공식에 따라 결정된다.
키는 파티션 번호가 아니라는 점에 유의하자.
키에는 비즈니스 관련 정보가 담기는 것이 보통이다.
파티션 번호는 MQ 내부적으로 사용되는 개념이므로 클라이언트에게 노출되어서는 안된다.
메시지 값
메시지 값은 메시지의 내용, 즉 payload를 말한다.
메시지 값은 일반 텍스트일 수도 있고 압축된 이진 블록일 수도 있다.
일괄 처리
생산자, 소비자, MQ는 메시지를 가급적 일괄 처리한다.
일괄 처리가 성능 개선에 중요한 이유는 다음과 같다.
- 운영체제로 하여금 여러 메시지를 한 번의 네트워크 요청으로 전송할 수 있도록 하기 때문에 값비싼 네트워크 왕복 비용을 제거할 수 있다.
- 브로커가 여러 메시지를 한 번에 로그에 기록하면 더 큰 규모의 순차 쓰기 연산이 발생하고 운영체제가 관리하는 디스크 캐시에서 더 큰 규모의 연속된 공간을 점유하게 된다.
- 그 결과로 더 높은 디스크 접근 대역폭을 달성할 수 있다.
그러나 높은 대역폭과 낮은 응답 지연(latency)은 동시에 달성하기 어려운 목표다.
시스템이 낮은 latency가 중요한 전통적 MQ로 이용된다면 일괄처리 메시지 양은 낮춘다.
이로인해 디스크 성능은 다소 낮아진다.
처리량을 높여야 한다면 topic당 파티션의 수는 늘린다.
그래야 낮아진 순차 쓰기 연산 대역폭을 벌충할 수 있다.
생산자 측 작업 흐름
생산자가 어떤 파티션에 메시지를 보내야 한다면, 어느 브로커에 연결해야 할까?
한 가지 해결책은 라우팅 계층을 도입하는 것이다.
브로커를 여러 개로 복제하여 운용하는 경우에 메시지를 받을 적절한 브로커는 바로 leader 브로커다.
아래 그림에서 생산자는 토픽-A의 파티션-1로 메시지를 보내고자 한다.
- 우선 생산자는 메시지를 라우팅 계층으로 전송
- 라우팅 계층은 메타데이터 저장소에서 사본 분산 계획응ㄹ 읽어 자기 캐시에 보관
- 메시지가 도착하면 라우팅 계층은 파티션-1의 리더 사본에 전송
- 리더 사본이 우선 메시지를 받고, 해당 리더를 따르는 다른 사본은 해당 리더로부터 데이터를 받음
- ‘충분한’ 수의 사본이 동기화되면, 리더는 데이터를 디스크에 기록
- 데이터가 소비 가능 상태가 되는 시점
- 기록이 끝나고 나면 생산자에게 회신
리더와 사본이 필요한 이뉴는 장애 감내가 가능한 시스템을 만들기 위해서다.
지금까지 설명한 방법은 몇 가지 단점이 있다.
- 라우팅 계층을 도입하면 거쳐야 할 네트워크 노드가 하나 더 늘어나게 되므로 오버헤드가 발생하여 네트워크 전송 지연이 늘어남
- 일괄 처리가 가능하면 효율을 많이 높일 수 있는데 그런 부분은 고려하지 않은 설계
아래 그림은 이 무제를 고려하여 수정한 설계안이다.
라우팅 계층을 생산자 클라이언트 라이브러리의 일부로 생산자에 설치하여 생산자 내부로 편입시키고, 버퍼를 도입한다.
이렇게 하면 몇 가지 장점이 있다.
- 네트워크를 거칠 필요가 줄어들어 전송 지연도 줄어
- 생산자는 메시지를 어느 파티션에 보낼지 결정하는 자신만의 로직을 가질 수 있음
- 전송할 메시지를 버퍼 메모리에 보관했다가 목적지로 일괄 전송하여 대역폭 상승
얼마나 많은 메시지를 일괄 처리하는 것이 좋을까?
일괄 처리할 메시지의 양을 늘리면 > 대역폭은 늘어나지만 > 응답 속도는 느려진다.
양을 줄이면 > 지연은 줄어들지만 > 대역폭은 손해를 본다.
생산자는 MQ의 용도를 감안하여 일괄 처리 메시지 양을 조정해야 한다.
소비자 측 작업 흐름
소비자는 특정 파티션의 offset을 주고 해당 위치에서부터 이벤트를 묶어 가져온다.
푸시 vs 풀
브로커가 데이터를 소비자에게 보낼 것이냐 아니면 소비자가 브로커에서 가져갈 것이냐 하는 부분을 중요하게 따져봐야 한다.
푸시 모델
- 장점
- 낮은 지연: 브로커는 메시지를 받는 즉시 소비자에게 송신
- 단점
- 소비자가 메시지를 처리하는 속도가 생산자가 메시지를 만드는 속도보다 느릴 경우, 소비자에게 큰 부하가 걸릴 가능성
- 생산자가 데이터 전송 속도를 좌우하므로, 소비자는 항상 그에 맞는 처리가 가능한 컴퓨팅 자원을 준비
풀 모델
- 장점
- 메시지를 소비하는 속도는 소비자가 알아서 결정
- 따라서 어떤 소비자는 메시지를 실시간으로 가져가고 어떤 소비자는 일괄로 가져가는 등의 구성이 가능
- 메시지를 소비하는 속도가 생산 속도보다 느려지면, 소비자를 늘려 해결 가능
- 아니면 생산 속도를 따라잡을 떄까지 대기
- 일괄 처리에 적합
- 메시지를 소비하는 속도는 소비자가 알아서 결정
- 단점
- 브로커에 메시지가 없어도 소비자는 계속 데이터를 끌어가려 시도
- 소비자 측 컴퓨팅 자원 낭비
- 이 문제를 극복하기 위해 많은 MQ가 long polling 모드 지원
- 브로커에 메시지가 없어도 소비자는 계속 데이터를 끌어가려 시도
이런 이유들로 대부분의 MQ는 푸시 모델 대신 풀 모델을 지원한다.
풀 모델의 동작 흐름도는 아래와 같다.
- 그룹-1에 합류하고 토픽-A를 구독하길 원하는 새로운 소비자는 그룹 이름을 해싱하여 접속할 브로커 노드를 찾는다.
- 따라서 같은 그룹의 모든 소비자는 같은 브로커에 접속
- 이런 브로커는 해당 소비자 그룹의 코디네이터
- 소비자 그룹의 조정 작업만 담당
- 반면 조정 서비스는 브로커 클러스터 조정 작업을 담당
- 따라서 같은 그룹의 모든 소비자는 같은 브로커에 접속
- 코디네이터는 해당 소비자를 그룹에 참여시키고 파티션-2를 해당 소비자에 할당
- 파티션 배치 정책은 RR이나 범위 기반 정책 등
- 소비자는 마지막으로 소비한 offset 이후 메시지를 가져온다.
- offset 정보는 상태 저장소에 있다.
- 소비자는 메시지를 처리하고 새로운 offset을 브로커에 보낸다.
- 데이터 처리와 offset 갱신 순서는 메시지 전송 시맨틱에 영향을 미친다.
소비자 재조정
어떤 소비자가 어떤 파티션을 책임지는지 다시 정하는 프로세스다.
이 프로세스는 새로운 소비자가 합류하거나, 기존 소비자가 그룹을 떠나거나, 어떤 소비자에 장애가 발생하거나, 파티션들이 조정되는 경우에 시작될 수 있다.
위 절차에 코디네이터가 중요한 역할을 한다.
코디네이터는 소비자 재조정을 위해 소비자들과 통신하는 브로커 노드다.
코디네이터는 소비자로부터 오는 heartbeat 메시지를 살피고 각 소비자의 파티션 내 offset 정보를 관리한다.
- 각 소비자는 특정 그룹에 속한다.
- 해당 그룹 전담 코디네이터는 그룹 이름을 해싱하면 찾을 수 있다.
- 같은 그룹의 모든 소비자는 같은 코디네이터에 연결
- 코디네이터는 자신에 연결한 소비자 목록을 유지
- 이 목록에 변화가 생기면 코디네이터는 해당 그룹의 새 리더를 선출
- 새 리더는 새 파티션 배치 계획을 만들고 코디네이터에게 전달
- 코디네이터는 해당 계획을 그룹 내 다른 모든 소비자에게 알림
분산 시스템이므로 소비자는 네트워크 이슈를 비롯한 다양한 장애를 겪을 수 있다.
코디네이터 관점에서 보면 소비자에게 발생한 장애는 heartbeat 신호가 사라지는 현상을 통해 감지할 수 있다.
소비자 장애를 감지하면 코디네이터는 재조정 프로세스를 시작하여 파티션을 재배치한다.
재조정 시나리오: 새로운 소비자의 합류
새로운 소비자 B가 그룹에 합류한 경우를 보자. (그룹 내 소비자 수 = 2, 구독하는 토픽에 파티션 = 4로 가정)
- 시작 시점에는 그룹 안에는 소비자 A만 있는 상태
- 소비자 A는 모든 파티션의 메시지를 소비하며 코디네이터에게 지속적으로 heartbeat 메시지를 송신
- 소비자 B가 그룹에 합류 요청
- 코디네이터는 소비자 재조정이 필요한 시점이라고 판단하고 모든 소비자에게 그 사실을 수동적으로 통지
- 즉, 소비자 A의 heartbeat 메시지가 왔을 때 그 응답으로 그룹에 다시 합류하라고 알림
- 모든 소비자가 그룹에 다시 합류하면 코디네이터는 그 가운데 하나를 리더로 선출하고 모든 소비자에게 그사실을 알림
- 리더는 파티션 배치 계획을 생성한 다음 코디네이터에게 송신
- 리더 외의 소비자는 코디네이터에게 요청하여 파티션 배체 계획을 받아옴
- 소비자는 자신에게 배치된 파티션에서 메시지를 가져오기 시작
재조정 시나리오: 기존 소비자의 이탈
- 소비자 A와 소비자 B는 같은 소비자 그룹 멤버
- 소비자 A가 가동 중단이 필요하여 그룹 탈퇴를 요청
코디네이터는 소비자 재조정이 필요한 시점으로 판단하고 소비자 B의 박동 메시지를 수신하면 그룹에 다시 합류할 것을 지시
(나머지 절차는 새로운 소비자의 합류 시나리오와 거의 동일)
- 모든 소비자가 그룹에 다시 합류하면 코디네이터는 그 가운데 하나를 리더로 선출하고 모든 소비자에게 그사실을 알림
- 리더는 파티션 배치 계획을 생성한 다음 코디네이터에게 송신
- 리더 외의 소비자는 코디네이터에게 요청하여 파티션 배체 계획을 받아옴
- 소비자는 자신에게 배치된 파티션에서 메시지를 가져오기 시작
재조정 시나리오: 기존 소비자에 장애 발생
- 소비자 A와 B는 같은 소비자 그룹 멤버로, 지속적으로 코디네이터에게 heartbeat 메시지를 전송
- 소비자 A에 장애가 발생하면 더 이상의 heartbeat 메시지는 코디네이터에게 전달되지 못한다.
- 코디네이터는 일정 시간 동안 해당 상황이 지속되면 해당 소비자가 사라진 것으로 판단
코디네이터는 소비자 재조정 프로세스를 개시
(나머지 절차는 새로운 소비자의 합류 시나리오와 거의 동일)
- 모든 소비자가 그룹에 다시 합류하면 코디네이터는 그 가운데 하나를 리더로 선출하고 모든 소비자에게 그사실을 알림
- 리더는 파티션 배치 계획을 생성한 다음 코디네이터에게 송신
- 리더 외의 소비자는 코디네이터에게 요청하여 파티션 배체 계획을 받아옴
- 소비자는 자신에게 배치된 파티션에서 메시지를 가져오기 시작
상태 저장소
MQ 브로커의 상태 저장소에는 아래와 같은 정보가 저장된다.
- 소비자에 대한 파티션의 배치 관계
- 각 소비자 그룹이 각 파티션에서 마지막으로 가져간 메시지의 offset
그룹- 1의 한 소비자가 파티션의 메시지를 순서대로 읽은 후 마지막 읽어간 메시지의 offset을 6으로 갱신했다.
즉, 6을 포함하여 그 앞 모든 메시지는 이미 읽어갔다는 뜻이다.
해당 소비자에 장애가 생기면 같은 그룹의 새로운 소비자가 이어받아 해당 위치 다음부터 메시지를 읽어갈 것이다.
소비자 상태 정보 데이터가 이용되는 패턴은 다음과 같다.
- 읽기와 쓰기가 빈번하게 발생하지만 양은 많지 않다.
- 데이터 갱신은 빈번하게 일어나지만 삭제되는 일은 거의 없다.
- 읽기와 쓰기 연산은 무작위적 패턴을 보인다.
- 데이터의 일관성이 중요하다.
데이터 일관성 및 높은 읽기/쓰기 속도에 대한 요구사항을 고려했을 때, 주키퍼 같은 키-값 저장소를 사용하는 것이 바람직해 보인다.
메타데이터 저장소
메타데이터 저장소에는 토픽 설정이나 속성 정보를 보관한다.
파티션 수, 메시지 보관 기간, 사본 배치 정보 등이 이에 해당한다.
메타데이터는 자주 변경되지 않으며 양도 적다.
하지만 높은 일관성을 요구한다.
이런 데이터의 보관에는 주키퍼가 적절하다.
주키퍼
주키퍼는 계층적 키-값 저장소 기능을 제공하는, 분산 시스템에 필수적인 서비스이다.
보통 분산 설정 서비스, 동기화 서비스, 이름 레지스트리 등으로 이용된다.
아래 그림은 주키퍼를 사용한 단순화된 설계이다.
- 메타데이터와 상태 저장소는 주키퍼를 이용해 구현
- 브로커는 이제 메시지 데이터 저장소만 유지
- 주키퍼가 브로커 클러스터의 리더 선출 과정을 도움
복제
분산 시스템에서 하드웨어 장애는 흔한 일이므로 무시해서는 안된다.
디스크에 손상이나 영구적 장애가 발생하면 데이터는 사라진다.
이런 문제를 해결하고 높은 가용성을 보장하기 위해 전통적으로 많이 사용된 방법이 바로 복제다.
각 파티션은 3개의 사본은 갖고, 이 사본들은 서로 다른 브로커 노드에 분산되어 있다.
- 생산자는 파티션에 메시지를 보낼 때 리더에게만 보낸다.
- 다른 사본은 리더에서 새 메시지를 지속적으로 가져와 동기화한다.
- 메시지를 완전히 동기화한 사본의 개수가 지정된 임계값을 넘으면 리더는 생산자에게 메시지를 잘 받았다는 응답을 보낸다.
사본을 파티션에 어떻게 분산할지 기술하는 것을 사본 분산 계획이라고 한다.
- 토픽-A의 파티션-1
- 사본 3개
- 리더 = 브로커-1
- 단순 사본 = 브로커-2, 브로커-3
- 토픽-A의 파티션-2
- 사본 3개
- 리더 = 브로커-2
- 단순 사본 = 브로커-3, 브로커-4
- 토픽-B의 파티션-1
- 사본 3개
- 리더 = 브로커-3
- 단순 사본 = 브로커-4, 브로커-1
사본 분산 계획은 누가 어떻게 만드는가?
조정 서비스의 도움으로 브로커 노드 가운데 하나가 리더로 선출되면, 해당 리더 브로커 노드가 사본 분산 계획을 만들고 메타데이터 저장소에 보관한다.
다른 모든 브로커는 해당 계획대로 움직이면 된다.
사본 동기화
어떤 한 노드의 장애로 메시지가 소실되는 것을 막기 위해 메시지는 여러 파티션에 두며, 각 파티션은 다시 여러 사본으로 복제한다.
메시지는 리더로만 보내고, 다른 단순 사본은 리더에서 메시지를 가져가 동기화한다.
풀어야 할 마지막 문제는 그 모두를 어떻게 동기화 시킬 것인가 하는 점이다.
동기화된 사본(In-Sync Replicas, ISR)은 리더와 동기화된 사본을 일컫는 용어로,
‘동기화되었다’는 것이 무엇을 의미하느냐는 토픽의 설정에 따라 달라진다.
예를 들어 replica.lag.max.messages=4 로 설정되어 있다고 가정하고,
단순 사본에 보관된 메시지 개수와 리더 사이의 차이가 3이라면 해당 사본은 여전히 ISR일 것이다.
리더는 항상 ISR 상태다.
ISR이 어떻게 동작하는지 알아보자.
- 리더 사본의 합의 오프셋(committed offset) 값 = 13
- 이 리더에 두 개의 새로운 메시지가 기록되었지만, 아직 사본 간의 합의가 이루어진 것은 아니다.
- 합의 오프셋 의미 = 이 오프셋 이전에 기록된 모든 메시지는 이미 ISR 집합 내 모든 사본에 동기화가 끝났다는 것
- 사본-2와 사본-3은 이미 리더 상태를 동기화하여 ISR이 되었으므로 새로운 메시지를 가져올 수 없다.
- 사본-4는 리더 상태를 충분히 따라잡지 못했으므로 아직 ISR이 아니다.
ISR이 필요한 이유는 무엇인가?
ISR은 성능과 영속성 사이의 타협점이다.
생산자가 보낸 어떤 메시지도 소실하지 않는 가장 안전한 방법은 생산자에게 메시지를 잘 받았다는 응답을 보내기 전에 모든 사본을 동기화하는 것이다.
하지만 어느 사본 하나라도 동기화를 신속하게 처리하지 못하게 되면 파티션 전부가 느려지거나 아예 못 쓰게 되는 일이 벌어지고 말 것이다.
ACK=all
ACK=all로 설정되어 있으면 생산자는 모든 ISR이 메시지를 수신한 뒤에 ACK 응답을 받는다.
가장 느린 ISR의 응답을 기다려야 하므로 메시지를 보내기 위한 시간이 길어진다.
하지만 메시지의 영속성 측면에서는 가장 좋은 구성이다.
ACK=1
ACK=1로 설정해두면 생산자는 리더가 메시지를 저장하고 나면 바로 ACK 응답을 받는다.
데이터가 동기화될 때까지 기다리지 않으니 응답 지연은 개선된다.
하지만 메시지 ACK을 보낸 직후 리더에 장애가 생기면 해당 메시지는 다른 사본에 반영되지 못했으므로 복구할 길 없이 소실된다.
이런 구성은 때로 데이터가 사라져도 상관없는 대신 낮은 latency를 보장해야 하는 시스템에 적합하다.
ACK=0
ACK=0으로 설정하면 생산자는 보낸 메시지에 대한 수신 확인 메시지를 기다리지 않고 계속 메시지를 전송하며 어떤 retry도 하지 않는다.
낮은 latency를 달성하기 위해 메시지 손실은 감수하는 구성이다.
지표(metric) 수집이나 데이터 로깅 등 처리해야 하는 메시지의 양이 많고 때로 데이터 손실이 발생해도 상관 없는 경우에 좋다.
가장 쉬운 구성은 소비자로 하여금 리더에서 메시지를 읽어가도록 하는 것이다.
그러나 리더 사본에 요청이 너무 몰리면 어떻게 될까?
왜 ISR 요건을 만족하는 사본에서 메세지를 가져가지 않는 것인가?
- 설계 및 운영이 단순
- 특정 파티션의 메세지는 같은 소비자 그룹 안에서는 오직 한 소비자 그룹 안에서는 오직 한 소비자만 읽어갈 수 있으므로 리더 사본에 대한 연결은 많지 않음
- 아주 인기 있는 토픽이 아니라면 리더 사본에 대한 연결의 수는 그렇게 많지 않음
- 아주 인기 있는 토픽의 경우에는 파티션 및 소비자 수를 늘려 규모를 확장하면 됨
리더 사본에서 메시지를 가져가는 것이 바람직하지 않은 용례도 있다.
- 소비자의 위치가 리더 사본이 존재하는 데이터 센터와 다른 지역 → 읽기 성능이 나빠짐
- 지역적으로 가까운 ISR 사본에서 메시지를 읽는 선택지를 고려해볼 수 있음
ISR은 아주 중요하다.
어떤 사본이 ISR인지 아닌지 어떻게 판별 가능할까?
- 보통 각 파티션 담당 리더는 자기 사본들이 어느 메시지까지 가져갔는지 추적하여 ISR 목록을 관리
규모 확장성
주요 시스템 컴포넌트의 규모 확장성을 알아보자.
생산자
생산자는 소비자에 비해 개념적으로는 훨씬 간단하다.
그룹 단위의 조정에 가담할 필요가 전혀 없기 때문이다.
생산자의 규모 확장성은 새로운 생산자를 추가하거나 삭제함으로써 쉽게 달성할 수 있다.
소비자
소비자 그룹은 서로 독립적이므로 새 소비자 그룹은 쉽게 추가하고 삭제할 수 있다.
같은 소비자 그룹 내의 소비자가 새로 추가/삭제되거나 장애로 제거되어야 하는 경우는 재조정 메커니즘이 맡아 처리한다.
브로커
브로커 노드의 장애가 어떻게 복구되는지 알아보자.
- 4개의 브로커가 있고, 파티션 분산 계획은 다음과 같다고 가정
- 토픽-A의 파티션-1: 사본은 각각 브로커-1(리더), 2, 3에 존재
- 토픽-A의 파티션-2: 사본은 각각 브로터-2(리더), 3, 4에 존재
- 토픽-B의 파티션-1: 사본은 각각 브로커-3(리더), 4, 1에 존재
- 브로커-3에 장애가 발생하여 해당 노드의 모든 파티션이 소실 -> 이때 파티션 분산 계획은 다음과 같이 변경
- 토픽-A의 파티션-1: 사본은 각각 브로커-1(리더), 2에 존재
- 토픽-A의 파티션-2: 사본은 각각 브로터-2(리더), 4에 존재
- 토픽-B의 파티션-1: 사본은 각각 브로커-4, 1에 존재
- 브로커 컨트롤러는 브로커-3이 사라졌음을 감지하고, 남은 브로커 노드를 위해 다음과 같은 파티션 분산 계획을 생성
- 토픽-A의 파티션-1: 사본은 각각 브로커-1(리더), 2, 4(신규)에 존재
- 토픽-A의 파티션-2: 사본은 각각 브로터-2(리더), 4, 1(신규)에 존재
- 토픽-B의 파티션-1: 사본은 각각 브로커-4(리더), 1, 2(신규)에 존재
- 새로 추가된 사본은 단순 사본으로서, 리더에 보관된 메시지를 따라잡는 동작을 개시
브로커의 결함 내성을 높이기 위해서는 다음과 같은 사항도 추가로 고려해야 한다.
- 메시지가 성공적으로 합의(committed) 되었다고 판단하려면 얼마나 맣은 사본에 메시지가 반영되어야 하는가?
- 그 수치가 높으면 높을수록 안전
- 하지만 latency와 안정성 사이의 균형을 찾을 필요가 있음
- 파티션의 모든 사본이 같은 브로커 노드에 있으면 해다아 노드에 장애가 발생할 경우 해당 파티션은 완전 소실
- 따라서 같은 노드에 데이터를 복제하는 것은 자원 낭비
- 사본은 같은 노드에 두면 안됨
- 파티션의 모든 사본에 문제가 생기면 해당 파티션의 데이터는 영원히 사라짐
- 사본 수와 사본 위치를 정할 때는 데이터 안정성, 자원 유지에 드는 비용, latency 등을 고려
- 사본은 여러 데이터 센터에 분산하는 것이 안전하지만, 데이터 동기화 때문에 latency와 비용 상승
- 데이터 미러링을 도입하여 데이터 센터 간 데이터 복사를 용이하게하는 것도 방법
브로커의 규모 확장성 문제의 해법으로 브로커 노드가 추가되거나 삭제될 때 사본을 재배치했다.
하지만 그보다 나은 방법이 있다.
브로커 컨트롤러로 하여금 한시적으로 시스템에 설정된 사본 수보다 많은 사본을 허용하도록 하는 것이다.
새로 추가된 브로커 노드가 기존 브로커 상태를 따라작고 나면 더 이상 필요 없느 노드는 제거하면 된다.
- 최초 구성
- 브로커 3개
- 파티션 2개
- 파티션당 3개의 사본
- 새로운 브로커-4 추가
- 브로커 컨트롤러는 파티션-2의 사본 분산 계획을 (2, 3, 4)로 변경 결정
- 브로커-4에 추가된 새 사본은 리더인 브로커-2의 파티션에서 메시지를 가져오기 시작
- 이때 파티션-2의 사본 수는 한시적으로 3보다 큼
- 브로커-4의 사본이 리더의 상태를 완전히 따라잡으면 브로커-1에 있는 불필요한 사본은 삭제
이 절차를 밟으면 브로커를 추가하는 도중에 발생할 수 있는 데이터 손실을 피할 수 있다.
브로커를 제거할 때도 비슷한 방법을 적용하면 안전하게 제거할 수 있다.
파티션
토픽의 규모를 늘리거나, 대역폭을 조정하거나, 가용성과 대역폭 사이의 균형을 맞추는 등의 운영상 이유로 파티션의 수를 조정해야 하는 일이 생길 수 있다.
생산자는 브로커와 통신할 때 그 사실을 통지 받으며, 소비자는 재조정을 시행한다.
따라서 파티션 수의 조정은 생산자와 소비자의 안정성에는 영향을 끼치지 않는다.
파티션의 수가 달라지면 데이터 저장 계층에 무슨 일이 일어나는지를 중점적으로 살펴보자.
토픽에 새로운 파티션이 추가된 사례다.
- 지속적으로 보관된 메시지는 여전히 기존 파티션에 존재하며 해당 데이터는 이동 X
- 새로운 파티션이 추가되면(피티션-3) 그 이후 오는 메시지는 3개 파티션 전부에 지속적으로 보관
따라서 파티션을 늘리면 간단히 토픽의 규모를 늘릴 수 있다.
반면 파티션 삭제 절차는 좀 더 까다롭다.
- 파티션-3을 퇴역(decommission)시킨다는 결정이 내려지면 새로운 메시지는 다른 파티션에만 보관(파티션-1, 파티션-2)
- 퇴역된 파티션은 바로 제거하지 않고 일정시간 동안 유지
- 해당 파티션의 데이터를 읽고 있는 소비자가 있을 수 있음
- 해당 유지 기간이 지나고 나면 데이터 삭제 → 저장 공간 반환
- 따라서 파티션을 줄여도 저장 용량은 신속하게 늘지 않음
- 파티션 퇴역 후 실제로 제거가 이뤄지는 시점까지 생산자는 메시지를 남은 두 파티션으로만 발행
- 소비자는 세 파티션 모두에서 메시지를 읽음
- 실제로 파티션이 제거되는 시점이 오며 생산자 그룹은 재조정 작업을 개시
메시지 전달 방식
분산 MQ가 지원해야 하는 다양한 메시지 전달 방식에 대해 살펴보자.
최대 한 번(at-most once)
메시지가 전달 과정에 소실되더라도 다시 전달되는 일은 없다.
- 생산자는 토픽에 비동기적으로 메시지를 보내고, 수신응답을 기다리지 않는다.(ACK=0)
- 메시지 전달이 실패해도 retry X
- 소비자는 메시지를 읽고 처리하기 전에 offset부터 갱신
- offset이 갱신된 직후에 소비자가 장애로 죽으면 메시지는 다시 소비 X
이 전달 방식은 지표 모니터링 등, 소량의 데이터 손실은 감수할 수 있는 애플리케이션에 적합하다.
최소 한 번(at-least once)
같은 메시지가 한 번 이상 전달될 수는 있으나 메시지 소실은 발생하지 않는 전달 방식이다.
- 생산자는 메시지를 동기적/비동기적으로 보낼 수 있으며, ACK=1 또는 ACK=all의 구성을 이용
- 즉, 메시지가 브로커에게 전달되었음을 반드시 확인
- 메시지 전달이 실패하거나 타임아웃이 발생한 경우에는 계속 retry
- 소비자는 데이터를 성공적으로 처리한 뒤에만 offset을 갱신
- 메시지 처리가 실패한 경우에는 메시지를 다시 가져오므로 데이터가 손실되는 일 X
- 메시지를 처리한 소비자가 미처 offset을 갱신하지 못하고 죽었다가 다시 시작하면 메시지는 중복 처리
- 메시지는 브로커나 소비자에게 한 번 이상 전달 될 수 있다.
이 방식을 채택하면 메시지가 소실되는 일은 없지만 같은 메시지가 여러 번 전송될 수 있다.
정확히 한 번
구현하기 가장 까다로운 전송 방식이다.
- 사용자 입장에서는 편리
- 시스템의 성능 및 구현 복잡도 측면에서는 큰 대가 지불
지불, 매매, 회계 등 금융 관련 응용에는 이 전송 방식이 적합하다.
고급 기능
메시지 필터링
토픽은 같은 유형의 메시지를 담아 처리하기 위해 도입된 논리적 개념이다.
어떤 소비자 그룹에서 토픽 가운데서도 특정한 세부/하위 유형의 메시지에만 관심이 있다면?
이런 요구사항을 처리하는 한 가지 방법은 토픽을 분리하는 것이지만 다음과 같은 우려가 있다.
- 다른 시스템에도 비슷할 필요가 있을 수 있다.
- 그때마다 전용 토픽을 만들 것인가?
- 같은 메시지를 여러 토픽에 저장하는 것은 자원 낭비
- 새로운 소비자 측 요구사항이 등장할 때마다 생산자 구현을 바꿔야 할 수 있다.
- 생산자와 소비자 사이의 결합도 증가
메시지 필터링을 사용하면 이런 문제를 피할 수 있다.
- 메시지를 필터링하는 가장 쉬운 방법 → 소비자가 모든 메시지를 받은 다음 필요 없는 메시지는 버리는 방법 → 유연성은 높음, 불필요한 트래픽 발생으로 성능 저하
- 더 나은 방법은 브로커에게 메시지를 필터링하여 소비자는 원하는 메시지만 받을 수 있도록 하는 것 → 필터링을 하기 위해 복호화나 역직렬화 필요 → 브로커 성능 저하
- 메시지에 민감한 데이터(보호 데이터) → 메시지 큐에서 해당 메시지를 읽으면 안 됨
- 브로커에서 구현할 필터링 로직은 메시지의 내용(페이로드)을 추출해서는 안 됨 → 필터링에 사용될 데이터는 오직 메타데이터 영역에 두어 브로커로 하여금 효율적으로 읽어갈 수 있도록 해야 함
메시지마다 태그를 두면 소비자는 아래 그림처럼 어떤 태그를 가진 메시지를 구독할지 지정할 수 있게 된다.
메시지의 지연 전송 및 예약 전송
때로는 소비자에게 보낼 메시지를 일정 시간만큼 지연시켜야 하는 일이 있을 수 있다.
발송 즉시 전달되는 메시지와는 달리, 이런 메시지는 토픽에 바로 저장하지 않고 브로커 내부의 임시 저장소에 넣어 두었다가 시간이 되면 토픽으로 옮긴다.
이 시스템의 핵심 컴포넌트는 임시 저장소 및 타이밍 기능이다.
- 하나 이상의 특별 메시지 토픽을 임시 저장소로 활용 가능
- 타이밍 기능
- 메시지 지연 전송 전용 MQ 사용
- 계층적 타이밍 휠 사용
메시지 예약 전송 기능은 지정된 시간에 소비자에게 메시지를 보낼 수 있도록 하는 기능으로,
전반적인 시스템 설계 철학은 메시지 지연 전송 시스템과 유사하다.
4단계: 마무리
시간이 남는다면 다음 사항에 대해 이야기해보면 좋을 것이다.
- 프로토콜
- 노드 사이에 오고 가는 데이터에 대한 규칙, 문법, API를 규정
- 분산 MQ 시스템의 경우 프로토콜은 다음 사항을 기술
- 메시지 생산과 소비, 박동 메시지 교환 등의 모든 활동을 설명
- 대용량 데이터를 효과적으로 전송할 방법을 설명
- 데이터의 무결성을 검증할 방법을 기술
- 유명한 프로토콜로는 AMQP, Kafka 프로토콜 등이 있다.
- 메시지 소비 재시도
- 새로 몰려드는 메시지들이 제대로 처리되지 못하는 일을 막으려면, 어떻게 재시도해야 할까?
- 이력 데이터 아카이브
- 시간 기반, 용량 기반 로그 보관 메커니즘이 있다고 가정할 때, 이미 삭제된 메시지를 다시 처리하길 원하는 소비자가 있다면 어떻게 할까?