본문으로 건너뛰기

OT의 언어: OPC UA, MQTT, 그리고 Sparkplug B

📍 현재 위치: Part II · 공정 포착하기 — 우리는 결정론적(deterministic) 바이오리액터(bioreactor)를 만들고 그 태그(tag)에 이름을 붙였습니다. 이제 그 데이터가 현대 모든 생산 현장이 돌리는 두 가지 프로토콜(protocol)을 통해 말하게 만들 차례입니다. 진짜 보안과 함께, 그리고 현장 배포가 어디서 잘못되는지에 대한 솔직한 시선과 함께 말이죠.

쉽게 말하면

바이오리액터가 숫자를 웅얼거리기만 할 줄 아는 사람이라고 상상해 보세요. 두 명의 조력자가 나머지 공장을 위해 그 말을 통역해 줍니다. 첫 번째는 OPC UA로, 꼼꼼한 사서(librarian)입니다. 무엇이든 물어보면 단순한 값만이 아니라 라벨이 붙은 카드를 건네줍니다 — "이것은 온도이고, 단위는 섭씨이며, 14:32:07에 측정되었고, 저는 이 값이 양호하다고 확신합니다." 두 번째는 Sparkplug B를 입은 MQTT로, 공동 회선 위의 동네 외침꾼(town crier)입니다. 모든 장치는 깨어날 때 스스로를 알리고("BR101이 온라인입니다, 제 메트릭은 이렇습니다"), 변화가 생기는 즉시 외치며, 영리하게도 교환대에 봉인된 사망 증명서를 미리 맡겨 두어, 근무 도중 갑자기 멈춰 버려도 모두가 즉시 그 사실을 알게 합니다. 이 장에서는 두 가지를 모두 세우고, 시뮬레이션된 CHO 바이오리액터에 연결하며, 대부분의 사람이 건너뛰는 부분 — 실제로 보안을 켜는 일 — 에 대해 단도직입적으로 이야기합니다.

이 장에서 다루는 내용

4장에서 우리는 모든 신호에 BR101.Titer.PV 같은 규율 있는 이름을 부여했습니다. 전송 수단이 없는 이름은 빈 상자에 붙은 라벨일 뿐입니다. 이 장에서는 그 상자를 채우고 발송합니다. 다루는 내용은 다음과 같습니다.

  • OPC UA 정보 모델(information model) — 각 태그가 값에 더해 타입(type), 공학 단위(engineering unit), 타임스탬프(timestamp), 품질 플래그(quality flag)를 함께 싣고 다니는 자기 기술형(self-describing) 주소 공간(address space) — 그리고 오픈 소스 스택(open62541, node-opcua, asyncua)으로 서버와 클라이언트를 세우는 방법.
  • Eclipse Mosquitto를 이용한 MQTT 발행/구독(publish/subscribe), 그리고 멍청한 메시지 버스(message bus)를 상태를 가진 자기 탐색형(self-discovering) 버스로 바꿔 주는 Sparkplug B의 탄생/사망(birth/death) 생애 주기.
  • 진짜 OPC UA 보안: Basic256Sha256 정책, 애플리케이션 인증서(application certificate), 엄격한 신뢰 목록(trust list) — 그리고 대부분의 현장 서버가 바로 이 부분을 잘못 구성한다는 불편한 증거.
  • 구문적(syntactic) 전송(바이트를 안전하게 옮기기)과 의미적(semantic) 전송(의미를 옮기기)의 차이, 그리고 품질 플래그와 타임스탬프가 선택적 장식이 아닌 이유.

여기 나오는 모든 것은 실제로 여러분의 노트북에서 동작하는 동반 저장소(companion repo)의 코드로 뒷받침됩니다. 상자를 열어 봅시다.

OPC UA: 자기 기술형 사서

