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

MQTT 대시보드 값이 뒤로 튀는 이유: timestamp와 sequence number 설계

MQTT 도착 순서는 공정 순서가 아니다. DUP flag의 한계, Sparkplug seq의 8-bit wrap, MQTT 5.0 retained 만료 처리.

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

값이 4분 뒤로 튀었다

Oven 온도 trend가 09:18에 184.2도를 찍고, 다음 point에서 갑자기 09:14의 181.6도로 돌아갔다. Gateway가 죽은 것도 아니고 broker가 죽은 것도 아니다. 3분짜리 network 단절이 있었고, gateway가 buffering해 둔 sample을 reconnect 직후에 쏟아냈을 뿐이다. Live stream은 이미 재개된 상태였다.

HMI 잘못이 아니다. HMI는 도착한 순서대로 그렸다. 애초에 payload에 "이 값이 언제 측정된 것인지"가 없었을 뿐이다.

MQTT는 message를 옮기는 데는 좋다. 어떤 sample이 먼저 발생했는지, 이게 늦게 온 것인지, replay된 값이 최신 값을 덮어도 되는지는 알려주지 않는다. 알려줄 의무도 없다. 그건 payload 설계자 몫이다.

MQTT의 DUP flag는 중복 방지 장치가 아니다

여기서 제일 많이 오해한다. PUBLISH packet의 fixed header에는 DUP flag가 있으니 중복은 broker가 걸러 주지 않느냐는 것이다.

아니다. MQTT 5.0 §3.3.1.1은 반대로 못 박는다. DUP는 transport 재전송 표시일 뿐이고, 규범 조항 [MQTT-3.3.1-3]은 이렇게 쓴다 — 들어온 PUBLISH의 DUP 값은 subscriber에게 전파되지 않으며, 나가는 PUBLISH의 DUP는 "그 packet이 재전송인지 여부만으로" 결정되어야 한다.

실무로 옮기면 이렇다.

  • QoS 1 재전송으로 같은 message가 두 번 오면 두 번째는 DUP=1이다. 이건 잡을 수 있다.
  • Gateway가 store-and-forward buffer를 reconnect 후 다시 publish하면 그건 새 PUBLISH다. DUP=0으로 온다.
  • 이중화 gateway 두 대가 같은 설비 상태를 각자 publish해도 둘 다 DUP=0이다.

현장에서 실제로 아픈 중복은 뒤의 두 가지다. 그리고 그 둘은 DUP flag로 절대 안 잡힌다. Application level에서 식별자를 직접 실어야 한다.

Packet Identifier도 마찬가지다. §2.2.1에서 16-bit Two Byte Integer로 정의되어 있고, QoS 1/2 handshake가 끝나면 재사용된다. Broker hop을 넘어가면 값이 바뀐다. 중복 판정 key로 쓸 물건이 아니다.

Timestamp 하나로 뭉치지 않는다

timestamp라는 field 하나만 있으면 그게 PLC sample time인지, gateway receive time인지, broker receive time인지, application insert time인지 알 수 없다. 이건 서로 다른 사실이고, 장애 분석할 때 필요한 건 대부분 첫 번째다.

{
  "source": "line3/oven1",
  "tag": "PV_Temperature",
  "value": 184.2,
  "quality": "good",
  "sampleTime": "2026-06-28T09:14:22.315Z",
  "publishTime": "2026-06-28T09:14:22.612Z",
  "bootId": "a7f3-2026-06-28T06:02:11Z",
  "seq": 18445521
}

sampleTime은 공정 기준 시각이다. Controller나 gateway가 값을 취득한 순간이어야 한다. publishTime은 통신 기준 시각이다. 위 예시의 297 ms 차이는 정상 buffering이지만, 이 값이 갑자기 몇 분으로 벌어지면 store-and-forward replay를 보고 있는 것이다. publishTime - sampleTime을 그대로 diagnostic tag로 뽑아 두면 장애 때 제일 먼저 보게 된다.

Controller가 믿을 만한 timestamp를 못 주면 interface 문서에 그렇게 적는다. Gateway time을 PLC time처럼 포장하지 않는다. 나중에 event 순서 분석할 때 이게 제일 크게 발목을 잡는다.

Clock은 지루할수록 좋다

Timestamp 설계는 clock이 흔들리면 그 자리에서 무너진다. 1분 offset은 온도 overview에서는 넘어갈 수 있다. Batch release나 event 순서 분석에서는 못 넘어간다.

  • Payload는 UTC로 쓴다. Local time 변환은 HMI나 report layer에서 한다.
  • Gateway와 server의 NTP 상태를 감시한다. Plant LAN에서 NTP는 보통 수 ms에서 수십 ms 수준이다. 그보다 정밀한 순서가 필요하면 wall-clock이 아니라 sequence를 봐야 한다.
  • Clock sync가 허용 오차를 넘으면 quality나 diagnostic을 바꾼다. 조용히 넘어가면 안 된다.
  • Commissioning 기록에 timestamp source를 남긴다.

