← 전체 글
MQTT/약 13분 읽기/— 조회

MQTT 와일드카드 구독 하나가 historian을 밀어내는 이유

site/# 구독 부하를 승인 전에 계산하는 법. Retain Handling, Shared Subscription, Receive Maximum, SUBACK으로 막기.

MQTTSCADA히스토리언네트워킹문제 해결

Historian collector가 8분 뒤처져 있었다. Broker CPU는 12%. Edge gateway도 정상. 문제는 다른 데 있었다. 두 달 전 시운전 때 누가 dashboard 서비스에 site/#를 물려 놓고 그대로 갔고, 그 client 하나가 historian보다 많은 message를 받고 있었다.

Broker는 아무 잘못이 없었다. Filter가 너무 넓었을 뿐이다.

#가 실제로 무엇을 잡는지부터

MQTT Version 5.0(OASIS Standard, 2019) §4.7.1에 wildcard 규칙이 있다. 읽어 보면 대부분이 잘못 알고 있는 항목이 두 개 나온다.

하나. #는 부모 level까지 같이 잡는다. §4.7.1.2에 따르면 sport/tennis/player1/#는 sport/tennis/player1 자체에도 match한다. 그래서 site/a/line/pack01/telemetry/#로 구독하면 .../telemetry topic에 publish된 payload도 그대로 들어온다. 하위 tag만 받는다고 생각하고 parser를 짜 두면 여기서 깨진다.

둘. wildcard는 $로 시작하는 topic을 절대 잡지 못한다. §4.7.2에 명시돼 있다. site/#도, #도 $SYS/...를 받지 못한다. Broker 통계를 보려면 $SYS/#를 따로 구독해야 한다. 참고로 $SYS tree 자체는 MQTT 표준이 아니라 Mosquitto 등이 만든 관행이다. Broker마다 경로가 다르다.

그리고 #는 filter의 마지막 문자여야 하고 앞에 /가 와야 한다(§4.7.1.2). site/line1#는 유효하지 않은 filter다. 이걸 보내면 broker가 연결을 끊거나 SUBACK reason code 0x8F(Topic Filter invalid)를 돌려준다.

승인 전에 하는 계산

Topic 개수를 세는 건 의미가 없다. 천천히 변하는 tank level 하나와 100 ms 주기 vibration payload 하나는 영향이 완전히 다르다.

계산은 곱셈 세 번이면 끝난다.

gateway 40대 × tag 60개 × 1 Hz          = 2,400 msg/s
2,400 msg/s × JSON payload 280바이트    = 672 kB/s  ≈ 5.4 Mbit/s

여기에 MQTT fixed header 최소 2바이트, topic string 길이, QoS 1이면 PUBACK 왕복까지 더해진다. Topic 이름이 60자면 payload보다 topic이 더 무거운 tag도 나온다.

측정해야 하는 항목:

  • 해당 tree 아래 publisher 수와 payload 종류별 publish 주기
  • 재접속, store-and-forward flush, 교대 시작 같은 burst 조건
  • JSON / Sparkplug B / binary encoding 후의 실제 payload 크기
  • QoS level과 retained message 사용 여부
  • historian write, alarm 평가 같은 downstream 처리 비용

정상 운전 중의 조용한 1분만 보면 안 된다. WAN이 20분 끊겼다가 붙는 순간이 진짜 시험이다.

소비자별로 구독 범위를 다르게 잡는다

모든 client에 같은 넓은 filter를 주면 설정은 빠르다. 대신 장애 때 원인 찾기가 어려워진다.

소비자보통 필요한 topic 범위너무 넓게 잡았을 때
HMI live 화면한 area, skid, 설비 그룹화면에 쓰지 않는 데이터를 계속 받아 change burst 때 느려진다
Historian collector승인된 telemetry namespacedebug, command, test topic까지 저장한다
Alarm servicealarm/event topic단순 status chatter를 alarm 입력처럼 처리한다
Engineering tool임시 site 또는 line wildcard끄지 않으면 운영 부하로 남는다
Dashboard집계 metric시간당 합계면 충분한데 고주기 raw data를 끌어온다