OPC UA(공식 명칭 IEC 62541)가 현대 생산 현장을 지배하는 이유는, 숫자를 벌거벗은 채로 보내기를 거부하기 때문입니다. 그 핵심 아이디어는 **주소 공간(address space)**입니다. 탐색 가능한 *노드(node)*의 트리로, 노드는 단순한 값이 아니라 메타데이터 — 데이터 타입, 공학 단위, 접근 권한, 그리고 다른 노드에 대한 참조 — 를 싣고 다니는 객체입니다 [1]. 클라이언트는 한 번도 본 적 없는 서버에 접속해 트리를 탐색하고, 사전에 공유된 사양서 없이도 거기에 무엇이 있는지 발견할 수 있습니다. 이 자기 기술이 바로 핵심입니다. 온도 노드는 자기가 온도이고, 단위가 °C이며, 지금 이 순간의 값이고, 그 측정값이 신뢰할 만하다고 스스로 말해 줍니다.

우리 동반 저장소는 정확히 이것을 모델링합니다. examples/chapters/05-connectivity-opcua-mqtt/opcua_server.py 파일은 FreeOpcUa 프로젝트의 순수 파이썬 asyncio 구현체인 asyncua를 사용해 OPC UA 서버를 세웁니다 [2]. 이 서버는 BR101 바이오리액터를 하나의 주소 공간으로 — 살아 있는 공정 변수(process variable)마다 노드 하나씩 — 노출하고, 결정론적 유가식(fed-batch) 트레이스를 그 노드들에 재생합니다. 서버의 심장부는 다음과 같습니다.

# examples/chapters/05-connectivity-opcua-mqtt/opcua_server.py
ENDPOINT = "opc.tcp://0.0.0.0:4841/bioproc/"
NAMESPACE = "https://example.org/bioproc"

# the tags the server publishes (a subset of the historian tags, the live PVs)
TAGS = ["BR101.Temp.PV", "BR101.pH.PV", "BR101.DO.PV", "BR101.Agitation.PV",
"BR101.Titer.PV", "BR101.OnlineGlucose.PV"]


async def build_server() -> tuple[Server, dict]:
server = Server()
await server.init()
server.set_endpoint(ENDPOINT)
server.set_server_name("BR101 Bioreactor (simulated)")
idx = await server.register_namespace(NAMESPACE)

objects = server.nodes.objects
br = await objects.add_object(idx, "BR101")
nodes = {}
for tag in TAGS:
var = await br.add_variable(idx, tag, 0.0)
await var.set_writable()
nodes[tag] = var
return server, nodes

세 가지 세부 사항이 제 몫을 합니다. 첫째, 엔드포인트(endpoint) opc.tcp://0.0.0.0:4841/bioproc/는 OPC UA의 기본 바이너리 TCP 전송 방식으로, 효율적이며 클라이언트가 기본으로 접속하는 통로입니다. 둘째, register_namespace는 URI(https://example.org/bioproc)를 점유하여 우리 BR101 객체가 서버 내장 노드와 뒤엉키지 않고 자기만의 네임스페이스(namespace)에 살게 합니다. 클라이언트는 이를 *인덱스(index)*로 해소하는데, 뒤에 나오는 클라이언트 코드가 탐색 전에 네임스페이스 번호를 먼저 묻는 이유가 바로 이것입니다. 셋째, 우리는 BR101을 **객체(object)**로 추가하고 그 아래에 **변수(variable)**들을 매답니다 — 이 계층 구조 자체가 자기 기술입니다. 탐색하는 클라이언트는 BR101BR101.Titer.PV를 보고 즉시 그 역가(titer)가 해당 바이오리액터에 속한다는 것을 압니다.

값이 기록되면 asyncua는 기본적으로 거기에 Good 상태 코드(status code)와 소스 타임스탬프를 찍습니다 [2]. 값 더하기 품질 더하기 시간이라는 그 짝이 바로 사서의 색인 카드이며, 순진한 전송 방식이 내다 버리는 부분입니다.

다시 읽어 보기: 왕복(round-trip) 증명

저장소는 이것을 설명만 하는 게 아니라, 실제로 돌아간다는 것을 증명합니다. demo_roundtrip() 함수는 서버를 시작하고, 최고 역가 부근의 트레이스 10분을 재생한 다음, 클라이언트를 연결해 노드를 다시 읽어 옵니다.