서로 관련된 event를 gateway 두 대가 각각 publish한다면, 두 clock 차이가 분석 요구 수준 안에 들어와야 한다. 안 들어오면 wall-clock time으로 순서를 논하는 것 자체가 의미가 없다.

Sequence number는 scope와 wrap을 같이 적는다

Sequence number는 무엇을 세는지 정해져 있을 때만 쓸모가 있다.

Scope예장점위험
Tag별Temperature tag가 publish마다 증가한 point의 missing sample 확인여러 tag 간 event 순서 판단 불가
Device별Gateway 하나가 모든 message에 counter 하나한 device의 전체 순서 확인고속 traffic에서 wrap이 빨라진다
Event stream별Alarm/event topic에 별도 counterMES event 중복 처리에 유리Reset 동작을 정의해야 한다
Boot session별Reboot 후 reset, boot ID 동봉단순 embedded device에 적합Consumer가 boot ID를 같이 봐야 한다

Wrap은 추상적인 걱정이 아니라 산수다. 초당 10 message를 내보내는 gateway에서 16-bit counter는 65536 / 10 = 약 6554초, 즉 1시간 49분마다 한 바퀴 돈다. 8-bit counter는 25.6초다. Historian connector가 "번호가 낮아졌다"를 gap으로 해석하도록 짜여 있으면, wrap 한 번마다 가짜 경보가 하나씩 생긴다.

Reboot 후 counter가 조용히 0으로 돌아가는 것도 같은 문제다. Boot identifier나 session ID가 없으면 consumer는 새 message를 오래된 duplicate로 버리거나, 진짜 gap을 놓친다.

이 설계는 이미 표준이 있다 — Sparkplug B

위 항목을 직접 정의하기 전에 Eclipse Sparkplug 3.0을 먼저 본다. 지금 손으로 만들려던 걸 그대로 규정해 둔 표준이다.

  • seq는 message마다 1씩 증가하고 255에서 0으로 넘어간다. 8-bit다. 앞의 산수를 그대로 적용받는다.
  • NBIRTH의 seq는 반드시 0이다. Consumer는 여기서 stream 시작점을 잡는다.
  • bdSeq는 connect마다 증가하는 별도 counter로, NBIRTH와 NDEATH가 같은 값을 싣는다. Host는 이걸로 "어느 session이 끝났는지"를 정확히 짝지어 준다. 직접 만들려던 bootId가 바로 이거다.
  • Payload timestamp는 UTC epoch 기준 밀리초다. Local time 논쟁이 없다.

Sparkplug를 그대로 쓸지는 별개 문제다. seq가 8-bit라 25초짜리 wrap을 consumer가 감당해야 하고, payload가 protobuf라 debugging이 번거롭다. 다만 자체 JSON schema를 쓰기로 했더라도 bdSeq + seq 짝만큼은 그대로 베끼는 게 맞다. 이 문제를 이미 몇 년치 현장에서 두들겨 본 설계다. Topic 구조는 MQTT Sparkplug topic 설계에 따로 정리해 두었다.

중복 판정 key를 미리 정한다

QoS는 손실을 줄일 뿐 중복 처리를 없애 주지 않는다. QoS 1은 정의상 at-least-once다. Consumer가 message를 식별할 기준이 필요하다.

  • Sample value: source + tag + sampleTime + seq
  • Alarm 또는 production event: source + eventId
  • Reboot로 sequence가 reset되는 device: source + bootId + seq
  • MES transaction: lotId + equipmentId + eventType + eventTime

Arrival time만으로 중복을 판정하지 않는다. Broker, network, consumer 부하에 따라 arrival time은 쉽게 달라진다. QoS 선택과 retained 조합은 MQTT QoS와 retained message 쪽에 더 자세히 있다.

늦게 온 데이터는 consumer마다 다르게 다룬다

늦게 온 데이터가 항상 나쁜 건 아니다. Historian은 backfill 중에 오래된 sampleTime을 받아야 한다. Live HMI는 이미 더 최신 값이 떠 있으면 무시해야 한다. MES는 event window가 열려 있을 때만 받는다.

Consumer권장 동작
Live HMIsample time 기준 최신 valid sample 표시, 오래된 데이터는 stale 또는 replay 상태로 표시
Historiansample time 기준 저장, 불가능한 time jump는 reject 또는 quarantine
Alarm/event serviceevent time과 sequence로 순서 유지, late arrival 표시
MES transaction handleridempotent event ID 사용, lot move 중복 생성 금지
Analytics jobbackfill 허용, ingestion time은 별도 보관

같은 topic을 여러 consumer가 본다. Payload에는 각자 자기 기준으로 판단할 만큼의 context가 들어 있어야 한다. Consumer마다 정책을 물어보러 오게 만들면 안 된다.