기본 원칙은 소비자의 질문에 답하는 가장 작은 topic tree를 구독하는 것이다. 여러 area가 필요하면 site-wide catch-all 하나보다 명시적인 filter 여러 개가 낫다. 후자는 broker metric에서 어느 filter가 무거운지 바로 보인다.

Retained burst는 SUBSCRIBE option byte로 끈다

여기가 대부분 놓치는 부분이다. Retained message 폭탄은 broker 설정이 아니라 구독하는 쪽이 SUBSCRIBE packet에서 고르는 값이다.

MQTT 5.0 §3.8.3.1의 Subscription Options는 topic filter마다 붙는 1바이트다.

bit이름값
0–1QoS0, 1, 2
2No Local1이면 자기가 publish한 건 자기가 안 받는다
3Retain As Published (RAP)1이면 원래 RETAIN flag를 그대로 전달
4–5Retain Handling0 = 구독 시 retained 전부 전송, 1 = 새 구독일 때만 전송, 2 = 전송 안 함

Historian collector가 재접속할 때마다 retained snapshot 전체를 다시 받는다면 Retain Handling = 2를 쓰면 된다. 그 이후에 publish되는 retained message는 정상적으로 계속 들어온다. 끊기는 건 구독 시점의 일괄 전송뿐이다. Session을 유지하는 collector라면 1도 충분하다.

RAP도 그냥 flag가 아니다. RAP = 0이면 broker가 전달하는 message의 RETAIN bit를 0으로 지워 버린다. 즉 구독자는 그게 지금 올라온 값인지 예전에 남은 retained 값인지 구분할 수 없다. 운전원 화면이 죽은 장비의 옛날 값을 초록색으로 보여 주는 사고가 여기서 나온다. RAP = 1로 켜고 화면 로직이 RETAIN bit를 읽게 만드는 편이 낫다.

주의: 구독 직후 일괄 전송되는 retained message는 RAP 값과 관계없이 RETAIN = 1로 온다. 구분해야 할 건 그 이후에 도착하는 것들이다.

넓은 filter가 정말 필요하면 나눠서 받는다

Filter를 좁힐 수 없는 경우도 있다. Historian은 원래 telemetry 전부를 받아야 한다. 그럴 때 답은 client 하나를 더 크게 만드는 게 아니다.

MQTT 5.0 §4.8.2의 Shared Subscription을 쓴다.

$share/historian/site/a/+/+/telemetry/#

같은 $share/historian/ group으로 collector instance 세 개가 붙으면 broker가 message를 셋에 나눠 보낸다. 복제가 아니라 분배다. 넓은 filter를 유지하면서 처리량만 늘린다.

Broker가 지원하는지는 CONNACK property로 확인한다. 0x2A(Shared Subscription Available)가 0이면 지원하지 않고, 그래도 $share/로 구독하면 SUBACK에 reason code 0x9E가 온다. 같은 방식으로 0x28은 Wildcard Subscription Available, 0x29는 Subscription Identifier Available이다. Wildcard 자체를 막아 둔 broker에서는 SUBACK reason code 0xA2가 돌아온다.

SUBACK이 왔다고 구독이 된 게 아니다. SUBACK payload의 각 바이트가 그 filter의 결과다. 0x00/0x01/0x02는 승인된 QoS, 0x87은 Not authorized, 0x8F는 Topic Filter invalid. 많은 client library가 이 바이트를 조용히 버린다. ACL에 막혀서 데이터가 안 오는 건데 "broker가 안 보낸다"로 몇 시간 헤매는 이유가 이거다.

Client 쪽 backpressure: Receive Maximum

Collector가 밀릴 때 message를 client heap에 쌓는 것보다 broker에 남겨 두는 게 낫다.

MQTT 5.0의 CONNECT property 0x21(Receive Maximum)이 그 역할이다. 이 값은 client가 동시에 처리 중일 수 있는 QoS 1/2 PUBLISH 개수 상한이다. 생략하면 기본값이 65535 — 사실상 무제한이다. Collector에서 이걸 낮게(수십 단위) 잡으면 broker가 그 이상 밀어넣지 못하고 자기 queue에 들고 있는다. 그러면 backlog가 broker metric에 보인다. Client가 OOM으로 죽는 것보다 훨씬 낫다.