# examples/chapters/05-connectivity-opcua-mqtt/opcua_server.py
async def demo_roundtrip() -> dict:
"""Start the server, replay a few steps, read a node with a client, stop."""
from asyncua import Client

server, nodes = await build_server()
state = fed_batch.simulate().state
async with server:
# replay 10 minutes near peak titer so the read is interesting
for step in range(19000, 19010):
await _pump(nodes, state, step)
await asyncio.sleep(0.2)
async with Client(ENDPOINT.replace("0.0.0.0", "127.0.0.1")) as client:
node = await client.nodes.objects.get_child(
[f"{await _ns(client)}:BR101", f"{await _ns(client)}:BR101.Titer.PV"])
titer = await node.read_value()
return {"endpoint": ENDPOINT, "tags": len(nodes), "read_titer_g_L": round(float(titer), 3)}

실행하면 결정론적 결과가 나옵니다(시뮬레이터는 SIM_SEED=2026으로 고정되어 있으므로, 여러분의 기계에서도 바이트 단위로 동일합니다).

{"endpoint": "opc.tcp://0.0.0.0:4841/bioproc/", "tags": 6, "read_titer_g_L": 4.902}

클라이언트는 BR101을 탐색하고, 이름으로 BR101.Titer.PV를 찾아 4.902 g/L — 우리 골든 배치(golden batch)의 19009분 시점 역가 — 를 읽었습니다. 서버가 펌프해 넣은 기반 상태도 그만큼 구체적입니다.

step=19000 temp_C=36.96 pH=6.967 DO_pct=31.6 titer_g_L=4.896 glucose_g_L=1.416
step=19001 temp_C=36.98 pH=6.951 DO_pct=33.2 titer_g_L=4.897 glucose_g_L=1.413
...
step=19009 titer_g_L=4.902

이것이 "연결성(connectivity)"의 가장 작고 정직한 단위입니다. 값 하나가 한 공정을 떠나 다른 공정에 온전하게, 그 정체성이 보존된 채로 도착한 것이죠. 이 장의 테스트 스위트(tests/test_chapters.py::test_ch05_opcua_roundtrip)는 바로 그것을 단언합니다 — tags == 6이고 0 < read_titer_g_L < 10 — 그래서 이 왕복은 결코 조용히 썩어 갈 수 없습니다.

다른 언어에서의 같은 작업

asyncua는 우리의 기준점이지만, OPC UA는 다국어 세계이며 이 책은 의도적으로 세 가지 오픈 스택을 사용합니다. open62541은 OPC UA Server Profile에 대해 인증되었고 Basic256Sha256 보안 정책을 지원하는 C99 구현체(MPL v2.0)로, 계측기 가까이에 작고 빠른 서버를 임베드해야 할 때 올바른 선택입니다 [3]. node-opcua는 MIT 라이선스의 Node.js/TypeScript SDK로, 컬렉터(collector)가 웹 서비스와 나란히 애플리케이션 계층에 살 때 이상적입니다 [4]. 이들은 모두 같은 와이어 프로토콜(wire protocol)을 말하므로, node-opcua 컬렉터든, UaExpert 데스크톱 브라우저든, Telegraf의 OPC UA 입력 플러그인이든 수정 없이 우리 asyncua 서버를 구독할 수 있습니다. 그 상호 운용성(interoperability)이 바로 표준이 제 일을 하는 것입니다.

보안 켜기 (모두가 건너뛰는 부분)

이 장이 누그러뜨리기를 거부하는 불편한 진실이 여기 있습니다. OPC UA는 아름답게 잠글 있습니다 — OPC UA 보안 모델(OPC 10000-2 / IEC 62541-2)은 서명되고 암호화된 채널(channel), 애플리케이션 인증서, 그리고 서버가 대화조차 나눌 피어(peer)를 명시하는 신뢰 목록을 정의합니다 [5]. Basic256Sha256 보안 정책은 SHA-256과 2048비트 이상의 RSA 키를 사용하며, 현대의 기준선입니다.

