Report가 정확히 절반만큼 틀렸다. Line 3 Gateway는 초당 1,800건 정도의 Telemetry 메시지를 발행하고 있었고 Historian Dashboard는 멀쩡해 보였는데, 교대 집계는 PLC Counter 값의 대략 50%로 나왔다. 원인은 서로 다른 설정 파일 두 줄이었다. 누군가 새 Report 전처리 서비스를 $share/line3/plant/+/telemetry에 구독시켰는데, 1년 전 다른 통합업체가 붙여 둔 Historian Collector가 같은 Shared Group을 쓰고 있었다. Broker는 MQTT 5.0 규격대로 스트림을 두 Client에 부하 분산했다. 각자 절반씩 받았고, 어느 쪽도 오류를 남기지 않았다.
Shared Subscription의 핵심 문제가 이 한 사건에 다 들어 있다. 이건 부하 분산 Primitive이지 데이터 모델 기능이 아니며, 전체 스트림이 필요한 Consumer에 갖다 붙이면 아무 소리 없이 실패한다.
규격이 실제로 주는 것
일반 구독은 Fan-out이다. plant/+/telemetry를 구독한 모든 Client가 각자 한 벌씩 받는다. HMI Gateway, Historian Collector, 진단 Recorder를 나란히 돌릴 때 원하는 동작이 이것이다.
Shared Subscription은 Fan-in 후 분배다. Client들이 $share/{ShareName}/{TopicFilter} 형식으로 이름 있는 그룹에 들어오면 Broker는 각 메시지를 그룹 구성원 한 명에게만 넘긴다. 이 방식은 MQTT 5.0(OASIS MQTT Version 5.0, §4.8.2)에서 표준이 됐다. 그 전에는 Broker 확장이었다 — Mosquitto는 1.6에서 추가했고 EMQX/HiveMQ는 각자 $share/ 처리를 더 일찍 내놨다. 아직 3.1.1 Broker를 쓴다면 문법을 가정하지 말고 그 Broker가 기대하는 형식을 확인하라.
규격에서 위험한 단어는 "한 명에게만"이다. 이게 당신이 요청한 부하 분산이자, 동시에 당신이 실수로 잃어버린 Fan-out이다.
유일한 설계 결정: 누가 전체 복사본이 필요한가
Replica 수를 건드리기 전에 Consumer를 두 목록으로 나눠라.
전체 스트림이 필요한 쪽(절대 같은 Shared Group에 묶지 말 것):
- Historian 적재 — 설정된 모든 Tag를 봐야 한다
- 상태를 메모리에 들고 있는 알람/Event 처리기
- Audit Log 저장
- Operator 화면을 공급하는 HMI Gateway
- 아직 디버깅 중인 시운전 Recorder
하나의 Shared Group 뒤에 Worker Pool로 돌려도 되는 쪽:
- 상태 없는 페이로드/스키마 검증
- Idempotent Key로 DB나 Queue에 쓰는 전처리
- 메시지 단위 순서가 중요하지 않은 Report 전처리
- 설비별 계산 — 단, 한 설비의 메시지가 항상 같은 Worker로 갈 때만
Line 3 Report를 살릴 수 있었던 규칙: Shared Group 이름은 인터페이스 계약의 일부다. Application과 Scope를 접두어로 붙이고(hist-ingest-line3, report-prep-area1), 이름 충돌은 두 장비가 같은 Modbus Unit ID를 주장하는 것과 똑같이 다뤄라.
Round-Robin은 Trend를 뒤섞는다
그룹 분리를 제대로 해서 상태 없는 Worker 세 개를 $share/report-prep-area1/... 뒤에 뒀다고 하자. 이제 순서 문제가 나온다. SCADA 데이터에서 순서는 전역으로 필요한 경우가 거의 없다. 설비별, Tag별, Lot별, 알람 Source별로 지켜지면 된다. Shared Subscription은 이걸 공짜로 주지 않는다.
전적으로 Broker의 Dispatch 전략에 달렸다. Mosquitto는 그룹에 무조건 Round-Robin을 돈다 — 한 유량계에서 연속으로 나온 메시지가 서로 다른 Worker로 가고, Worker B의 DB 쓰기가 Worker A보다 40 ms 늦으면 나중 Sample이 먼저 Sample보다 앞서 Current Value 테이블에 들어갈 수 있다. EMQX는 이걸 설정으로 노출한다(shared_subscription_strategy: round_robin, random, sticky, hash_clientid, hash_topic). 순서가 중요한 작업에 실제로 필요한 건 Hash 전략이다. hash_topic은 Topic이 설비 식별자를 담고 있는 한 plant/fic101/telemetry의 모든 메시지를 한 Worker에 고정한다.
Broker가 Round-Robin만 한다면 정직한 선택지는 셋이다.
- 순서가 중요한 작업은 Shared Group에서 아예 빼라
- Topic 수준에서 Partition하라 — Area별 Shared Group 하나, 또는 설비 범위를 Worker에 고정 배정 — 그래서 Broker가 순서 결정을 할 일이 없게 하라
- Consumer를 재정렬 허용형으로 만들어라 — 페이로드에
source,sampleTime, 단조 증가seq를 담고,sampleTime/seq가 Current Value보다 오래된 갱신은 거부한다. Historian 적재는 Broker 수신 시간이 아니라 Source Time을 Key로 쓴다.
이 중 하나 없이 Replica만 늘리는 건 증설이 아니다. 병목을 CPU에서 데이터 품질 계층으로 옮기는 것이고, 거기서는 훨씬 보기 어렵다.
QoS는 언제 데이터를 잃을지를 정한다 — 그러니 의도적으로 정하라
QoS 1이나 2에서 Broker는 ACK가 올 때까지 메시지를 Client에 묶어 두며, 그 한도는 Client의 Receive Maximum이다(MQTT 5.0 Flow Control; CONNECT에서 생략하면 기본 65535이지만 대부분의 Broker는 실제 In-flight Window를 훨씬 낮게 잡는다). PUBACK을 어디에 두느냐가 신뢰성 전부다.
- DB 쓰기가 Durable해지기 전에 ACK → Worker Crash 때 그 Sample들이 조용히 사라진다
- 쓰기 후에 ACK → 맞지만, Historian이 느리면 Broker 측 Queue가 빨리 쌓이고 저장소가 삐끗할 때마다 Queue Depth가 올라가는 걸 보게 된다
- Durable한 내부 Queue에 넘긴 뒤 ACK → Historian 적재에서 흔히 쓰는 절충안
실패 모드를 의도적으로 고르고 신뢰 가능한 인계점이 어디인지 문서에 남겨라. Historian 데이터는 Durable 이후 ACK. Dashboard용 파생 값은 값 하나 빠져도 괜찮다면 조기 ACK도 가능. 알람/Event 처리는 Production과 똑같은 QoS와 Clean Start/Session Expiry 설정으로 시험하라 — Clean Session의 함정은 Reconnect 동작에 숨어 있다. 이걸 건너뛰면 이후 모든 장애 분석이 "Broker가 잃었나, Worker가 잃었나, DB가 잃었나" 논쟁이 된다.
Production에서 그룹을 지켜보기
Client 하나만 살아 있어도 Shared Group은 뒤에서 밀리는 중에도 겉으로는 멀쩡해 보인다. 이 그룹을 연결 상태 표시등이 아니라 감시 대상 Production 구성요소로 다뤄라. 알람 걸 값들:
- Shared Subscription Queue Depth와 가장 오래된 대기 메시지의 나이(진짜 Lag 신호 — 0으로 안 내려오는 그룹은 Worker가 부족하거나 뒤쪽이 막힌 것이다)
- Client별 Message Rate와 Disconnect 횟수
- Reconnect 후 Redelivery Count, Stale/Duplicate 거부 건수
- 설비별 또는 Tag별 Sequence Gap
- Consumer 뒤쪽 DB/Historian Write Latency
시운전 때 Simulator로 짧은 Burst를 넣고 Lag가 올라갔다가 0으로 돌아오는지 확인하라. 온 김에 Burst 중에 Worker 하나를 일부러 죽여 봐라. DB Connection이 막혔는데 MQTT Session은 살아 있는 Worker는 자기 몫 메시지를 붙들고 아무 진전도 못 낸다. 해법은 Worker가 Downstream에 못 쓸 때 MQTT Client를 끊는 Application 수준 Health Check다 — 그래야만 Broker가 그 부하를 재분배한다.
마지막으로 미묘해서 짚고 넘어갈 함정 하나: 서로 관련된 알람 Transition을 두 Worker가 각자 계산하면 서로 어긋난다. 상대의 상태 변화를 못 보기 때문이다. 알람 상태는 단일 Active Processor에 두거나, 알람 Source별 Atomic Update가 되는 Store에 넣어라. 동시 알람 상태 기계를 올바르게 만드는 Shared Subscription 요령은 없다 — 그건 설계로 피해야 한다.