QoS 0에는 이 보호가 없다. 느린 consumer에게 가는 QoS 0 message는 broker가 그냥 버려도 규격 위반이 아니다. 조용히 사라진다. Historian 경로에 QoS 0을 쓰지 않는 이유다.

Command와 telemetry tree를 분리한다

Topic 방향이 분명하면 넓은 read 구독도 덜 위험하다.

site/a/line/pack01/telemetry/...
site/a/line/pack01/event/...
site/a/line/pack01/alarm/...
site/a/line/pack01/command/...
site/a/line/pack01/config/...

이 모양이면 historian은 site/a/+/+/telemetry/#를 구독해도 command payload를 보지 않는다. HMI command service에는 command/# publish 권한만 주면 되고, 그 서비스가 모든 vibration sample을 받을 필요도 없다.

섞어 두면 wildcard ACL과 wildcard subscription을 검토할 수 없다. 기술 문제가 운영 문제로 바뀐다. 누가 무엇을 보고 누가 무엇을 쓰는지 증명할 방법이 없어진다.

자주 보이는 실패 패턴

시험용 클라이언트를 끄지 않는다

맨 앞의 그 이야기다. 시운전 중 엔지니어링 노트북이 site/#를 구독하고, 원격 접속 터널로 계속 붙어 있는다.

현장 확인: 인수인계 전에 broker client 목록에서 모르는 client ID와 넓은 filter를 확인한다. Mosquitto라면 $SYS/broker/clients/connected부터 본다.

Dashboard가 raw telemetry를 직접 구독한다

Dashboard에 필요한 건 시간당 KPI 열 개다. 그런데 초 단위 telemetry 전부를 구독해서 브라우저에서 집계한다. 데모 때는 된다. 라인이 꽉 차면 안 된다.

현장 확인: 집계를 upstream으로 올리거나 summary topic tree를 따로 만든다.

Store-and-forward replay가 consumer를 밀어낸다

장애 후 edge gateway가 buffer를 한꺼번에 비운다. Broker는 traffic을 받아 내는데 historian collector가 뒤처진다. 몇 시간 동안 늦은 sample을 쓰는 상태가 된다.

현장 확인: 실제에 가까운 buffer 크기로 replay를 시험하고 catch-up 시간을 측정한다. Receive Maximum을 낮춰 두면 이 backlog가 broker 쪽에 보인다.

Sparkplug birth payload를 계산에 넣지 않았다

Sparkplug B에서 edge node가 재접속하면 NBIRTH와 device별 DBIRTH를 보낸다. Birth payload에는 그 node의 metric 전체가 들어간다. Gateway 40대가 동시에 복구되면 초당 message 수가 아니라 한 번에 오는 metric 수가 문제가 된다.

현장 확인: gateway 하나를 재시작해서 birth payload 크기를 재고 40배 해 본다.

시운전 체크

  1. Clean session으로 client를 시작하고 초기 수신 message 수와 바이트를 기록한다.
  2. Retain Handling 값을 확인한다. 0으로 두고 재접속마다 snapshot을 다시 받고 있지 않은지 본다.
  3. Edge gateway 하나를 재시작해서 birth/retained burst를 측정한다.
  4. WAN link를 끊었다가 복구하고 flush rate와 catch-up 시간을 잰다.
  5. SUBACK reason code를 로그에 남기는지 확인한다. 0x87을 조용히 삼키고 있지 않은지.
  6. 영구 wildcard subscriber 추가 전후로 broker metric snapshot을 남긴다.

Dashboard client가 historian보다 많은 traffic을 받고 있으면 filter가 너무 넓은 것이다. 거의 예외가 없다.

넓은 wildcard가 항상 나쁜 건 아니다. Discovery, 진단, 짧은 장애 분석에는 이만한 게 없다. 문제는 그게 아무도 모르는 영구 연동 규칙으로 남을 때다. 운영에 남길 거면 owner, 목적, 측정된 message rate 세 가지는 적어 두자.