Publish 하나를 놓쳤다고 값이 사라진 것은 아니다
OPC UA subscription은 데이터 변화를 TCP로 밀어 보내고 도착하기를 바라기만 하는 방식이 아니다. 모든 NotificationMessage에는 sequence number가 붙고, 서버는 최근에 보낸 메시지를 재전송 큐에 보관한다. 클라이언트가 sequence number에서 빈칸을 발견하면 Republish 서비스로 누락된 메시지를 다시 받을 수 있다.
이게 중요한 이유는, PublishResponse가 하나 빠지거나 순서가 바뀌었다고 해서 반드시 값이 사라지는 것은 아니기 때문이다. 깨끗한 네트워크에서는 거의 발생하지 않는다. 하지만 상태가 나쁜 링크, 부하가 큰 서버, 잠깐 Publish 호출을 멈춘 클라이언트에서는 이것이 트렌드에 진짜 구멍이 나는 것과 조용히 복구되는 것의 차이를 만든다.
함정은, 많은 클라이언트가 재접속이나 "subscription recovered" 한 줄만 로그로 남기고 실제로 notification이 손실됐는지는 알려주지 않는다는 점이다. sequence number와 Republish 카운터를 확인하지 않으면, 복구가 깨끗했다고 그냥 믿는 것이다. 실제로는 아닐 수 있다.
Sequence number는 어디서 오는가
각 subscription은 NotificationMessage마다 1부터 순서대로 번호를 붙이고, notification을 담은 메시지마다 1씩 증가한다. 이 번호 매김과 여기에 기대는 Republish 서비스는 OPC UA Part 4(Services, IEC 62541-4)에 정의되어 있다. 벤더 고유의 마법이 아니라서, 규격을 지키는 서버라면 상대편에서 지원한다. Keepalive 메시지는 sequence number를 소비하지 않는데, 트레이스를 읽을 때 흔히 헷갈리는 부분이다.
클라이언트는 받은 메시지를 다음 Publish request에서 acknowledge한다. Acknowledge가 되면 서버는 그 메시지를 재전송 큐에서 버릴 수 있다. Acknowledge 전까지는 큐 크기 한도 안에서 Republish용으로 남아 있다.
즉 세 가지가 움직인다:
- 서버가 다음에 보낼 next sequence number.
- 보냈지만 아직 acknowledge 되지 않은 메시지를 담은 재전송 큐.
- 매 Publish request에 실려 가는 클라이언트 acknowledgement.
Gap이란 클라이언트가 N을 받고 나서 N+1을 한 번도 못 본 채 N+2를 받는 상황이다.
Gap 감지
Gap을 알아채는 것은 클라이언트의 책임이다. 서버는 경고해 주지 않는다. 실무적인 감지 방법:
- subscription별로 마지막으로 받은 sequence number를 기록한다.
- NotificationMessage마다 last+1과 비교한다.
- 새 번호가 기대값보다 크면 그 사이 메시지가 하나 이상 빠진 것이다.
- 새 번호가 같거나 작으면 중복이거나 순서가 바뀐 메시지다. Gap으로 처리하면 안 된다.
순서 뒤바뀜은 실제로 일어난다. 특히 gateway나 재전송하는 NAT 경계를 지날 때 그렇다. N+1 뒤에 늦게 도착한 N을 두 개의 개별 장애가 아니라 중복으로 처리하도록 검사를 만들어야 한다.
Republish 호출
클라이언트가 N+1이 빠진 것을 감지하면, subscription id와 누락된 sequence number로 Republish를 호출한다. 가능한 결과:
- 성공 — 서버가 그 메시지를 아직 재전송 큐에 가지고 있어서 돌려준다. 클라이언트는 정상적으로 도착한 것처럼 순서대로 처리한다.
- BadMessageNotAvailable — 메시지가 이미 버려졌다. 보통 큐가 너무 작거나 시간이 너무 지났기 때문이다. 이 notification은 진짜로 손실된 것이다.
- BadSubscriptionIdInvalid — subscription이 더 이상 없다. 복구 단계를 지나 subscription을 다시 만들어야 하는 상황이다.
Republish는 한 번에 sequence number 하나만 처리한다. 여러 개를 복구하려면 순서대로 반복 호출해서, 서버가 더 못 주는 번호에 도달하거나 따라잡을 때까지 진행한다. 첫 BadMessageNotAvailable에서 멈춰라. 뒤쪽 메시지가 이미 사라졌다면 앞쪽 메시지도 남아 있지 않다.
Gap이 실제로 생기는 이유
원리는 단순하지만 원인은 늘 있는 현장 문제들이다.
- 클라이언트가 Publish 호출을 멈췄다. OPC UA의 notification은 pull 방식이다. 서버는 클라이언트가 보낸 Publish request를 들고 있을 때만 보낼 수 있다. 클라이언트의 Publish 파이프라인이 멈추면(GC pause, 막힌 스레드, historian 경로의 느린 디스크 쓰기) 서버는 notification을 쌓고 클라이언트는 뒤처진다.
- 서버 Publish 큐 오버플로. 서버가 보관하는 대기 Publish request와 큐 메시지에는 한도가 있다. 클라이언트가 Publish request를 충분히 공급하지 않으면 서버는 결국 moreNotifications 플래그를 세우거나 keepalive 동작으로 떨어지고, sequence 연속성이 깨질 수 있다.
- 전송 손실 또는 리셋. Secure channel 끊김, 재접속, 유휴 연결을 리셋하는 중간 장비가 진행 중인 PublishResponse를 잃을 수 있다.
- 재전송 큐가 너무 작다. Republish를 제대로 호출해도, 메시지 두어 개만 담는 큐는 클라이언트가 더 많이 뒤처졌을 때 도움이 안 된다.
복구를 좌우하는 클라이언트 설정
Republish를 직접 튜닝하지는 않지만, 관련 설정 몇 가지가 Republish가 성공할 수 있는지를 결정한다.
- 미처리 Publish request 개수. 클라이언트는 Publish request를 하나가 아니라 여러 개를 계속 띄워 둬야 한다. 흔한 지침은 subscription 개수에 여유를 더한 만큼이며, 그래야 서버가 항상 답할 request를 가진다. 미처리 request가 하나뿐인 것이 자초하는 gap의 대표적 원인이다.
- Publishing interval 대 클라이언트 처리 시간. notification이 클라이언트가 처리하고 acknowledge하는 속도보다 빠르게 오면 backlog가 커진다. Interval을 클라이언트가 현실적으로 소화할 수 있는 값에 맞춰라.
- 재전송 큐 크기(설정 가능한 서버 쪽). 현재 publish 속도에서 현실적인 최악의 재접속 구간을 덮을 만큼 커야 한다.
- Lifetime count와 keepalive count. 클라이언트가 너무 오래 조용해서 subscription이 만료되면 메시지 하나가 아니라 subscription 전체를 잃고, Republish로도 못 살린다.
커미셔닝·트러블슈팅 체크리스트
- 클라이언트가 subscription별 마지막 sequence number를 기록하고, 단순한 "recovered"가 아니라 gap을 명시적으로 로그로 남긴다.
- Gap 감지 시 누락된 sequence number마다 순서대로 Republish를 호출하고, BadMessageNotAvailable에서 멈춘다.
- 진짜 손실된 notification(BadMessageNotAvailable)은 조용히 건너뛰지 않고 bad quality나 트렌드 gap으로 드러낸다.
- 순서 뒤바뀜·중복 메시지는 두 배 장애로 세지 않고 중복으로 처리한다.
- 클라이언트가 Publish request를 여러 개 띄워 둔다. 설정만 보지 말고 패킷 캡처로 확인한다.
- Publishing interval이 부하 상태에서 클라이언트 처리·acknowledge 시간으로 달성 가능한 값이다.
- 재전송 큐가 현재 속도에서 최악의 재접속 구간에 맞게 크기가 잡혀 있다.
- Keepalive와 lifetime count가 잠깐의 클라이언트 멈춤으로 subscription이 만료되지 않도록 설정되어 있다.
- Republish 성공·실패 횟수가 진단이나 로그에 보여서, 조용히 Republish에 의존하는 링크를 실패하기 전에 알아챈다.
의존하기 전에 복구 경로를 증명하라
감시하지 않으면 subscription이 몇 주 동안 notification을 흘려도 모든 복구 로그는 여전히 깨끗하게 보인다. 이 장애 자체가 설계상 조용하다. 그러니 Republish를 믿음으로 넘기지 마라.
Gap을 일부러 만들어 보라. 실험실이나 정비 창에서 값이 바뀌는 동안 클라이언트의 Publish 루프를 몇 초 멈추거나, 진행 중인 PublishResponse가 넘칠 만큼 돌아오는 트래픽을 방화벽으로 막은 뒤 트렌드를 확인한다. 구멍 없이 복구되면 sequence 추적과 Republish 경로가 제 역할을 하는 것이다. Gap이 남으면 재전송 큐가 너무 작았거나 클라이언트가 실제로 Republish를 호출하지 않은 것이다. 그리고 이걸 반년 뒤 감사 중이 아니라 화요일 오후에 알게 된 셈이다.