철거한 장비 값이 새 화면에 다시 떴다
site/line1/press/pt101/pv에 붙어 있던 센서는 두 달 전에 뜯어냈다. 그런데 새로 띄운 HMI 화면에 4.82 bar가 뜬다. 게이트웨이는 죽었고 publish하는 쪽은 아무도 없다.
브로커가 들고 있던 retained message다. 지우는 명령은 한 줄이다.
# payload 길이 0, retain 1 → 해당 토픽의 retained message 삭제
mosquitto_pub -h broker -t 'site/line1/press/pt101/pv' -r -n
빈 payload + RETAIN=1이 "저장된 값을 버려라"라는 뜻이라는 건 MQTT 3.1.1과 5.0 양쪽 §3.3.1.3 RETAIN에 적혀 있다. 이 삭제 메시지 자체는 저장되지 않고, 이미 붙어 있던 subscriber에게는 전달된다. 그래서 HMI는 빈 payload를 받아도 죽지 않게 처리해 둬야 한다. 여기서 한 번 당한다.
QoS를 올려도 이 문제는 안 사라진다. QoS, retained, session, 그리고 payload에 넣는 시각·품질을 따로따로 정해야 한다.
QoS는 전달 보장이지 값의 품질이 아니다
QoS는 브로커와 클라이언트 한 구간의 약속이다. 그 값이 최신인지, PLC가 받아들였는지, 계측기가 정상인지는 다루지 않는다. 비용은 패킷 수로 바로 드러난다(MQTT 5.0 §4.3).
| QoS | 왕복 패킷 | 패킷 흐름 | SCADA에서 쓰는 경우 |
|---|---|---|---|
| 0 | 1 | PUBLISH | 다음 값이 곧 오는 빠른 텔레메트리 |
| 1 | 2 | PUBLISH → PUBACK | 중요한 상태 변화, 이벤트. 중복 처리 전제 |
| 2 | 4 | PUBLISH → PUBREC → PUBREL → PUBCOMP | 현장에서는 드물다. 패킷 4배, 명령 안전은 못 산다 |
설비 데이터 대부분은 QoS 0이나 1로 끝난다. QoS 2는 "정확히 한 번"이라는 이름 때문에 모터 기동 명령에 쓰고 싶어지는데, 보장 범위가 MQTT 구간뿐이다. 기동 명령에 필요한 건 command ID, 인터록 확인, 피드백, 타임아웃, 운전 이력이다. 그건 QoS가 주지 않는다.
브로커가 QoS 2를 아예 안 받는 경우도 있다. MQTT 5.0에서는 CONNACK의 Maximum QoS property(0x24)로 알려주고, 그보다 높은 QoS로 publish하면 reason code 0x9B QoS not supported가 돌아온다. 3.1.1에는 이 통로가 없어서 브로커 문서를 읽는 수밖에 없다.
DUP flag를 중복 판정에 쓰면 안 된다
QoS 1에서는 같은 메시지가 두 번 온다. 고장이 아니라 설계가 그렇다. 재전송하는 쪽은 fixed header 첫 바이트의 bit 3, DUP flag를 1로 세운다(§3.3.1.1). QoS 0에서는 DUP이 항상 0이어야 한다.
문제는 그 다음이다. DUP 값은 subscriber로 전달될 때 그대로 넘어오지 않는다. 브로커가 publisher의 재전송을 받아 처리하고 subscriber에게 새로 보내면 DUP은 0일 수 있다. 같은 §3.3.1.1에 명시된 동작이다. 그래서 중복 판정은 payload 안의 sequence나 command ID로 해야 한다. DUP을 믿은 dedup 코드는 조용히 통과시킨다.
받는 쪽을 이렇게 만든다.
- payload에 source timestamp를 넣는다.
- 순서가 중요하면 source별 증가
sequence를 넣는다. - 히스토리언과 이벤트 저장은 같은 메시지를 다시 받아도 결과가 같게 만든다(idempotent).
- 같은 MQTT 메시지가 다시 왔다고 생산 수량이나 정지 횟수를 또 올리지 않는다.
가장 자주 깨지는 건 이벤트 테이블이다. 네트워크가 2초 끊긴다. subscriber가 재접속한다. QoS 1 메시지가 다시 들어온다. 이벤트 처리기가 새 이벤트로 적으면 일일 보고서가 틀어진다. MQTT는 규격대로 동작했고, 업무 데이터만 깨졌다.
명령에는 Response Topic과 Correlation Data를 쓴다
command ID와 응답 토픽을 payload에 직접 넣는 설계는 흔하지만, MQTT 5.0에는 이미 자리가 있다. Response Topic(property 0x08)과 Correlation Data(0x09)다. 요청에 둘을 실어 보내면, 받은 쪽은 Correlation Data를 그대로 복사해 Response Topic으로 답한다. 직접 만든 규칙보다 이쪽이 낫다. 다른 벤더 클라이언트가 그대로 알아듣는다.
3.1.1에서는 이 property가 없으므로 payload에 직접 넣는다. 그때는 필드 이름이 프로젝트 문서에 박혀야 한다.
Retained message는 "새 subscriber에게 보여줄 값"만
Retained message는 브로커가 토픽별 마지막 메시지 하나를 저장했다가 새 subscriber에게 즉시 보내는 기능이다. 토픽당 1개다. 상태값에는 편하고, 오래된 값과 실시간 값을 화면에서 구분하지 못하면 위험하다.
retained로 둘 만한 것:
- 엣지 장치 birth 정보.
- 모델명, 펌웨어 버전 같은 정적 정보.
- 현재 모드나 레시피명. 단, 타임스탬프와 장치 상태가 같이 있어야 한다.
- 화면에 "마지막으로 알려진 상태"라고 분명히 표시하는 값.
retained로 두면 곤란한 것:
- 순간 명령. 클라이언트가 재시작할 때마다 다시 실행된다.
- 알람 확인 요청.
- 빠르게 변하는 아날로그 샘플.
- 한 번만 처리해야 하는 배치 이벤트.
MQTT 5.0은 받는 쪽에서도 제어할 수단을 준다. SUBSCRIBE의 subscription options(§3.8.3.1) 중 Retain Handling 2비트다.
| Retain Handling | 동작 |
|---|---|
| 0 | subscribe 시 retained message를 보낸다(기본값, 3.1.1과 같은 동작) |
| 1 | 그 subscription이 새로 생기는 경우에만 보낸다 |
| 2 | retained message를 보내지 않는다 |
HMI 쪽 라이브 화면은 2가 맞다. 오래된 값을 애초에 안 받는다. 같은 §3.8.3.1의 Retain As Published 비트는 전달되는 PUBLISH의 RETAIN flag를 살릴지 정한다. 기본값 0이면 브로커가 0으로 지워서 보내므로, subscriber는 "subscribe 직후 받은 RETAIN=1"만 저장된 값으로 구분할 수 있다.
브로커가 retain을 끈 경우도 있다. CONNACK의 Retain Available property(0x25)가 0이면 retain=1로 publish할 수 없고, 하면 reason code 0x9A Retain not supported로 끊긴다.
mosquitto_sub -R은 이 셋 중 어느 것도 아니다. 받은 뒤 화면에 안 찍는 클라이언트 쪽 필터일 뿐이고, 바이트는 이미 왔다. 프로토콜 수준으로 막으려면 라이브러리 클라이언트에서 Retain Handling 2를 줘야 한다.
텔레메트리, 상태, 명령 토픽을 나눈다
모든 토픽에 같은 설정을 쓰면 반드시 꼬인다.
| 토픽 종류 | 예 | 보통의 설정 |
|---|---|---|
| 텔레메트리 | site/line1/press/pt101/pv | QoS 0~1, retained 없음, source timestamp 필수 |
| 상태 | site/line1/filler/state | QoS 1, 마지막 상태가 필요할 때만 retained |
| Birth/death | site/line1/gw01/status | QoS 1, birth retained + Will message |
| 이벤트 | site/line1/filler/events | QoS 1, retained 없음, event ID 필수 |
| 명령 | site/line1/filler/cmd/start | QoS 1, retained 금지, Correlation Data + Response Topic |
장애 분석도 쉬워진다. 브로커에 retained 명령이 남아 있으면 토픽 정책이 틀린 것이고, 위의 -r -n 한 줄로 치운다.
Session과 backlog: 숫자가 정해져 있다
subscriber가 꺼져 있는 동안 브로커가 뭘 쌓아 두는지가 운영을 가른다. MQTT 5.0은 여기에 쓸 수 있는 값을 명시해 뒀다.
| 항목 | 값 | 비고 |
|---|---|---|
| Session Expiry Interval (0x11) | 초 단위 32비트. 없으면 0 | 0 = 연결 끊길 때 session 소멸. 0xFFFFFFFF = 만료 없음 |
| Clean Start (CONNECT flag, §3.1.2.4) | 1 = 기존 session 버리고 시작 | 3.1.1의 Clean Session과 역할이 다르다. 수명은 Session Expiry가 따로 정한다 |
| Keep Alive (§3.1.2.10) | 초 단위 16비트, 최대 65535 s(약 18시간 12분) | 0 = 사용 안 함. 브로커는 1.5 × Keep Alive 안에 아무것도 안 오면 끊는다 |
| Receive Maximum (0x21) | 없으면 65535 | QoS 1·2 동시 진행 개수 상한. 느린 subscriber 보호용 |
| Message Expiry Interval (0x02) | PUBLISH에 붙이는 초 단위 TTL | 만료된 메시지는 전달되지 않고, 전달 시 남은 시간으로 다시 써서 보낸다 |
Message Expiry Interval이 "오래된 운전값이 한꺼번에 몰려오는" 문제의 정식 해답이다. 1초 주기 PV에 30 s를 걸어 두면 10분 끊겼다 붙어도 쓰레기 600개가 HMI로 쏟아지지 않는다. 반대로 이벤트 수집기는 만료를 걸지 않고, 받은 걸 원래 source timestamp로 저장해야 한다.
Client ID도 여기 걸린다. 게이트웨이 두 대에 같은 Client ID를 넣으면 서로를 쫓아낸다. 쫓겨난 쪽은 reason code 0x8E Session taken over를 받고 재접속하고, 다시 상대를 쫓아낸다. 재접속 loop가 분 단위로 돌고 데이터는 톱니처럼 끊긴다. 이건 브로커 설정 문제가 아니라 Client ID 작명 문제다.
시운전 때 확인할 것.
- 클라이언트 라이브러리가 Clean Start와 Session Expiry를 각각 어떤 값으로 보내는가. 기본값이 둘 다 "session 안 남김"인 라이브러리가 많다.
- backlog를 받아도 되는 subscriber와 안 되는 subscriber를 구분했는가.
- 브로커의 queued message, dropped message 카운터를 보고 있는가.
- 실제 publish 중에 subscriber를 내렸다 올려서 순서와 시각 처리를 봤는가.
모든 클라이언트가 항상 살아 있을 때만 맞는 설계는 현장 설계가 아니다. 데모다.
payload에 들어가야 하는 것
MQTT는 payload 내용을 정의하지 않는다. 5.0이 주는 메타데이터는 Payload Format Indicator(0x01, 0=바이트열 / 1=UTF-8)와 Content Type(0x03)뿐이다. timestamp field는 프로토콜에 없다. 브로커 수신 시각은 쓸 수 없다. 재접속 backlog에서는 실제 측정 시각과 수십 분까지 벌어진다.
그래서 payload에 직접 넣는다.
value: 계측값, 상태값, 이벤트 내용.sourceTimestamp: 장치나 게이트웨이가 값을 만든 시각. UTC, ISO 8601.quality: good / uncertain / bad / simulated / forced.sequence: source별 증가 번호.source: 게이트웨이, PLC, 장비 ID.
명령 응답에는 accepted 또는 rejected, 거절 사유, 평가 후 장비 상태를 넣는다. 상관 관계는 Correlation Data가 맡는다. 이게 없으면 사고 후에 브로커 로그만 뒤진다.
시운전에서 해보는 테스트
- publisher가 이미 돌고 있을 때 subscriber를 새로 띄워서 어떤 retained 값이 들어오는지 본다. 같은 테스트를 Retain Handling 2로 한 번 더 한다.
- publisher를 끊고 Will message 또는 offline 상태가 뜨는지 본다. 5.0이면 Will Delay Interval(0x18)이 걸려 있지 않은지 확인한다. 걸려 있으면 Will이 그만큼 늦게 나간다.
- publisher를 다시 붙여 offline 상태가 정상 birth로 바뀌는지 본다.
- subscriber를 오래 내려 backlog를 만든 뒤 올려서 순서와 timestamp를 본다. Message Expiry Interval을 걸고 한 번 더 한다.
- PUBACK 직전에 네트워크를 끊어 QoS 1 중복을 만들고, dedup이 DUP flag가 아니라
sequence로 동작하는지 본다. - 명령 토픽이 retained가 아니며 클라이언트 재시작 뒤 재실행되지 않는지 본다.
- HMI가 live, stale, bad, last-known을 다르게 보여주는지 본다.
규격 출처
- MQTT Version 5.0, OASIS Standard, 2019-03-07. 위에 쓴 절 번호와 property ID는 이 문서 기준이다.
- MQTT Version 3.1.1, OASIS Standard, 2014-10-29. ISO/IEC 20922:2016으로도 발행됐다. RETAIN 동작(§3.3.1.3)은 5.0과 같고, Retain Handling, Message Expiry, Session Expiry, Response Topic은 없다.
브로커가 지원하는 버전부터 확인하고 설계해야 한다. 3.1.1 브로커에 5.0 property를 쓰는 설계도는 그림일 뿐이다.
MQTT는 메시지를 옮긴다. 그 메시지가 신선한지, 믿을 수 있는지, 제어에 써도 되는지는 payload와 SCADA 쪽 설계가 정한다.