Retained message: MQTT 5.0이 준 도구를 쓴다

Retained message는 current state topic에 유용하지만, 새 subscriber에게 shutdown 직전 값을 최신 상태처럼 보여 줄 수 있다. 3.1.1 시절에는 payload에 age를 넣고 consumer가 알아서 판단하는 수밖에 없었다. 5.0에는 broker가 처리해 주는 게 있다.

  • Message Expiry Interval (§3.3.2.3.3, property identifier 0x02, 초 단위 4-byte 정수). 이 시간을 넘긴 retained message는 broker가 지운다. 대기 중인 message를 전달할 때 남은 시간으로 줄여서 내보낸다. Status topic에 5분쯤 걸어 두면 "몇 시간 전 값이 최신인 척하는" 문제 자체가 broker 선에서 끝난다.
  • Retain Handling (§3.8.3.1, SUBSCRIBE subscription options의 bit 5–4). 0은 subscribe할 때마다 retained를 보낸다(기본값). 1은 그 subscription이 새로 생길 때만 보낸다. 2는 아예 안 보낸다. Reconnect를 자주 하는 consumer라면 1이 보통 맞다.
  • zero-byte retained payload (§3.3.1.3). RETAIN=1에 payload 길이 0으로 publish하면 그 topic의 retained message가 지워진다. 계획된 shutdown 때 status topic을 이걸로 정리하면 죽은 gateway가 살아 있는 척하지 않는다.
  • Will Delay Interval (§3.1.3.2.2, property identifier 0x18). LWT publish를 지정한 초만큼 미룬다. 5초짜리 reconnect마다 death message가 나가서 MES가 설비를 offline 처리하는 사고를 막는다. Birth/LWT 설계는 MQTT birth와 last will에 따로 정리했다.
  • Session Expiry Interval (§3.1.2.11.2, property identifier 0x11). Gateway가 얼마나 오래 끊겨 있어야 session을 버릴지 정한다. Store-and-forward buffer 크기와 같이 정해야 한다.

One-shot command, production transaction, alarm acknowledgement에는 retained를 쓰지 않는다. 새로 붙은 subscriber가 broker에 남아 있던 옛날 command를 실행하는 사고는 실제로 난다.

자주 보는 장애 네 가지

Gateway replay가 최신 값을 덮는다. 맨 앞의 그 증상이다. Gateway outage를 일부러 만들고 reconnect한 뒤, consumer가 arrival order가 아니라 sampleTime을 쓰는지 확인한다. Buffer 동작은 edge store-and-forward 쪽을 본다.

복사한 설정이 같은 source를 publish한다. Gateway 설정 파일을 복사하면서 source name과 client ID가 그대로 남는다. Broker가 한쪽을 끊거나, 두 장비가 같은 topic에 번갈아 publish한다. Topic 이름만 보고 실제 장비를 믿지 않는다. Source identity, client ID, certificate identity를 같이 log한다.

Counter reset이 대량 누락처럼 보인다. Reboot 후 seq가 0으로 돌아가고, historian connector가 이후 message를 전부 out-of-order로 찍는다. bdSeq든 자체 bootId든, ordering은 반드시 (bootId, seq) 짝으로 판단한다.

Retained online=true가 설비 사망을 숨긴다. Broker가 마지막 retained payload를 새 dashboard에 넘겨 준다. 실제 gateway는 offline인데 화면은 초록색이다. Message Expiry Interval, LWT, zero-byte clear 세 가지를 같이 쓴다. 그리고 communication quality와 process quality를 화면에서 분리한다.

인수인계 전 시험

깨끗한 lab path만 보지 말고 실제 장애를 흉내 낸다.

  1. Gateway network를 끊고 store-and-forward가 sampleTime을 보존하는지 확인한다.
  2. Reconnect 후 중복과 out-of-order 규칙이 문서대로 동작하는지 본다. Replay message가 DUP=0으로 오는 것을 packet capture로 직접 확인한다.
  3. Gateway를 restart하고 sequence reset 처리가 맞는지 본다.
  4. Counter를 강제로 wrap시켜 본다. 8-bit면 25초, 16-bit면 두 시간이면 된다.
  5. 새 client로 subscribe해서 retained age check와 Retain Handling 설정이 의도대로 도는지 본다.
  6. Clock sync loss를 만들고 quality나 diagnostic이 바뀌는지 확인한다.
  7. 같은 시험 구간에 대해 historian record, HMI 표시, broker capture를 나란히 비교한다.

좋은 MQTT interface는 topic 이름과 JSON field를 정하는 일이 아니다. 시간, 순서, 식별자, replay 동작에 대한 약속이다. 이 약속을 운영 전에 적어 두지 않으면, 첫 생산 장애가 대신 규칙을 정해 버린다.