하지만 할 수 있다실제로 한다는 서로 다른 행성입니다. 2020년 인터넷 전역 측정 연구는 도달 가능한 OPC UA 배포의 **92%**가 안전하지 않은 구성을 가졌음을 발견했고, 가장 뼈아프게도, Basic256Sha256 정책을 광고하던 564개 서버 중 409개가 그 정책과 일치하지조차 않는 인증서를 제시하여 MD5/SHA-1 서명이나 짧은 키로 후퇴했습니다 [6]. 다시 말해, 대부분의 현장 서버는 강력한 보안을 주장해 놓고 깨진 자격 증명을 건넵니다. 약한 고리는 결코 프로토콜이 아니었습니다. 배포가 문제였습니다.

그래서 우리 노트북 데모(교육용으로 열린 opc.tcp:// 엔드포인트를 사용)를 넘어서면, 진짜 보안은 몇 가지 의도적인 단계입니다. asyncua에서는 서버 자신의 인증서와 키를 로드하고, 허용 정책을 설정하며, 그리고 모두가 잊어버리는 단계 — 신뢰 목록을 고정(pin) 하여 서버가 목록에 없는 인증서를 가진 클라이언트를 모두 거부하게 — 를 수행합니다.

# Illustrative hardening — what the production deployment adds on top of the
# demo server; this is NOT in opcua_server.py (the runnable demo has no TLS).
from pathlib import Path

from asyncua import ua
from asyncua.crypto.truststore import TrustStore
from asyncua.crypto.validator import CertificateValidator, CertificateValidatorOptions

await server.load_certificate("certs/server-cert.pem")
await server.load_private_key("certs/server-key.pem")
server.set_security_policy([ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt])

# Strict trust: only clients whose certs live in the trust folder may connect.
# A TrustStore loads the trusted peer certs (and CRLs), and a CertificateValidator
# rejects any client that isn't trusted.
trust_store = TrustStore(trust_locations=[Path("certs/trusted")], crl_locations=[])
await trust_store.load()
validator = CertificateValidator(
CertificateValidatorOptions.TRUSTED | CertificateValidatorOptions.PEER_CLIENT,
trust_store,
)
server.set_certificate_validator(validator)

데이터가 우리에게 강제하는 교훈은 이렇습니다. 자기 서명한(self-signed) 낯선 이를 받아들이거나 인증서 검증을 건너뛴다면, Basic256Sha256을 광고하는 것은 무가치합니다. 정직한 체크리스트는 채널을 암호화하고, 체인(chain)을 검증하며, 신뢰 목록을 짧게 유지하고 검토하라입니다. 또한 여기가 순수 OSS가 와이어 위에서는 잘 해내지만, 내장 인증서 생애 주기(certificate lifecycle) — 발급, 교체(rotation), 폐기(revocation) — 는 제공하지 않는 지점입니다. GxP 공장에서는 그것을 실제 PKI(전역 탐색 서버(Global Discovery Server)나 사이트 CA)에 볼트로 결합하고, 그것을 문서화합니다. 스택은 공짜지만, 규율은 그렇지 않습니다.

공장 연결성 백본을 보여 주는 2개 차선 다이어그램. 위쪽 차선은 OPC UA를 보여 준다. BR101 바이오리액터가 탐색 가능한 주소 공간 트리로 표현되고, 각 노드는 값, 단위, 타임스탬프, 품질을 싣고 있으며, Basic256Sha256 서명·암호화 채널을 통해 컬렉터에 연결되고, 신뢰 목록이 어느 클라이언트가 참여할 수 있는지를 통제한다. 아래쪽 차선은 Sparkplug B를 입은 MQTT를 보여 준다. BR101이 NBIRTH 알림과 DBIRTH 메트릭 정의를 Mosquitto 브로커에 발행하고, 변화가 생길 때마다 DDATA 메시지를 보내며, 장치가 떨어져 나가면 브로커가 자동으로 발행하는 사전 등록된 NDEATH 유언 메시지가 있고, 하류에서 히스토리안이 구독한다.

서로를 보완하는 두 가지 전송 방식. OPC UA는 "이 태그에 대해 모든 것을, 안전하게, 요청 시 알려 달라"에 답하고, MQTT 위의 Sparkplug B는 "여기 내가 누구이고 무엇이 바뀌었는지 알린다"를 외치며, 장치가 죽는 순간 네트워크가 그것을 알게 됨을 보장합니다. Original diagram by the authors, created with AI assistance.

MQTT와 Sparkplug B: 스스로를 알리는 동네 외침꾼

OPC UA는 요청 구동형(request-driven)이며 무겁습니다. 풍부한 탐색과 안전한 점대점(point-to-point) 읽기에서 빛을 발하죠. 하지만 수백 개의 장치와 가느다란 네트워크 링크를 가진 공장은 가볍고 팬아웃(fan-out) 되는 경로도 원합니다. 그것이 바로 MQTT(OASIS 표준, ISO/IEC 20922로도 발행됨)입니다 — 장치가 *토픽(topic)*에 발행하면 **브로커(broker)**가 구독한 누구에게나 메시지를 펼쳐 주는 발행/구독 프로토콜입니다 [7]. 이것은 검소하기로 유명하며, 그래서 토양 센서부터 바이오리액터 스키드(skid)까지 모든 것 위에서 돌아갑니다.

우리 브로커는 Eclipse Mosquitto입니다 [8]. 개발 스택 설정인 examples/platform/mosquitto/mosquitto.conf는 짧고, 중요하게도, 개발 전용임을 솔직하게 밝힙니다.

# examples/platform/mosquitto/mosquitto.conf
# Mosquitto broker config for the local dev stack (Chapter 5).
# Dev-only: anonymous access on the plain 1883 listener. Chapter 25 (operating &
# securing) replaces this with TLS + per-client ACLs; never ship anonymous in
# a real plant.
listener 1883
allow_anonymous true

# enable the $SYS topic tree so the healthcheck can confirm the broker is alive
sys_interval 10

persistence true
persistence_location /mosquitto/data/
log_dest stdout

주석을 약속으로 읽으세요. 평문 1883 리스너(listener)의 allow_anonymous true는 노트북에서는 괜찮지만 공장에서는 금지입니다. Mosquitto는 클라이언트 인증서를 이용한 TLS 위의 MQTT를 지원하며 [8], 25장에서는 이 파일을 TLS 리스너와 클라이언트별 접근 제어 목록(access-control list)으로 교체합니다. 안전하지 않은 개발 설정을 보여 주고 그것에 큰 소리로 라벨을 붙이는 것이야말로 이 책이 설파하는 규율입니다 — 우리는 편리한 기본값이 운영 환경에 슬쩍 끼어들도록 결코 내버려 두지 않습니다.

Sparkplug B: 버스에 심장 박동 주기

순수 MQTT에는 산업용으로 쓰기에 문제가 있습니다. 상태가 없고 토픽이 무정부 상태라는 점이죠. 어떤 장치든 아무 문자열에든 무엇이든 발행할 수 있고, 장치가 네트워크에서 떨어져 나가도 구독자는 알 길이 없습니다 — 그냥 그 장치 소식이 들리지 않을 뿐이며, 이는 "아무것도 바뀌지 않음"과 구별되지 않습니다. Sparkplug B(Eclipse Sparkplug 3.0.0)는 엄격한 토픽 네임스페이스와 탄생/사망 생애 주기를 정의하여 이를 해결하는 공개 명세입니다 [9]. 참조 인코딩은 Eclipse Tahu(EPL-2.0)에 있으며, 이는 Java, Python, C로 된 Sparkplug B 구현을 제공합니다 [10].

생애 주기에는 반드시 이해해야 할 네 가지 메시지 타입이 있습니다.

  • NBIRTH노드 탄생(Node Birth). 에지 노드(edge node)(가령 BR101 컨트롤러)가 연결되면, 스스로를 알리는 탄생 증명서를 발행합니다.
  • DBIRTH장치 탄생(Device Birth). 그 노드 아래의 각 장치에 대해, 모든 메트릭을 정의하는 탄생 메시지입니다 — 이름, 데이터타입, 현재 값. 이것이 자기 기술이며, BR101에 g/L 단위 float인 Titer.PV가 있음을 버스가 배우는 순간입니다.
  • NDEATH / DDEATH노드/장치 사망(Node/Device Death). 노드 또는 장치가 오프라인이 되었다는 알림입니다.

천재성은 사망이 어떻게 전달되는가에 있습니다. Sparkplug는 MQTT의 유언 메시지(Will message) 기능을 활용합니다. 장치가 연결될 때 자신의 NDEATH 페이로드(payload)를 브로커에 미리 등록합니다 [7][9]. 장치의 연결이 끊어지면 — 충돌, 케이블 뽑힘, 정전 — 브로커가 직접 사전 등록된 사망 증명서를 발행합니다. 폴링(polling)도 없고, 타임아웃(timeout)을 어림짐작할 필요도 없습니다. 네트워크는 킵얼라이브(keep-alive) 시간 안에 BR101이 사라졌음을 배웁니다. 조용히 죽은 센서 하나가 알아채지 못한 온도 일탈을 뜻할 수 있는 공정에서, 그 보장은 큰 가치가 있습니다.

Sparkplug 토픽과 디코딩된 DBIRTH 메트릭은 다음과 같이 생겼습니다 — Tahu가 우리 바이오리액터를 위해 만들어 내는 형태입니다.

topic: spBv1.0/newark/DBIRTH/BR101/reactor
{
"timestamp": 1768759740000,
"metrics": [
{ "name": "BR101.Titer.PV", "datatype": "Float", "value": 4.902, "properties": { "unit": "g/L", "quality": 192 } },
{ "name": "BR101.Temp.PV", "datatype": "Float", "value": 36.96, "properties": { "unit": "degC", "quality": 192 } }
]
}

토픽 구조를 보세요. spBv1.0(Sparkplug 버전) / newark(그룹, 우리 사이트 — 4장의 UNS 경로 관례에 맞추려고 소문자) / DBIRTH(메시지 타입) / BR101(에지 노드) / reactor(장치)입니다. 그 엄격한 네임스페이스 덕분에 어떤 Sparkplug 인식 소비자든 그저 청취만 함으로써 공장 전체를 발견할 수 있고, 이는 이후 장에서 발전시킬 통합 네임스페이스(Unified Namespace) 아이디어와 맞물립니다. 메트릭은 unitquality 코드를 싣고 다닙니다 — OPC UA와 똑같은 값 더하기 의미의 규율을, 다만 요청이 아니라 알림으로 전달하는 것이죠. 그 숫자에 대한 한마디: 192(0xC0)는 OPC UA 상태가 아니라 OPC DA(클래식) Good 품질 코드입니다. 많은 Sparkplug 에지 노드가 레거시 OPC DA 서버 앞단에 서서 DA 스타일 품질을 그대로 통과시키며, 그래서 와이어 위에서 이 값이 보입니다. 앞에서 우리가 만든 OPC UA 서버는 Good을 다르게 보고합니다 — UA의 StatusCode Good은 단순히 0(0x00000000)이며, 이것이 바로 asyncua가 각 쓰기에 찍는 값입니다.

구문적 대 의미적: 바이트 옮기기 대 의미 옮기기

이 장에서 가장 깊은 아이디어에 이름을 붙일 만합니다. **구문적 전송(syntactic transport)**은 A에서 B로 바이트를 손상 없이 옮기는 것입니다 — TLS, TCP, 메시지 프레이밍(framing). OPC UA와 MQTT 둘 다 이것을 잘 해냅니다. **의미적 전송(semantic transport)**은 의미를 옮기는 것입니다. 바이트 4.902는 수신자가 그것이 g/L 단위의 역가이고, 알려진 시점에 측정되었으며, Good 품질이라는 것까지 배우지 않으면 쓸모가 없습니다. OPC UA는 그 의미를 주소 공간에 싣고, Sparkplug는 그것을 DBIRTH 메트릭 정의에 싣습니다. temp 토픽으로 보낸 4.902라는 평범한 MQTT 메시지는 구문은 싣지만 의미는 거의 싣지 않습니다 — 바로 그것이 Sparkplug가 존재하는 이유입니다. 이 책의 나머지 전체에 걸쳐, 데이터가 경계를 넘을 때마다 질문은 같습니다. 의미가 살아남았는가, 아니면 바이트만 살아남았는가?

왜 중요한가

이 연결성 백본은 나머지 모든 것이 그 위에 서는 바닥입니다. 전송이 샘플 하나를 잃거나, 단위를 잘못 붙이거나, 품질 플래그를 떨어뜨리면, 여러분의 히스토리안(historian), SPC 차트, 소프트 센서(soft sensor)는 모두 충실하게 쓰레기를 분석하고 있는 셈입니다. 전송을 제대로 하면 — 값, 단위, 타임스탬프, 품질이 모두 보존되고, 모두 보안이 적용되면 — 플랫폼의 나머지는 신뢰할 만한 입력을 공짜로 물려받습니다.

규제적 측면도 있습니다. EU GMP Annex 11과 FDA 21 CFR Part 11 모두 기록이 시스템 사이를 이동할 때 그 진정성(authenticity)과 무결성(integrity)을 보존하는 통제 — 그저 막연한 네트워크가 아니라 문서화된 보관 연속성(chain of custody) — 을 기대합니다 [11][12]. 검토된 신뢰 목록을 갖춘 서명·암호화 OPC UA 채널이나, 클라이언트별 ACL을 갖춘 TLS 보안 Sparkplug 버스가 바로 전송을 귀속 가능(attributable) 하게 만드는 방법입니다 — 누가 무엇을 보냈는지 말할 수 있고, 그것이 전송 중에 변조되지 않았음을 증명할 수 있습니다. 우리가 보여 준 기본값이 안전하지 않은 설정들은, 검사관이라면 명백히 지적할 바로 그것입니다.

실제 현장에서는

현대의 mAb 생산 현장에 들어서면 제어 시스템이 IT 세계를 만나는 거의 모든 곳에서 OPC UA를 발견하게 됩니다 — Emerson DeltaV, Siemens PCS 7, AVEVA PI가 모두 그것을 말합니다 — 한편 MQTT/Sparkplug는 점점 더 팬아웃이 높은 텔레메트리(telemetry)와 에지 데이터를 실어 나릅니다. 우리의 유가식 CHO + Protein A 라인은 가장 지배적인 승인 항체 방식이며, 그 센서들은 20년 동안 OPC를 말해 왔습니다. 집약식/연속식 변형 — 다중 컬럼(multi-column) 포집을 갖춘 관류식(perfusion) — 은 태그 수를 배가시킬 뿐이며, 바로 그때가 가벼운 Sparkplug 버스가 OPC UA 옆에서 제 자리를 얻는 시점입니다.

미국의 바이오 의약품 제조를 위한 민관 협력 기관인 NIIMBL은 정확히 이런 종류의 상호 운용성 작업에 자금을 지원하며, 그 SABRE 시설 — 2024년 4월에 착공하여 2026년 중반 현재까지 건설 중인 델라웨어 대학교의 파일럿 규모 cGMP(current Good Manufacturing Practice, 현행 우수 제조 관리 기준) 시설 — 은 검증된 상용 제어 시스템 옆에 오픈 소스 연결성 계층이 놓일 법한 종류의 사이트입니다.

그리고 계층에 대한 정직한 평결은 오픈 소스에 유난히 후합니다. 여기 나오는 OSS 스택들은 진정으로 운영 등급(production-grade)입니다. open62541 [3], node-opcua [4], asyncua [2]는 실제 OPC UA 구현체이고, Mosquitto [8]와 Tahu [10]는 성숙한 Eclipse 프로젝트입니다. 상용 라이선스 없이 이들 위에 전체 전송 백본을 구축할 수 있습니다. OSS가 건네주지 않는 것은 GxP 마지막 한 걸음입니다 — 인증서 교체와 폐기를 위한 관리형 PKI, 감사관이 전화했을 때 책임질 벤더, 그리고 즉시 쓸 수 있는 검증 증거. 어떤 OSS 브로커나 스택도 기본값으로 21 CFR Part 11을 준수하지 않습니다 — 준수란 다운로드의 속성이 아니라, 여러분이 구성하고 검증한 시스템의 속성입니다. 와이어는 열려 있지만, 인증서 생애 주기, ACL 검토, 그리고 검증은 여러분이 떠안을 일입니다. 규모를 키울 때 라이선스도 지켜보세요. 여기 나오는 OPC UA 스택들은 허용적(MPL/MIT/LGPL)이고 Mosquitto/Tahu는 EPL/EDL이지만, 상용 브로커 EMQX는 BSL 라이선스로 옮겨 갔으므로, 모든 MQTT 선택지가 운영에 공짜라고 가정하지 마세요.

핵심 용어

  • OPC UA / IEC 62541 — 그 주소 공간이 값에 더해 타입, 단위, 타임스탬프, 품질을 싣고 다니는 자기 기술형 산업 프로토콜 [1].
  • 주소 공간(Address space) — OPC UA 서버가 노출하는, 탐색 가능한 노드(객체와 변수)의 트리. 자기 기술의 원천.
  • 품질 플래그(Quality flag) — 모든 측정값과 함께 이동하여 소비자가 그것을 신뢰할지 알게 해 주는 상태 코드. OPC UA의 Good은 StatusCode 0(0x00000000)인 반면, 레거시 OPC DA(클래식) Good 코드는 192(0xC0)로 — DA 서버 앞단에 선 Sparkplug 브리지가 종종 그대로 통과시키는 값.
  • Basic256Sha256 — 서명·암호화 채널을 위한 현대의 OPC UA 보안 정책(SHA-256, 2048비트 이상 RSA) [5].
  • 신뢰 목록(Trust list) — 서버가 받아들일 피어 인증서의 명시적 집합. 대부분의 현장 배포가 망치는 단계 [6].
  • MQTT — 중앙 브로커를 갖춘 가벼운 발행/구독 프로토콜. OASIS/ISO 20922 [7].
  • 브로커(Broker) — 발행된 메시지를 구독자에게 펼쳐 주는 MQTT 서버(여기서는 Mosquitto).
  • 유언 메시지(Will message) — 클라이언트가 예기치 않게 연결이 끊겼을 때 브로커가 그 클라이언트를 대신해 발행하는 MQTT 메시지. Sparkplug 사망 증명서의 기반 [7].
  • Sparkplug B — MQTT에 엄격한 토픽 네임스페이스와 탄생/사망 생애 주기를 더하는 공개 명세 [9].
  • NBIRTH / DBIRTH / NDEATH / DDEATH — Sparkplug의 노드/장치 탄생 및 사망 증명서. DBIRTH는 모든 메트릭을 정의하고, 사망은 오프라인 전환을 알린다.
  • 구문적 대 의미적 전송(Syntactic vs semantic transport) — 바이트를 안전하게 옮기기 대 의미(값 + 단위 + 시간 + 품질)를 온전하게 옮기기.
  • cGMP — current Good Manufacturing Practice, 현행 우수 제조 관리 기준. 통제되고 문서화되며 재현 가능한 제조에 대한 구속력 있는 기대.

다음 이야기

이제 우리 바이오리액터는 유창한 OT를 말합니다 — 풍부하고 안전하며 탐색 가능한 읽기를 위한 OPC UA, 그리고 스스로를 알리는 팬아웃 텔레메트리를 위한 MQTT 위의 Sparkplug B. 하지만 날것의 현장 트래픽이 곧장 히스토리안으로 흘러드는 일은 드뭅니다. 먼저 걸러지고, 재성형되고, 버퍼링되고, 라우팅됩니다. 다음 장 에지 게이트웨이: Node-RED, Telegraf, NiFi로 현장 데이터 라우팅하기는 그 중간 계층을 구축합니다 — 이들 프로토콜에서 데이터를 끌어와 깨끗하고 맥락이 부여된 스트림을 하류의 모든 것에 전달하는 오픈 소스 배관입니다.