본문으로 건너뛰기

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(Chinese Hamster Ovary, 중국 햄스터 난소 세포주) 바이오리액터 — 세포가 자라며 약물을 분비하는 교반 용기 — 에 연결하며, 대부분의 사람이 건너뛰는 부분 — 실제로 보안을 켜는 일 — 에 대해 단도직입적으로 이야기합니다.

이 장에서 다루는 내용

5장에서 우리는 모든 신호에 BR101.Titer.PV 같은 규율 있는 이름을 부여했습니다 — 자산.측정.역할로 읽으며, Titer(역가)는 브로스 속 항체(제품) 농도(단위 g/L)이고 .PV는 그 살아 있는 프로세스 값(Process Value, 설정값 .SP와 대비되는 현재 측정값)입니다. 전송 수단이 없는 이름은 빈 상자에 붙은 라벨일 뿐입니다. 이 장에서는 그 상자를 채우고 발송합니다. 다루는 내용은 다음과 같습니다.

  • OPC UA 정보 모델(information model) — 각 태그가 값에 더해 타입(type), 공학 단위(engineering unit), 타임스탬프(timestamp), 품질 플래그(quality flag)를 함께 싣고 다니는 자기 기술형(self-describing) 주소 공간(address space) — 노드(node) 하나하나, 속성(attribute) 하나하나까지 — 그리고 오픈 소스 스택(여기서 스택(stack)은 프로토콜을 구현한 소프트웨어 라이브러리를 뜻합니다 — open62541, node-opcua, asyncua)으로 서버와 클라이언트를 세우는 방법.
  • 클라이언트–서버 핸드셰이크(handshake) 그 자체 — 탐색(discover), 보안 채널 열기, 세션(session) 생성·활성화, 브라우징(browse), 읽기(read), 구독(subscribe)으로 이어지는 순서 있는 서비스 집합(service set) — 그리고 살아 있는 공정 데이터에는 왜 폴링(polling)(타이머에 맞춰 다시 읽기)보다 구독(subscription)이 나은지.
  • Eclipse Mosquitto를 이용한 MQTT 발행/구독(publish/subscribe) — QoS 수준, 유언 메시지(Will message), 킵얼라이브(keep-alive) — 그리고 멍청한 메시지 버스(message bus)를 상태를 가진 자기 탐색형(self-discovering) 버스로 바꿔 주는 Sparkplug B의 완전한 세션 생애 주기(탄생·데이터·명령·사망과 seq/bdSeq 시퀀싱, 메트릭 별칭(alias), 기본 호스트(primary host) STATE), 이어서 TLS와 클라이언트별 ACL로 버스를 잠그는 방법.
  • 진짜 OPC UA 보안: Basic256Sha256 정책, 애플리케이션 인증서(application certificate), 엄격한 신뢰 목록(trust list) — 그리고 대부분의 도달 가능한 서버가 바로 이 부분을 잘못 구성한다는 불편한 증거.
  • 구문적(syntactic) 전송(바이트를 안전하게 옮기기)과 의미적(semantic) 전송(의미를 옮기기)의 차이, 그리고 품질 플래그와 타임스탬프가 선택적 장식이 아닌 이유.

여기 OPC UA 이야기는 여러분의 노트북에서 처음부터 끝까지 동작합니다. Sparkplug B 절반은 명세와 디코딩된 페이로드로 가르치며 — 인용되었을 뿐, 실행되지는 않습니다 — 실행 가능한 Sparkplug 토픽 헬퍼는 5장에 있습니다. 상자를 열어 봅시다.

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 서버를 세웁니다 [8]. 이 서버는 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"

# tag -> (engineering unit, low, high). Each Variable also carries an EngineeringUnits
# and an EURange Property, so a browsing client discovers "g/L, range 0..10" by itself.
TAGS = {
"BR101.Temp.PV": ("degC", 0.0, 50.0),
"BR101.pH.PV": ("pH", 0.0, 14.0),
"BR101.DO.PV": ("%sat", 0.0, 100.0),
"BR101.Agitation.PV": ("rpm", 0.0, 200.0),
"BR101.Titer.PV": ("g/L", 0.0, 10.0),
"BR101.OnlineGlucose.PV": ("g/L", 0.0, 12.0),
}


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

# explicit string NodeId (ns=2;s=BR101) — legible, the way a real DCS names nodes
br = await server.nodes.objects.add_object(
ua.NodeId("BR101", idx), ua.QualifiedName("BR101", idx))
nodes = {}
for tag, (unit, low, high) in TAGS.items():
var = await br.add_variable(
ua.NodeId(tag, idx), ua.QualifiedName(tag, idx), 0.0)
await var.set_writable()
# make the unit + range browsable, not a guess
eu = ua.EUInformation()
eu.NamespaceUri = "http://www.opcfoundation.org/UA/units/un/cefact"
eu.DisplayName = ua.LocalizedText(unit)
await var.add_property(idx, "EngineeringUnits", eu)
await var.add_property(idx, "EURange", ua.Range(Low=float(low), High=float(high)))
nodes[tag] = var
return server, nodes

몇 가지 세부 사항이 제 몫을 합니다. 첫째, 엔드포인트(endpoint) opc.tcp://0.0.0.0:4841/bioproc/는 OPC UA의 기본 바이너리 TCP 전송 방식으로, 효율적이며 클라이언트가 기본으로 접속하는 통로입니다. (0.0.0.0은 "이 기계의 모든 네트워크 인터페이스에서 수신하라"는 뜻이고, 4841은 TCP 포트(port) — OPC UA 트래픽이 도착하는, 호스트 위의 번호가 매겨진 문 — 입니다.) 둘째, register_namespace는 URI(https://example.org/bioproc)를 점유하여 우리 BR101 객체가 자기만의 네임스페이스(namespace) — 우리 노드가 서버 자신의 내장 노드와 충돌하지 않게 해 주는, 이름표가 붙은 이름 바구니 — 에 살게 합니다(이 URI는 순전히 전역적으로 유일한 라벨로 쓰일 뿐, 방문할 수 있는 웹 주소가 아닙니다). 클라이언트는 이를 인덱스(index)로 해소하는데, 뒤에 나오는 클라이언트 코드가 탐색 전에 네임스페이스 번호를 먼저 묻는 이유가 바로 이것입니다. 셋째, 우리는 각 노드에 스택이 자동 할당하는 숫자 대신 ns=2;s=BR101.Titer.PV 같은 명시적 문자열 NodeId를 부여합니다 — 읽기 쉽고, 실제 DCS(Distributed Control System, 분산 제어 시스템 — 공장의 중앙 공정 컨트롤러로, 아래 "실제 현장에서는" 절에서 온전히 다룹니다)가 자기 파라미터에 이름을 붙이는 방식이기도 합니다. 넷째, 우리는 BR101객체(object)로 추가하고 그 아래에 변수(variable)들을 매답니다 — 이 계층 구조 자체가 자기 기술입니다. 탐색하는 클라이언트는 BR101BR101.Titer.PV를 보고 즉시 그 역가(titer)가 해당 바이오리액터에 속한다는 것을 압니다. 다섯째, 각 변수는 EngineeringUnitsEURange 프로퍼티(property)를 싣고 다니므로, 단위(g/L)와 예상 범위(0..10)가 독자가 미리 알아야 하는 관례가 아니라 탐색 가능한 메타데이터가 됩니다. (단위 하나는 생리학적 설명을 한마디 받을 만합니다: BR101.DO.PV의 단위는 %sat — 공기 포화도 대비 백분율로 나타낸 용존 산소(dissolved oxygen)로, CHO 배양에서는 보통 약 30–40% 설정값으로 유지되며, 여전히 밀집되고 활발히 호흡하는 배양액 — 세포는 자라고 대사하면서 산소를 소비합니다 — 이 산소를 최고조 가까이로 끌어 쓰는 배치 후반에 트레이스가 30%대 초중반에 머무는 이유입니다.) 다음 절에서는 이 노드 중 하나를 끝까지 열어 봅니다.

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

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

저장소는 이것을 설명만 하는 게 아니라, 실제로 돌아간다는 것을 증명합니다. demo_roundtrip() 함수는 서버를 시작하고, 배치 후반, 역가가 아직 수확을 향해 오르고 있는 트레이스 10분을 재생한 다음(시뮬레이션 한 스텝은 약 14일짜리 유가식 실행의 1분이므로, 19000–19009 스텝은 13일째 무렵의 10분 — 배치 후반, 수확(harvest) 직전, 즉 배양을 멈추고 항체를 거두어들이는 시점 — 입니다), 클라이언트를 연결해 노드를 다시 읽어 옵니다.

# 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 — 그래서 이 왕복은 결코 조용히 썩어 갈 수 없습니다.

노드 해부학: 값이 실제로 무엇을 싣고 다니는가

왕복은 4.902를 돌려줬지만, 그 숫자는 잔뜩 옷을 차려입고 도착했습니다. 클라이언트에게 노드 전체를 달라고 하면 — 같은 파일의 demo_attributes()는 벌거벗은 read_value() 대신 read_data_value()를 호출합니다 — OPC UA가 함께 싣고 다니는 모든 필드가 돌아옵니다.

{
"node_id": "ns=2;s=BR101.Titer.PV",
"browse_name": "2:BR101.Titer.PV",
"data_type": "Double",
"value": 4.902,
"status": "Good",
"status_hex": "0x00000000",
"unit": "g/L",
"range": [0.0, 10.0],
"has_source_ts": true,
"has_server_ts": true,
"access_level": 3
}

모든 필드는 OPC UA 주소 공간 모델(IEC 62541-3)의 의도된 일부입니다 [13]. UaExpert 브라우저(표준 데스크톱 OPC UA 점검 도구)로 노드를 클릭했을 때처럼 하나씩 따라가 봅시다 — 이것이 바로 "값이 어떤 형태로 들어가는가"에 대한 답입니다.

  • NodeId — 주소. ns=2;s=BR101.Titer.PV는 노드의 고유하고 서버 범위(server-scoped)에서 유일한 정체성이며, 엄격한 형태를 가집니다: ns=<index>;<type>=<identifier>. <type> 플래그는 i(숫자), s(문자열), g(GUID), b(불투명 바이트) 중 하나입니다. 우리는 읽기 쉬운 문자열 s를 골랐고, 스택의 기본값은 ns=2;i=6 같은 벌거벗은 정수 i였을 것입니다. 클라이언트는 NodeId를 추측하지 않습니다 — 탐색으로 발견한 다음, 빠른 읽기를 위해 캐시합니다.
  • NamespaceIndex 대 네임스페이스 URI. ns=2는 단지 지역 핸들일 뿐입니다. 인덱스 0은 언제나 OPC UA 코어 네임스페이스, 1은 서버 자신의 것이며, 우리 https://example.org/bioproc는 이 서버에서 마침 2에 자리 잡았지만 다른 서버에서는 5일 수도 있습니다. URI는 이식 가능한 이름이고 인덱스는 서버별 단축키입니다 — 그래서 클라이언트는 URI를 먼저 인덱스로 해소하고(get_namespace_index) 결코 2를 하드코딩하지 않습니다.
  • BrowseName 대 DisplayName. 두 개의 이름, 두 개의 역할. BrowseName(2:BR101.Titer.PV, QualifiedName = 네임스페이스 인덱스 + 텍스트)은 기계용입니다: 브라우즈 경로를 만들 때 걸어가는 이름이죠. DisplayName(LocalizedText = 로캘 + 텍스트)은 사람용이며 지역화될 수 있습니다. 둘 다 노드를 식별하지는 않습니다 — 오직 NodeId만이 식별합니다.
  • NodeClass와 DataType. BR101.Titer.PV변수(Variable)(값을 가짐)이고, 그 위의 BR101객체(Object)(값은 없고 구조만 가짐)입니다. DataTypeDouble이며 — DataType 자체도 타입 트리 안의 노드이므로, "이건 무슨 타입이지"조차 탐색 가능합니다.
  • Value는 벌거벗은 숫자가 아니라 DataValue입니다. 여기가 핵심입니다. 읽기는 네 개의 필드가 함께 이동하는 DataValue 구조체를 돌려줍니다: 값 자체(Variant에 담겨 — 자기 타입을 아는 컨테이너이므로 4.902는 익명 바이트의 한 줄이 아니라 "Double 4.902"로 이동합니다), 그리고 StatusCode, SourceTimestamp, ServerTimestamp.
  • StatusCode — 두 비트에 담긴 품질. 최상위 두 비트가 판정을 담는 32비트 코드입니다: Good0x00000000, Uncertain0x40000000, Bad0x80000000이고, 하위 비트가 사유(Bad_SensorFailure, Uncertain_LastUsableValue 등)를 가리킵니다 [15]. 이것이 OPC UA 고유의 품질이며 — 곧 Sparkplug 와이어에서 만날 레거시 OPC DA의 192아닙니다. UA의 Good은 그냥 0입니다.
  • 타임스탬프가 둘, 일부러. SourceTimestamp센서가 표본을 뜬 시각이고, ServerTimestamp서버가 도장을 찍은 시각입니다. 데모에서는 마이크로초 차이지만 실제 링크에서는 — 캐시된 값, 느린 필드버스 때문에 — 둘이 벌어지며, 둘 다 보관하는 것이 하류 히스토리안이 도착 시각이 아니라 진짜 표본 시각을 복원하는 방법입니다.
  • AccessLevel — 무엇을 할 수 있는가. 비트마스크입니다: CurrentRead = 1, CurrentWrite = 2, HistoryRead = 4. 쓰기 가능한 우리 노드는 3 — 읽기 더하기 쓰기 — 을 보고합니다.
  • EngineeringUnits와 EURange — 데이터로서의 단위. 변수에 프로퍼티(Property)로 매달려(HasProperty 참조로 도달하며, 일반 속성으로 저장되지 않음) EngineeringUnits(그 DisplayName"g/L"이고 NamespaceUri가 UNECE(United Nations Economic Commission for Europe, 국제 연합 유럽 경제 위원회) 단위 코드 목록 — 모든 공학 단위에 표준 코드를 부여하는 국제 등록부 — 을 가리키는 EUInformation 구조체)와 EURange(Low/High = 0..10)가 있습니다. 여기서 단위는 모두가 공유하기를 바라는 관례이기를 멈추고 클라이언트가 읽을 수 있는 사실이 됩니다 [15].
  • References — 타입이 있는 간선. 노드는 그래프 안의 교차점입니다. BR101.Titer.PVBR101에서 HasComponent 참조로 도달되고, HasTypeDefinition으로 자기 타입(여기서는 BaseDataVariableType)을 가리키며, HasProperty로 자기 단위를 가리킵니다. 각 참조 타입 자체가 노드이므로, 간선의 의미조차 탐색 가능합니다.

OPC UA 노드를 신원 카드로 그린 그림. BR101.Titer.PV 변수의 NodeId, BrowseName, DisplayName, NodeClass, DataType 행이 있고, 값을 Variant로 담고 StatusCode, SourceTimestamp, ServerTimestamp를 함께 가진 강조된 DataValue 블록이 있으며, 이어서 AccessLevel과 EngineeringUnits·EURange 프로퍼티, 그리고 부모 객체·타입 정의·단위 프로퍼티로 향하는 타입 있는 References 패널이 있다. 노드 하나를 끝까지 펼친 모습. 읽기는 벌거벗은 숫자가 아니라 라벨이 붙은 카드를 돌려줍니다 — 정체성, 타입, 값, 품질, 시간, 단위, 범위, 그리고 이웃 노드로 향하는 타입 있는 간선들. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

그 카드 전체 — 정체성, 타입, 값, 품질, 시간, 단위, 범위, 간선 — 가 사서가 하나의 태그에 대해 건네주는 것입니다. 그것이 숫자와 측정값의 모든 차이입니다.

이 내용이 시리즈에서 놓이는 자리

이 DataValue 카드는 두 자매 책이 이미 들려준 이야기를 오픈 소스로 구현한 것입니다. 카드가 싣고 다니는 4.902 g/L생산 바이오리액터 — CHO 배양이 항체를 분비하고 프로브가 그것을 표본으로 뜨는 교반조 단계 — 에서 물리적으로 만들어졌습니다. Book 2는 같은 측정값을 데이터 포인트와 그 열린 과제로 명명합니다: OPC UA DataValue와 Sparkplug 메트릭이 바로 그 책이 정의하는 값 더하기 단위, 타임스탬프, 품질의 구조이며, 스키드(사전 조립되어 프레임에 장착된 장비 패키지) 위 기기와 센서에서 태어납니다. 이 장은 그 데이터 포인트가 마침내 돌아가는 코드가 되는 곳입니다.

Raman 분석기는 실제로 데이터를 어떻게 노출하는가

위에서 펼쳐 본 것은 모두 스칼라(scalar)였습니다 — BR101.Titer.PV는 부동소수점 하나, Double 하나로, DataValue 하나에 실려 갑니다. 현장의 거의 모든 태그가 이 형태를 띱니다. 하지만 바이오리액터에 진짜 PAT(Process Analytical Technology, 공정 분석 기술) 분석기를 볼트로 붙이는 순간, 와이어가 싣고 가는 것은 종류 자체가 달라지며, 책의 나머지가 여러분을 놀라게 하지 않도록 지금 봐 두는 것이 좋습니다.

브로스(broth)를 실시간으로 지켜보며 뒤에서 우리가 만들 역가(titer) 소프트 센서(soft-sensor)에 데이터를 먹이는 인라인 Raman(라만) 분석기를 예로 들어 봅시다. 대표적인 업계 계측기는 Endress+Hauser Kaiser Raman Rxn2/Rxn4 공정 분석기 계열로(Kaiser Optical Systems는 Endress+Hauser의 일부가 되었습니다), 그 내장형 Raman RunTime 제어 소프트웨어가 이를 구동합니다. Raman RunTime은 이 장이 들려주는 연결성 이야기에 정확히 끼워 넣을 수 있게 만들어졌습니다. 살아 있는 스펙트럼과 계산된 결과를 OPC UA로 — 방금 해부한 그 자기 기술형 주소 공간으로, 그리고 전체 스펙트럼 데이터를 실어 나를 수 있어 권장되는 프로토콜로 — 노출하고, 레지스터 크기의 계산 결과는 Modbus로도 닿을 수 있습니다(레지스터 기반 프로토콜로, 레거시·상용 스키드 연결: Modbus, Siemens S7, PLC4X 장에서 다시 만납니다).

여기까지는 익숙합니다. 반전은 페이로드(payload)에 있습니다. Raman 측정값은 숫자 하나가 아닙니다 — 그것은 스펙트럼(spectrum)입니다: 파수(wavenumber, Raman 시프트, 단위 cm⁻¹)마다 하나씩, 강도(intensity)의 벡터(vector) 전체입니다. 분석 장인 공정 분석: SPC, MVDA, 소프트 센서는 정확히 이런 점을 701개 사용합니다(wn_400wn_1800). 그 분석기가 OPC UA로 발행할 때, DataValue 안의 값은 Double이 아니라 배열(array) — 벡터를 담은 Variant — 이며, 스칼라가 입었던 것과 똑같은 상태 코드와 타임스탬프를 입고 있습니다. 사서는 여전히 라벨이 완전히 붙은 카드를 건네줍니다. 다만 그 카드가 기술하는 것은 단일 측정값이 아니라, 하나의 관측으로 함께 묶인 701개의 숫자입니다.

그리고 그 분석기의 네이티브 디스크 형식은 또 한 번 자기만의 방언입니다. Raman 계측기는 CSV를 쓰지 않습니다. 그 네이티브 스펙트럼 파일 형식은 SPC(Thermo Galactic / GRAMS의 .spc 형식)로, 강도-대-파수 배열을 그 축과 취득 메타데이터까지 온전히 담도록 전용으로 만들어진 바이너리 컨테이너입니다. (분석기는 CSV를 스펙트럼이 아니라 파생 모델 결과용으로만 씁니다.) 그래서 분석기 하나가 같은 스펙트럼을 세 가지로 내놓을 수 있습니다 — 디스크 위의 SPC 파일, 와이어 위의 OPC UA 값, 그리고 하류의 컬럼 저장소(column store) 안의 한 행 — 그리고 그 어느 것도 스칼라 태그가 아니고, 그 어느 것도 CSV가 아닙니다.

이것이 플랫폼의 나머지가 존중해야 하는 이음매입니다. 벡터는 태그 히스토리안(historian)이 둘러싸고 지어진 타임스탬프당-부동소수점-하나 모델에 맞지 않으며, 이는 히스토리안 장인 오픈소스 히스토리안: 시계열 저장소 선택과 운영이 정면으로 명명하는 바로 그 문제입니다 — 스펙트럼을 저장하는 것은 태그를 저장하는 것이 아니다. 그 경고를 여기서부터 가지고 가세요: 전송은 스펙트럼을 충실히 옮길 수 있지만, 그것이 도착하는 순간 여러분은 나머지 태그가 떨어지는 스칼라 슬롯이 아니라, 그것이 본래 가진 배열의 형태에 맞는 컨테이너를 그것에게 빚지게 됩니다.

다른 언어에서의 같은 작업

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

와이어만이 아니라 형태(shape)를 표준화하기

방금 본 다국어 상호 운용성은 하나를 한 스택에서 다른 스택으로 충실히 옮겨 주지만, 그 값이 어떻게 배치되는지에 대해서는 아무 말도 하지 않습니다. BR101.Titer.PV 노드를 가진 우리 BR101 주소 공간은 우리가 발명한 배치입니다. 기본 OPC UA(IEC 62541, OPC 10000 시리즈)는 의도적으로 도메인 중립적입니다. 객체, 변수, 타입이 지정된 참조로 어떤 주소 공간이든 어떻게 짓는가에 대한 규칙 — 모델을 만드는 방법의 모델 — 인 메타 모델(meta-model)은 표준화하지만, 바이오리액터의 주소 공간이 무엇을 담아야 하는지는 결코 말하지 않습니다. 두 벤더의 바이오리액터를 여기에 맞춰도, 같은 물리적 대상에 대해 완전히 다른 두 트리가 나올 수 있습니다.

컴패니언 규격(companion specification)이 바로 그 틈을 메웁니다. 이것은 기본 위에 얹는 표준화된 정보 모델(information model)입니다. 한 도메인이나 장치 종류에 대한 객체 타입, 변수, 의미(semantics)를 공표해 둔 묶음으로, 규격을 따르는 장치들이 같은 형태를 노출하게 하여 클라이언트가 벤더의 데이터시트가 아니라 장치의 모델로 그 장치를 알아볼 수 있게 합니다. "컴패니언(동반)"이라 불리는 이유는 OPC Foundation이 산업 단체와 공동으로 개발하기 때문이며 — 대다수가 이런 공동(joint) 규격입니다 — 오늘날 430개가 넘습니다 [16]. 많은 규격이 계층을 이루어 쌓입니다. 그 바닥에는 OPC UA DI(Devices, OPC 10000-100 / IEC 62541-100)가 있는데, 명판(nameplate)·상태(health)·벤더 신원 같은 일반 장치 모델로, 더 풍부한 규격들이 이를 물려받아 확장합니다 [17].

우리 같은 라인에는 이 가운데 네 가지가 중요하며, 이 책은 각각을 실제로 쓰이는 자리에서 만납니다:

  • PA-DIM(Process Automation Device Information Model, OPC 30081)은 DI 위에 세워져 공정 계측기업스트림 수집: 생산 바이오리액터 장에서 다루는 바이오리액터 스키드에 붙는 pH·온도·유량 트랜스미터 — 의 데이터와 NAMUR 방식 진단을 표준화합니다. FieldComm Group, OPC Foundation, PROFIBUS & PROFINET International, NAMUR, ODVA, ZVEI, VDMA, FDT Group, ISA100 WCI 등 아홉 개 기관이 공동 소유하는데, 이는 벤더 간 계측기 의미를 합의하기가 얼마나 어려운지를 가장 분명히 보여 줍니다 [18].
  • PackML(OPC 30050)은 ISA-88(공장이 조직되는 장비 계층 — Unit, 장비 모듈 — 을 정의하는 배치 제어 표준)에서 파생되어, 포장기나 충전기에 표준 상태 모델(state model)과 "PackTags"를 OPC UA 위에서 부여합니다 — 충전·포장과 환경 모니터링 장에서 기대는 바로 그 형태입니다 [19].
  • OPC UA LADS(Laboratory and Analytical Device Standard, OPC 30500) 역시 DI 위에 세워져, 수십 개의 드라이버 대신 하나의 자기 기술형 모델을 실험실·분석 기기에 부여합니다 — 분석 실험실: 기기, LIMS, ELN 장의 근간입니다 [20].
  • MTP(Module Type Package, VDI/VDE/NAMUR 2658)는 OPC 컴패니언 규격이 아니라, 런타임 계층이 OPC UA 정보 모델 위에서 동작하는 모듈형 자동화 표준입니다. 스키드의 서비스를 기술하여 상위 시스템이 plug-and-produce로 오케스트레이션할 수 있게 하는데 — 배치·장비 데이터 모델: PostgreSQL로 구현하는 ISA-88/95 장의 모듈형 장비 모델이 바로 이 아이디어입니다 [21].

그리고 이 장이 여러분에게 빚진 정직한 평결이 여기 있습니다. CHO fed-batch 바이오리액터나 Protein A 포획 단계를 위한 컴패니언 규격은 없습니다 — 이 책 전체가 좇는 핵심 분석값(역가, 생존 세포 밀도, 온라인 글루코스)에는 따를 표준 모델이 없습니다. opcua_server.py가 자기만의 BR101.* 배치를 발명하는 이유가 바로 이것이며, 이는 오늘날 실제 현장의 현실적 다수입니다. 대부분의 서버는 여전히 벤더 고유의 주소 공간을 노출하고, 공정 산업 전반의 컴패니언 규격 채택은 정착이 아니라 부상하는 중입니다 — PA-DIM과 MTP는 아직 젊고, MTP로 지어진 첫 상용 파마 플랜트도 최근에야 등장했습니다. 그러니 컴패니언 규격을 진행 방향으로 다루세요. 트랜스미터, 충전 라인, 실험실 기기가 이미 표준 형태를 가졌다면 그것을 우선하고, 바이오리액터 자체가 아직 그렇지 않다면 5장의 명명 규율(일관된 계층적 자산.측정.PV 점 표기 경로 관례)로 되돌아가 여러분이 발명하는 모델이 적어도 엄격히 자기 일관적이도록 하세요.

클라이언트와 서버는 실제로 어떻게 대화하는가

우리는 노드를 읽고 해부했지만, 그 대화가 어떻게 설정되는지는 건너뛰었습니다. OPC UA가 "무겁다"는 평판은 거의 전적으로 이 핸드셰이크에서 나오며 — 그 모든 단계가 구체적인 무언가를 사들입니다. 클라이언트와 서버는 순서 있는 서비스 집합(service set)(IEC 62541-4)을 따라 움직입니다 [14]. 우리 asyncua 클라이언트는 그 전부를 단 하나의 async with Client(...) 안에서 대신 돌려 주지만, 와이어 위에서 무슨 일이 벌어지는지 알아 둘 가치가 있습니다.

두 개의 생명선(lifeline)을 가진 시퀀스 다이어그램 — 왼쪽에 OPC UA 클라이언트, 오른쪽에 서버 — 이 다섯 단계를 밟는다. 탐색: 클라이언트가 GetEndpoints를 호출하고 서버가 엔드포인트 URL과 보안 정책을 돌려준다. 보안 채널 열기: Basic256Sha256 정책으로 서명·암호화된 OpenSecureChannel, 인증서를 신뢰 목록과 대조한다. 세션 생성·활성화: CreateSession 다음 사용자 신원을 실은 ActivateSession. 브라우징과 읽기: Browse BR101이 자식 노드를 돌려주고, 역가 노드의 Read가 4.902, Good, 타임스탬프를 담은 DataValue를 돌려준다. 구독: CreateSubscription과 CreateMonitoredItems 후, 서버가 클라이언트가 열어 둔 Publish 요청에 응답하여 바뀌는 값 4.897 다음 4.902를 돌려준다.

  1. 탐색 — GetEndpoints. 어떤 보안에 앞서, 클라이언트는 서버의 탐색 엔드포인트(discovery endpoint)에 접속해 무엇을 제공하느냐고 묻습니다. 서버는 엔드포인트마다 EndpointDescription을 돌려줍니다: URL, 보안 정책(SecurityPolicy)(가령 Basic256Sha256), 메시지 보안 모드(MessageSecurityMode)(None, Sign — 메시지에 서명하여 변조를 탐지할 수 있지만 내용은 여전히 와이어에서 읽을 수 있음, 또는 SignAndEncrypt — 서명에 더해 암호화하여 내용까지 숨김), 그리고 받아들이는 사용자 신원 토큰. 클라이언트는 이제 사전 공유 설정 파일 없이도 안전하게 접속하는 법을 압니다.
  2. 보안 채널 열기 — OpenSecureChannel. 클라이언트는 보안 채널(SecureChannel)을 세웁니다: 서명되고 (선택적으로) 암호화된 전송 터널입니다. 여기서 다음 절의 애플리케이션 인증서신뢰 목록이 일을 합니다 — 채널은 이후 모든 메시지가 그 안에서 타고 가는 암호 봉투입니다. 계층 구조에 주목하세요: 채널은 전송을 보호할 뿐, 아직 사용자를 인증하지는 않았습니다.
  3. 세션 생성·활성화 — CreateSession 다음 ActivateSession. 세션(Session)은 채널 위에 타고 가는 애플리케이션 수준의 대화입니다. CreateSession은 그것을 열고(아직 사용 불가), ActivateSession사용자 신원을 공급하고 검증하는 곳입니다 — 익명, 사용자명/비밀번호, 또는 X.509 인증서. 둘을 나누는 것이 중요합니다: 세션은 채널이 끊겨도 살아남아 새 채널에 다시 묶일 수 있으므로, 잠깐의 네트워크 끊김이 여러분의 구독을 잃게 하지 않습니다.
  4. 브라우징과 읽기 — View와 Attribute 서비스. 이제 쓸모 있는 부분입니다. Browse는 노드의 참조를 걸어 자식을 발견합니다 — 클라이언트가 BR101.Titer.PV가 있다는 말을 듣지도 않고 BR101 아래에서 그것을 찾아낸 것이 바로 이 방식입니다 — 그리고 Read는 속성을 가져와 위에서 해부한 DataValue 전체를 돌려줍니다. (이력은 별도의 HistoryRead 뒤에 있습니다.)
  5. 구독 — 그리고 변화가 여러분에게 오게 하기. 타이머로 읽는 것(폴링)은 단순하지만 낭비입니다: 움직이지 않은 값을 다시 가져오면서도 폴 사이의 빠른 과도현상은 놓칩니다. 대안은 구독(Subscription)입니다. 클라이언트는 CreateSubscription(발행 간격(publishing interval) — 알림을 얼마나 자주 전달할지 — 을 설정)과 CreateMonitoredItems(노드마다 하나씩, 각자 표본 간격(sampling interval)과 사소한 흔들림을 걸러 내는 선택적 데드밴드(deadband)를 가짐)를 호출합니다. 그러면 데이터는 반대 방향으로 흐릅니다: 값이 바뀔 때 서버가 그것을 돌려줍니다.

저장소가 이것도 증명합니다. demo_subscription()은 역가 노드에 모니터링 항목 하나를 등록하고 서버가 변화를 흘려보내게 합니다.

{"endpoint": "opc.tcp://0.0.0.0:4841/bioproc/", "notifications": 8, "first_g_L": 4.897, "last_g_L": 4.902}

여러 개의 변경 알림이 클라이언트가 다시 묻지 않았는데도 도착했습니다(참조 실행에서는 여덟 개; 데모가 100 ms 발행 간격에 맞서 변화를 일으키므로 정확한 개수는 타이밍에 따라 달라집니다) — 골든 배치가 진행되면서 역가가 결정론적으로 4.897 → 4.902로 오르는 모습이죠. 그리고 다이어그램이 조심스럽게 다루는 한 가지 정직한 미묘함: OPC UA는 서버가 클라이언트로 소켓을 여는 방식이 아닙니다. 클라이언트가 하나 이상의 Publish 요청을 서버에 세워 두고, 서버는 모니터링하는 값이 바뀌는 순간 그 각각에 응답합니다 — 혹은 한동안 아무것도 바뀌지 않으면, 구독이 여전히 살아 있음을 클라이언트가 알도록 빈 킵얼라이브(keep-alive)로 응답합니다. 푸시처럼 느껴지지만, 기계적으로는 클라이언트가 기다리게 남겨 둔 요청에 서버가 답하는 것입니다. 바로 그 차이 때문에 구독은 진짜 서버 발신 콜백이라면 막혔을 방화벽을 통과합니다.

1–3단계는 또한 다음에 올 내용과의 접합점입니다: 바로 그곳이 현장 배포가 보안을 켜거나, 조용히 꺼 두는 자리입니다.

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

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

하지만 할 수 있다실제로 한다는 서로 다른 행성입니다. 2020년 인터넷 전역 측정 연구는 도달 가능한 OPC UA 배포의 92%에 달하는 수가 안전하지 않은 구성을 가졌음을 발견했고, 가장 뼈아프게도, Basic256Sha256 정책을 광고하던 564개 서버 중 409개가 그 정책과 일치하지조차 않는 인증서를 제시하여 MD5/SHA-1 서명이나 짧은 키로 후퇴했습니다 [3]. 그 표본은 인터넷에서 도달 가능한 서버였습니다 — 잘 분리된(segmented) 플랜트 서버는 방화벽 뒤에 있어 표본에 들지 않았죠 — 하지만 잘못된 구성은 똑같고, 교훈은 그대로입니다. 서버는 강력한 보안을 주장하면서도 깨진 자격 증명을 건넬 수 있습니다. 약한 고리는 결코 프로토콜이 아니었습니다. 배포가 문제였습니다.

그래서 우리 노트북 데모(교육용으로 열린 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(open-source software, 오픈 소스 소프트웨어)가 와이어 위에서는 잘 해내지만, 내장 인증서 생애 주기(certificate lifecycle) — 발급, 교체(rotation), 폐기(revocation) — 는 제공하지 않는 지점입니다. GxP 공장(규제·품질 관리 시설 — GxP는 의약품 제조를 규율하는 Good x Practice, 가령 우수 제조 관리 기준(GMP) 규칙을 아우르는 상위 용어)에서는 그것을 실제 PKI(public-key infrastructure, 공개 키 기반 구조 — 전역 탐색 서버(Global Discovery Server)나 사이트 CA)에 볼트로 결합하고, 그것을 문서화합니다. 스택은 공짜지만, 규율은 그렇지 않습니다.

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

서로를 보완하는 두 가지 전송 방식. OPC UA는 "이 태그에 대해 모든 것을, 안전하게, 요청 시 알려 달라"에 답하고, MQTT 위의 Sparkplug B는 "여기 내가 누구이고 무엇이 바뀌었는지 알린다"를 외치며, 장치가 죽는 순간 네트워크가 그것을 알게 됨을 보장합니다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

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

OPC UA는 요청 구동형(request-driven)이며 무겁습니다. 풍부한 탐색과 안전한 점대점(point-to-point) 읽기에서 빛을 발하죠. 하지만 수백 개의 장치와 가느다란 네트워크 링크를 가진 공장은 가볍고 팬아웃(fan-out) 되는 경로도 원합니다. 그것이 바로 MQTT(OASIS 표준, ISO/IEC 20922로도 발행됨)입니다 — 장치가 토픽(topic)에 발행하면 브로커(broker)가 구독한 누구에게나 메시지를 펼쳐 주는 발행/구독 프로토콜입니다 [4]. 이것은 검소하기로 유명하며, 그래서 토양 센서부터 바이오리액터 스키드(skid)까지 모든 것 위에서 돌아갑니다. (깔끔한 이분법에 대한 정직한 단서 하나: OPC UA가 오로지 요청 구동형인 것은 아닙니다 — OPC UA PubSub(IEC 62541-14)은 그 자체로 MQTT 위에 올라탈 수도 있는 브로커 기반 또는 UDP 기반 발행/구독 모드를 더하며, Sparkplug의 영역과 겹칩니다. 이 책이 기반으로 삼는 클라이언트–서버 모드보다 더 젊고 훨씬 덜 배포되어 있어, 우리는 OPC UA를 요청/응답 절반으로, Sparkplug를 발행/구독 절반으로 다룹니다 — 실제 현장에서 아직 가장 흔히 마주치는 구분이죠.)

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

# examples/platform/mosquitto/mosquitto.conf
# Mosquitto broker config for the local dev stack (Chapter 7).
# Dev-only: anonymous access on the plain 1883 listener. Chapter 28 (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를 지원하며 [9], 28장에서는 이 파일을 TLS 리스너와 클라이언트별 접근 제어 목록(access-control list)으로 교체합니다. 안전하지 않은 개발 설정을 보여 주고 그것에 큰 소리로 라벨을 붙이는 것이야말로 이 책이 설파하는 규율입니다 — 우리는 편리한 기본값이 운영 환경에 슬쩍 끼어들도록 결코 내버려 두지 않습니다.

Sparkplug B: 버스에 심장 박동을 부여하기

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

Sparkplug는 버스 위의 모든 메시지를 작고 고정된 어휘로 정리합니다 — 아홉 가지 메시지 타입이며, Sparkplug를 아는 소비자는 이 모두를 압니다.

  • NBIRTH / DBIRTH — 탄생 증명서. 에지 노드(edge node)(BR101 컨트롤러)가 연결되면 스스로를 알리는 NBIRTH를 발행하고, 그 아래의 각 장치(우리의 reactor 스키드)에 대해 DBIRTH앞으로 보고할 모든 메트릭을 정의합니다 — 이름, 데이터타입, 현재 값. 이것이 자기 기술이며, BR101에 g/L 단위 float인 Titer.PV가 있음을 버스가 배우는 순간입니다. 탄생은 계약입니다. 탄생에서 선언되지 않은 것은 나중에 등장할 수 없습니다.
  • NDATA / DDATA — 변화. 탄생 이후 노드는 움직인 것만 보냅니다 — 노드 수준 메트릭은 NDATA로, 장치 메트릭은 DDATA로 — 바뀐 값만 싣고 그 외에는 아무것도 싣지 않습니다. 이것이 예외 보고(report by exception)입니다. 바뀌지 않은 값은 다음 탄생까지 다시 보낼 필요가 없으며, 이는 OPC UA 구독의 데드밴드(deadband)와 똑같은 절약이고, 안정된 공정 위에서 Sparkplug 버스가 조용히 있는 이유입니다 [5].
  • NCMD / DCMD — 되쓰기(write-back). 흐름은 일방향이 아닙니다. 기본 호스트(primary host)(SCADA 감시 제어 시스템이나, 제조 실행 계층인 MES)는 NCMDDCMD를 발행하여 메트릭을 아래로 되쓸 수 있습니다 — 새 설정값(setpoint)이거나, 에지 노드에게 탄생 전체를 다시 알리라고 명령하는 예약된 Node Control/Rebirth 플래그입니다.
  • NDEATH / DDEATH — 사망 증명서. 노드, 또는 그 아래의 한 장치가 오프라인이 되었다는 알림입니다.
  • STATE — 호스트 자신의 맥박. 기본 호스트는 자신의 생존을 STATE 토픽에 발행하여, 에지 노드가 자기 기록상의 소비자(consumer-of-record)가 듣고 있기는 한지 알 수 있게 합니다(아래에서 더 다룹니다).

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

Sparkplug 메시지 해부학: 토픽, 봉투(envelope), 그리고 메트릭

OPC UA 읽기는 잔뜩 옷을 차려입고 도착했습니다. Sparkplug 메시지도 같은 천으로 재단되어, 다만 다르게 접혀 있을 뿐입니다. 주소는 바깥쪽 토픽에, 의미는 안쪽 자기 기술형 메트릭에 있습니다. 우리 바이오리액터의 DBIRTH는 다음과 같이 생겼습니다 — Eclipse Tahu가 만들어 내는 형태이며, 디코딩된 모습으로 보여 줍니다(와이어 그 자체는 텍스트가 아니라 압축 바이너리입니다 — 아래 참조).

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

Sparkplug 디코더가 이 메시지를 받아들일 때처럼 하나씩 따라가 봅시다 — 이것이 "Sparkplug 측정값이 어떤 형태로 들어가는가"에 대한 답입니다.

  • 토픽이 곧 주소 — 다섯 개의 엄격한 필드. spBv1.0 / newark / DBIRTH / BR101 / reactor네임스페이스 / group_id / 메시지 타입 / edge_node_id / device_id입니다. spBv1.0은 글자 그대로 Sparkplug B, 버전 1.0을 뜻합니다(예전의 spAv1.0은 폐기되었습니다). 그룹은 우리 사이트이고(5장의 UNS(Unified Namespace, 통합 네임스페이스) 경로 관례에 맞추려고 소문자), 마지막 필드인 장치는 오직 장치 수준 메시지에만 있습니다 — 그래서 노드 수준 NBIRTH나 NDEATH는 그것을 빼고 네 개짜리 토픽을 탑니다. 그 고정된 형태 덕분에 어떤 Sparkplug 소비자든 spBv1.0/#을 구독하기만 하면 공장 전체를 발견할 수 있으며(# 와일드카드는 그 아래의 모든 토픽 수준에 일치하고, +는 정확히 한 수준에만 일치합니다), 이는 이후 장에서 발전시킬 통합 네임스페이스(Unified Namespace) 아이디어와 맞물립니다.
  • 와이어는 JSON이 아니라 프로토콜 버퍼(Protocol Buffers). 위의 JSON은 디코딩일 뿐입니다. 와이어 위에서 Sparkplug B 페이로드는 압축된 Google 프로토콜 버퍼 바이너리 덩어리입니다 [5] — 그래서 가느다란 링크에도 충분히 검소하고, 그래서 소비자는 그것을 읽으려면 Sparkplug 스키마가 필요합니다. (유일한 예외는 STATE 메시지로, 이것은 정말로 JSON 텍스트입니다.)
  • 봉투 — timestamp와 seq. 메트릭을 감싸는 것은 페이로드 timestamp시퀀스 번호 seq입니다. seq0 → 255를 세고 다시 0으로 도는 한 바이트 — 여덟 비트, 즉 256개의 서로 다른 값 — 입니다. NBIRTH가 이를 0으로 재설정하고, 이후 모든 메시지가 하나씩 더합니다. seq가 — 7 다음 9로 — 건너뛰는 것을 본 소비자는 자기가 8번 메시지를 놓쳤음을 알고, 새 탄생을 요구할 수 있습니다(Node Control/Rebirth를 실은 NCMD). 이것이 버스에 내장된 간극 탐지기이며, OPC UA 읽기에게 무언가 잘못됐다고 알려 주는 품질 플래그에 대략 해당합니다.
  • 메트릭 — 이름, 타입, 값, 그리고 자기만의 라벨 카드. 각 메트릭은 자신의 name, datatype(여기서는 Float), value, 선택적 메트릭 수준 timestamp, 그리고 properties 집합을 싣고 다닙니다 — 우리의 unit(g/L)과 quality가 타고 가는 곳입니다. 이는 OPC UA가 DataValue에 담는 값 더하기 의미의 규율과 똑같으며, 다만 이 아니라 알림입니다. 메트릭은 또한 Sparkplug가 정의하지만 우리 탄생이 기본값으로 두는 플래그들도 싣습니다 — is_historical(소급 채운 값), is_transient(저장하지 말 것), is_null(선언되었으나 지금은 값이 없음).
  • 별칭(alias) — 이름은 한 번, 그다음엔 숫자. 각 메트릭에 alias(1, 2)도 있음에 주목하세요. 탄생은 모든 메트릭에 전체 이름과 작은 정수를 둘 다 주는 데 바이트를 씁니다. 그 뒤로 NDATA와 DDATA 메시지는 별칭과 값만 보내고 이름은 다시 보내지 않습니다 — 역가 업데이트는 사실상 {1: 4.903}으로 줄어듭니다. 이는 OPC UA가 NodeId를 한 번 탐색하고 캐시하는 것과 똑같은 수입니다: 정체성 값은 탄생에서 치르고, 그다음엔 싸게 탑니다.
  • quality: 192 — 더 오래된 프로토콜에서 온 손님.192(0xC0)는 OPC UA 상태가 아니라 레거시 OPC DA(클래식) Good 코드입니다. 많은 Sparkplug 에지 노드가 레거시 OPC DA 서버 앞단에 서서 그 품질을 그대로 통과시키므로, 와이어 위에서 192를 만나게 됩니다 — 반면 앞에서 우리가 만든 OPC UA 서버는 Good을 그냥 0(0x00000000)으로 보고하며, 이것이 바로 asyncua가 각 쓰기에 찍는 값입니다. 같은 발상, 두 혈통.

Sparkplug DBIRTH를 신원 카드로 그린 그림. 장미색 머리띠가 메시지를 토픽 spBv1.0/newark/DBIRTH/BR101/reactor로 명명하고, 다섯 개의 토픽 필드를 네임스페이스, 그룹, 메시지 타입, 에지 노드, 장치로 라벨링한다. 그 아래 봉투 블록이 페이로드 timestamp와 0에서 255까지 세고 도는 seq 시퀀스 번호를 담는다. 강조된 메트릭 블록은 메트릭 하나 BR101.Titer.PV를 보여 주는데, 그 별칭, 데이터타입 Float, 값 4.902, 그리고 단위 g/L와 품질 192를 싣는 properties 행, 그리고 is_historical, is_transient, is_null 플래그가 있다. 바닥 주석은 와이어 페이로드가 JSON이 아니라 프로토콜 버퍼 바이너리이며, 이후 NDATA 메시지는 별칭과 값만 보낸다고 설명한다. Sparkplug 메시지 하나를 끝까지 펼친 모습. 토픽이 주소이고, 봉투는 timestamp와 도는 시퀀스 번호를 싣고, 각 메트릭은 자기만의 라벨 카드입니다 — 이름, 별칭, 타입, 값, 단위, 품질, 플래그 — 와이어 위에서는 텍스트가 아니라 프로토콜 버퍼로 인코딩됩니다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

세션 생애 주기: 탄생, 데이터, 명령, 사망

OPC UA의 대화는 점대점 핸드셰이크였습니다. Sparkplug의 대화는 방송되는 일대기이고, 그 모든 장(章)이 브로커를 통해 흐릅니다. 에지 노드는 소비자와 결코 직접 말하지 않습니다 — 발행하면 Mosquitto가 펼쳐 주고, 기본 호스트가 듣습니다. BR101의 버스 위 일생을 따라가 봅시다.

  1. 연결 — 그리고 유언부터 건네기. 에지 노드는 킵얼라이브(가령 60초)를 걸어 MQTT 연결을 열고, 바로 그 CONNECT 안에서 자신의 NDEATH를 유언(Will)으로 등록합니다 — QoS 1, 비유지(non-retained)(MQTT 브로커는 유지(retained) 메시지를 붙들고 있다가 뒤늦게 구독한 클라이언트에게 다시 보내 줍니다. 사망 증명서는 결코 철 지난 채로 재생되어서는 안 되므로, 유언은 일부러 비유지로 둡니다 — 아래에서 더 다룹니다), bdSeq(탄생/사망 시퀀스 번호) 포함. 사망 증명서는 노드가 살아 있는 말을 한마디 꺼내기도 전에 미리 제출됩니다.
  2. NBIRTH — 알리고 시계를 0으로. 노드는 seq = 0과, 방금 유언에 넣은 것과 같은 bdSeq로 NBIRTH를 발행합니다 — 그래야 나중의 사망을 탄생에 맞출 수 있습니다. 모든 노드 수준 메트릭이 여기서 선언됩니다.
  3. DBIRTH — 장치의 전체 목록. 장치마다 하나의 DBIRTH가 모든 메트릭을 이름·데이터타입·값·별칭과 함께 정의합니다. 이후 소비자는 BR101의 전체 형태를 알게 되고, 뒤따르는 모든 것을 디코딩하는 데 필요한 별칭 표를 손에 쥡니다.
  4. NDATA / DDATA — 예외 보고. 배치가 돌아가는 동안 바뀐 값만 — 별칭으로, QoS 0(발사 후 망각: 어차피 다음 변화가 잃어버린 것을 덮어씁니다)으로 — 흐릅니다. 역가는 4.902 → 4.903 → 4.905로 오르고 그 외에는 아무 말도 하지 않습니다.
  5. NCMD / DCMD — 호스트가 되쓰기. 기본 호스트가 행동해야 할 때, 브로커를 통해 명령을 아래로 발행합니다 — 설정값이거나, 2–3단계를 다시 강제하는 Node Control/Rebirth(소비자가 seq 간극을 발견한 뒤 하는 바로 그것)입니다.
  6. NDEATH — 브로커가 죽은 자를 대변하다. BR101이 응답을 멈추면, 브로커는 킵얼라이브의 1.5배(여기서는 ~90초)를 메시지도 핑(ping)도 없이 기다린 뒤 연결을 네트워크가 끊긴 것처럼 죽은 것으로 선언하고, 노드를 대신해 NDEATH 유언을 발행합니다 [4]. 그 유언이 탄생의 bdSeq를 싣고 있으므로, 호스트는 노드가 이미 재연결하여 다시 태어난 뒤에 도착하는 철 지난 사망을 무시할 수 있습니다.

이 모든 것 위에 한 행위자가 군림합니다 — 에지 노드의 단일 자기 기록상 소비자(consumer-of-record)인 기본 호스트(primary host)입니다. 호스트는 spBv1.0/STATE/{host_id}유지(retained)되는 STATE 메시지로 자신의 생존을 알립니다 — 유지되면서 진짜 JSON인 유일한 Sparkplug 메시지입니다({"online": true, "timestamp": …}). 유지는 MQTT 플래그입니다: 브로커는 토픽의 마지막 유지 메시지를 붙들고 있다가 클라이언트가 구독하는 순간 곧바로 건네주므로, 늦게 합류한 에지 노드도 다음 업데이트를 기다릴 필요 없이 호스트가 온라인임을 알게 됩니다. (위의 NDEATH 유언이 비유지로 등록되는 이유가 바로 이것입니다 — 사망 증명서는 발행되는 그 순간에만 참이므로, 브로커가 그것을 새 구독자에게 철 지난 채로 재생하는 일은 결코 원하지 않습니다.) 에지 노드는 태어나기 전에 그 STATE를 기다리도록, 그리고 호스트가 online: false가 되는 순간 연결을 끊고 장애 조치(fail over)하도록 설정될 수 있습니다 — 그래서 공장은 듣고 있지 않은 히스토리안으로 결코 흘러들지 않습니다. Sparkplug 3.0이 바로 이 이음매를 단단히 했습니다: STATE를 spBv1.0/ 네임스페이스 아래로 옮기고, 예전의 맨 "ONLINE"/"OFFLINE" 문자열을 타임스탬프가 찍힌 JSON으로 바꿔, 순서가 뒤바뀐 OFFLINE을 진짜 OFFLINE과 구별할 수 있게 했습니다 [5].

세 개의 생명선(lifeline)을 가진 시퀀스 다이어그램 — 왼쪽에 장미색 BR101 에지 노드, 가운데에 초록색 Mosquitto 브로커, 오른쪽에 보라색 기본 호스트 또는 히스토리안 — 이 다섯 단계를 밟는다. 연결: 에지 노드가 60초 킵얼라이브로 CONNECT를 보내고 자신의 NDEATH를 QoS 1, 비유지, bdSeq를 실어 유언으로 등록한다. 탄생: 에지 노드가 seq 0과 같은 bdSeq로 NBIRTH를, 이어 모든 메트릭을 별칭과 함께 정의하는 DBIRTH를 발행하고, 브로커가 각 탄생을 호스트로 펼친다. 데이터: DDATA가 바뀐 값만 별칭으로 QoS 0으로, 예외 보고로 실어 호스트로 펼치는 루프. 명령: 호스트가 NCMD Node Control Rebirth를 브로커를 통해 에지 노드로 발행한다. 사망: 에지 노드가 침묵하고, 브로커가 킵얼라이브의 1.5배를 기다린 뒤 NDEATH 유언을 호스트로 발행하며, 그 bdSeq가 탄생과 일치한다.

버스 잠그기 (또다시, 모두가 건너뛰는 부분)

OPC UA는 보안을 자기 명세 안에 구워 넣습니다. Sparkplug는 그 반대로 하고, 또 그것을 솔직히 밝힙니다. Sparkplug 3.0 보안 장은 명시적으로 비규범적(non-normative)입니다 — 프로토콜은 자기 고유의 암호화도 인증도 정의하지 않고, 그 일 전체를 MQTT와 TLS에 넘깁니다 [5]. 이는 결함이라기보다 역할 분담이지만, 보안이 정확히 여러분이 향하는 브로커만큼만 좋다는 뜻입니다 — 그리고 우리의 개발용 mosquitto.conf는 평문 1883 리스너에 allow_anonymous true이니, 없는 것만큼 좋습니다.

그리고 현장 기록은 OPC UA의 92%만큼이나 뼈아픕니다. 2018년 Avast의 인터넷 전역 스캔은 공개 인터넷에서 도달 가능한 MQTT 브로커가 49,000개 이상, 그중 32,000개 이상이 비밀번호가 전혀 없음을 발견했습니다 — 누구든 읽거나 발행할 수 있는, 활짝 열린 버스들이었죠 [22]. 그 연구는 파마 플랜트가 아니라 스마트 홈과 IoT 브로커를 훑었지만, 구성의 죄는 똑같으며, 같은 단계를 잊는다면 바이오리액터 버스도 그만큼 노출됩니다.

그러니 MQTT를 단단히 하는 일은 OPC UA를 단단히 하는 일의 거울상이며, 28장에서 전부 다룹니다. 그 수순은: 모든 바이트를 전송 중에 암호화하는 TLS 리스너(포트 8883); 클라이언트 인증서 — 혹은 최소한 진짜 사용자명/비밀번호, 결코 익명 금지; 그리고 각 신원을 토픽 트리의 자기 몫에 고정하는 클라이언트별 ACL — BR101 에지 노드는 spBv1.0/newark/+/BR101/# 아래로만 발행할 수 있고, 히스토리안은 구독만 할 수 있으며, 그 외에는 아무것도 허용되지 않습니다. 정직한 체크리스트는 전과 같은 모양입니다: 전송을 암호화하고, 피어를 인증하며, 모든 클라이언트를 제 일을 할 수 있는 가장 좁은 토픽 필터로 제한하라. Sparkplug는 깔끔한 생애 주기를 공짜로 건네줍니다. 잠긴 문은 여전히 여러분이 달아야 합니다.

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

이 장에서 가장 깊은 아이디어에 이름을 붙일 만합니다. 구문적 전송(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가 존재하는 이유입니다. 이 책의 나머지 전체에 걸쳐, 데이터가 경계를 넘을 때마다 질문은 같습니다. 의미가 살아남았는가, 아니면 바이트만 살아남았는가? 의미가 살아남는다는 것이 무엇을 뜻하는지 — 모든 시스템이 가리킬 수 있는 공유 모델로, RDF 트리플로 표현되고 SHACL 형상으로 통제되며 상위 온톨로지에 근거를 둔 것 — 는 Book 2의 숫자가 연결되지 않는 이유온톨로지와 FAIR 데이터의 주제이며, 이 책의 지식 그래프 장에서 질의 가능한 그래프가 됩니다.

트리플로서의 DataValue, 제약으로서의 품질 플래그

"의미가 살아남았다"는 것이 그 읽기가 그래프에 도착했을 때 어떤 모습인지를 구체적으로 짚어 둘 만합니다. 우리의 DataValue는 거의 말 그대로 작은 RDF 트리플 묶음입니다(트리플은 지식 그래프를 이루는 주어-술어-목적어 사실입니다). BR101.Titer.PV의 읽기는 bp:obs-19009 bp:onTag "BR101.Titer.PV", bp:obs-19009 bp:value "4.902"^^xsd:float, bp:obs-19009 bp:unit "g/L", bp:obs-19009 bp:resultTime "…"^^xsd:dateTime, bp:obs-19009 bp:statusCode "Good"가 됩니다 — 방금 전송한 값, 단위, 타임스탬프, 품질 플래그가 각각 이제 다음 시스템이 추측해야 할 컬럼이 아니라 일급(first-class) 사실이 됩니다. 이것이 바로 이 책의 지식 그래프 장이 구축하는 트리플 해부이며, 술어 뒤의 공유 모델은 Book 4의 클래스와 분류체계식별자와 단위 장에서 다루는 주제입니다.

품질 플래그는 와이어와 그래프가 가장 유용하게 만나는 지점입니다. Bad StatusCode나 Good이어야 할 192는 단지 전송 산물이 아닙니다 — 그것은 하류 SHACL 형상(형상 제약 언어로, 그래프 데이터가 여러분이 요구하는 구조를 갖추었는지 검증합니다)이 그 숫자가 SPC 차트나 출하 계산에 닿기 전에 거부할 수 있는 단 하나의 사실입니다. Book 4의 릴리스 게이트가 완전하고 규격에 맞는 CQA 패널을 요구하는 데 쓰는 바로 그 sh:minCount/sh:datatype/sh:message 장치가 여기서는 한 단계 더 일찍 적용됩니다. 관측에 대한 형상은 도착하는 모든 읽기가 정확히 하나의 값, 하나의 단위, 하나의 타임스탬프, 그리고 Good 상태를 싣도록 강제할 수 있어, "품질 플래그가 살아남았는가?"를 적재 파이프라인이 시행할 수 있는 폐쇄형(closed-world) 합격/불합격으로 바꿉니다. 그리고 아래 규제 이야기의 귀속 가능(attributable) 한 절반 자체가 하나의 그래프 패턴입니다. 각 읽기는 그것을 방출한 OPC UA 서버나 Sparkplug 엣지 노드에 귀속된(prov:wasAttributedTo) prov:Entity 이므로, 보관 연속성의 사슬은 관계와 계보식별자와 단위 장이 모델링하는 PROV-O 출처가 됩니다 — 누가 무엇을 보냈는지 를 와이어에서 그저 바라기만 하는 것이 아니라 트리플로 기록하는 것입니다.

왜 중요한가

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

바로 그 바닥이, 그 위에 올라가는 어떤 학습 모델이든 방어 가능하게 만듭니다 — 그리고 전송의 세 가지 필드가 바로 모델이 나중에 기대는 필드로 드러납니다. 타임스탬프 한 쌍(소스 대 서버)은 잡무가 아닙니다. 라만 스펙트럼으로 학습한 소프트 센서는 행을 섞는 대신 배치 전체를 떼어 두고 검증해야 하는데, 같은 배치에서 몇 분 간격으로 찍은 두 읽기는 검정 답의 거의 사본을 학습으로 누설하는 거의 복제본이기 때문이며 — 모든 읽기가 정직한 샘플 시각을 달고 도착해야만 읽기들을 배치로 묶을 수 있습니다. 그 배치 단위 떼어 두기 규율(scikit-learn의 GroupKFold / LeaveOneGroupOut, 배치 키로 그룹화)이 Book 5의 학습 문제모델과 검증 장의 논지 전체이며, 그것은 와이어 위의 깨끗한 타임스탬프에서 시작합니다. 품질 플래그는 모델의 첫 번째 적용 영역(applicability domain) 신호입니다 — BadUncertain 읽기는 모델이 결코 보정받은 적 없는 입력으로, 모델이 자신 있게 외삽하기보다 보류해야 하는 종류이며, 이는 적용 영역 게이트가 고집하는 바로 그것입니다. 그리고 전송을 귀속 가능 하게 만드는 바로 그 출처가 모델에게 계보(lineage)를 줍니다. 각 읽기에 어느 프로브와 어느 서버가 도장을 찍었는지를 아는 것이, 나중에 정직한 모델 드리프트(보정이 낡아 감, 프로브가 서서히 오염됨)와 진짜 공정 드리프트(살아 있는 배양이 캠페인마다 진정으로 변함)를 구별하는 방법이며 — MLOps와 생애 주기 장이 두 가지 드리프트 탐지기를 그 둘레에 짓는 구별입니다. 단위, 타임스탬프, 품질 없이 도착한 읽기는 그 점검들이 의존하는 증거를 이미 파괴한 것입니다. 전송이 그것을 보존하거나, 아니면 뒤따르는 분석이 눈 가린 채 나는 것입니다.

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

실제 현장에서는

현대의 mAb 생산 현장에 들어서면 제어 시스템이 IT 세계를 만나는 거의 모든 곳에서 OPC UA를 발견하게 됩니다 — Emerson DeltaV, Siemens PCS 7, AVEVA PI가 모두 그것을 말합니다 — 한편 MQTT/Sparkplug는 점점 더 팬아웃이 높은 텔레메트리(telemetry)와 에지 데이터를 실어 나릅니다. 우리의 유가식(fed-batch) CHO + Protein A 라인 — 유가식은 운전 내내 양분을 공급하고 마지막에 배양을 수확하는 유한한 배치이고, Protein A는 항체를 포획하는 레진 단계입니다 — 은 가장 지배적인 승인 항체 방식이며, 그 센서들은 20년 동안 OPC를 말해 왔습니다. 집약식/연속식 변형 — 다중 컬럼(multi-column) 포집을 갖춘 관류식(perfusion)(Book 1에서 다루는 연속 공급-방출 모드) — 은 태그 수를 배가시킬 뿐입니다: 관류식은 연속 세포 유지(ATF/TFF), 블리드율(bleed-rate)과 관류율(perfusion-rate) 컨트롤러, 그리고 각 컬럼이 자기만의 UV280, 전도도, pH 프로브를 싣는 다중 컬럼 포집 스키드를 더하며 — 그 하나하나가 새 태그입니다. 바로 그때가 가벼운 Sparkplug 버스가 OPC UA 옆에서 제 자리를 얻는 시점입니다.

하지만 우리 데모는 바이오리액터 하나이고, 실제 스위트는 여럿입니다 — 그리고 그 가운데 DCS(Distributed Control System, 분산 제어 시스템)가 있습니다. 상용 현장이 리액터 한 대만 돌리는 일은 없습니다. 시드 트레인(seed train)이 여러 생산 용기에 공급하며, 단일 BR101을 노출하는 우리 opcua_server.py는 그것의 교육용 크기 단면입니다. 그것을 규모로 키우는 조각이 바로 DCS — 여기서는 DeltaV — 입니다. 각 바이오리액터의 프로브와 액추에이터는 DeltaV의 현장 I/O에 직접 배선되며, 그래서 DeltaV는 각 리액터를 폴링하는 원격 클라이언트가 아니라, 리액터들이 배선되어 들어가는 제어 시스템 그 자체이며, 모든 용기가 자기만의 ISA-88 Unit(BR101, BR102, BR103, …)으로 구성됩니다. DeltaV 하나가 스위트 전체를 돌립니다.

OPC UA는 한 계층 위, DCS와 IT 세계 사이에서 등장하며, 바로 여기서 클라이언트/서버 역할이 사람들을 헷갈리게 합니다. DeltaV가 OPC UA 서버를 돌립니다 — 대부분의 공장에서 이는 DeltaV의 오래된 OPC Classic 인터페이스를 OPC UA로 다시 표현하는 래퍼(wrapper) 로서, 그 주소 공간이 모든 Unit을 형제 하위 트리(BR101.Titer.PV, BR102.Titer.PV, …)로 담는 단일 엔드포인트입니다. 클라이언트는 소비자들입니다: 히스토리안(AVEVA PI), MES, 또는 이 책이 구축하는 오픈 소스 컬렉터. 이들은 모두 그 하나의 엔드포인트에 접속해 필요한 Unit을 브라우징하거나 구독합니다 — 서버 하나, 클라이언트 여럿, 모든 리액터가 하나의 트리 안에. 이것이 단일 BR101 데모를 실제 공장에 정직하게 대응시킨 모습입니다. 용기마다 서버 하나가 아니라, DCS 하나가 그것들을 모아 통째로 다시 노출하는 것이죠. (그 래퍼의 내부가 어떻게 생겼는지 — 실시간 데이터·알람·이력 면(face), DeltaV의 세 내부 히스토리안, 그리고 분석 스택이 그것을 읽어 들이는 읽기 전용 이음새 — 는 DCS·MES·ERP 연동 장의 주제로, 거기서 우리는 DeltaV를 실제로 끌어옵니다.)

그렇다면 OPC UA 클라이언트가 "바이오리액터에 임베드"되어 있을까요? 거의 그렇지 않습니다. 현대 리액터에 — 또는 그 옆에 — 임베드될 수 있는 것은 서버입니다. 단일 사용(single-use) 스키드(사전 조립되어 프레임에 장착된 장비 패키지 — Sartorius, Cytiva, Thermo)는 흔히 자체 컨트롤러에서 OPC UA 서버를 돌립니다 — 최신 라인에서는 네이티브로, 그 외에는 UA 게이트웨이 뒤의 OPC Classic 서버 형태로 — 그때는 DeltaV(또는 작은 에지 게이트웨이)가 그 스키드를 끌어와 같은 주소 공간에 다시 노출하는 클라이언트입니다. 모든 토폴로지에서 살아남는 규칙은 이것입니다. 리액터에 있는 것은 데이터 소스이므로 OPC UA를 말한다면 서버이고, 클라이언트는 언제나 소비자 쪽에 있습니다.

3단계 아키텍처 다이어그램. 왼쪽에는 BR101, BR102, BR103(그리고 그 이상)의 바이오리액터가 현장 계측기를 실은 박스로 쌓여 있고, 각각 중앙 DeltaV DCS의 현장 I/O에 직접 배선된다. DeltaV 내부에서는 주소 공간 트리가 각 리액터를 ISA-88 Unit으로 담고, 단일 OPC UA 서버 엔드포인트가 그것들을 모두 모은다. 오른쪽에는 세 개의 OPC UA 클라이언트 — 히스토리안(AVEVA PI), MES, 오픈 소스 컬렉터 — 가 각각 그 하나의 엔드포인트에 접속해 모든 unit을 브라우징한다. 하단 주석은 단일 사용 스키드 변형을 설명한다 — 스키드가 자체 임베디드 OPC UA 서버를 호스팅하고 DeltaV가 클라이언트가 되는 경우다. DeltaV 하나, 리액터 여럿: 각 바이오리액터는 자기만의 ISA-88 Unit으로 배선되어 들어가고, DCS는 그것들을 모두 단일 OPC UA 서버를 통해 다시 노출하며, 히스토리안·MES·오픈 소스 컬렉터가 각각 클라이언트로서 그것을 읽습니다. 저자가 AI의 도움을 받아 직접 제작한 그림입니다.

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

핵심 용어

  • OPC UA / IEC 62541 — 그 주소 공간이 값에 더해 타입, 단위, 타임스탬프, 품질을 싣고 다니는 자기 기술형 산업 프로토콜 [1].
  • 주소 공간(Address space) — OPC UA 서버가 노출하는, 탐색 가능한 노드(객체와 변수)의 트리. 자기 기술의 원천.
  • NodeId — 노드의 고유하고 서버 범위에서 유일한 주소. ns=<index>;<type>=<id>로 쓰며, 타입 플래그는 i(숫자), s(문자열), g(GUID), b(불투명) 중 하나. 클라이언트는 추측이 아니라 탐색으로 NodeId를 발견한다 [13].
  • DataValue — 변수 읽기가 돌려주는 구조체. 값(자기 타입을 아는 Variant에 담김)에 더해 StatusCode, SourceTimestamp, ServerTimestamp — 값·품질·시간을 한 단위로, 결코 벌거벗은 숫자로 두지 않는다.
  • EngineeringUnits / EURange — 변수에 (HasProperty로) 매달린 프로퍼티로, 단위(EUInformation, 가령 g/L)와 예상 범위(Range, Low/High)를 관례가 아니라 탐색 가능한 메타데이터로 만든다 [15].
  • 컴패니언 규격(Companion specification) — 기본 메타 모델(IEC 62541) 위에 얹어, 한 도메인이나 장치 종류의 객체 타입과 의미를 고정하여 규격을 따르는 장치들이 같은 형태를 노출하게 하는 표준화된 OPC UA 정보 모델. PA-DIM(공정 계측기), PackML(포장), LADS(실험실 기기) 등이 그 예다. 바이오리액터를 위한 그런 표준은 아직 없으며, 그래서 우리 서버는 커스텀 주소 공간을 쓴다 [16].
  • SecureChannel 대 Session — SecureChannel은 서명·암호화된 전송 터널(OpenSecureChannel)이고, Session은 그 위의 애플리케이션 대화(CreateSession + ActivateSession, 사용자 신원을 검증하는 곳)다. 세션은 채널이 끊긴 뒤 새 채널에 다시 묶일 수 있다 [14].
  • Subscription / MonitoredItem — 변화 구동 경로. 구독(발행 간격을 가짐)이 하나 이상의 모니터링 항목(각자 표본 간격과 선택적 데드밴드를 가짐)을 보유하며, 서버는 폴링을 받는 대신 클라이언트가 열어 둔 Publish 요청에 응답하여 바뀐 값을 돌려준다 [14].
  • 품질 플래그(Quality flag) — 모든 측정값과 함께 이동하여 소비자가 그것을 신뢰할지 알게 해 주는 상태 코드. OPC UA의 Good은 StatusCode 0(0x00000000)인 반면, 레거시 OPC DA(클래식) Good 코드는 192(0xC0)로 — DA 서버 앞단에 선 Sparkplug 브리지가 종종 그대로 통과시키는 값.
  • Basic256Sha256 — 서명·암호화 채널을 위한 현대의 OPC UA 보안 정책(SHA-256, 2048비트 이상 RSA) [2].
  • 신뢰 목록(Trust list) — 서버가 받아들일 피어 인증서의 명시적 집합. 대부분의 현장 배포가 망치는 단계 [3].
  • MQTT — 중앙 브로커를 갖춘 가벼운 발행/구독 프로토콜. OASIS/ISO 20922 [4]. 각 메시지는 QoS를 가진다: 0(최대 한 번, 발사 후 망각), 1(최소 한 번), 2(정확히 한 번). Sparkplug는 데이터에 QoS 0을, NDEATH 유언과 STATE에 QoS 1을 쓴다.
  • 브로커(Broker) — 발행된 메시지를 구독자에게 펼쳐 주는 MQTT 서버(여기서는 Mosquitto).
  • 유언 메시지(Will message) — 클라이언트가 예기치 않게 연결이 끊겼을 때(킵얼라이브의 1.5배 안에 핑이 없을 때) 브로커가 그 클라이언트를 대신해 발행하는 MQTT 메시지. Sparkplug 사망 증명서의 기반 [4].
  • Sparkplug B — MQTT에 엄격한 토픽 네임스페이스와 탄생/사망 생애 주기를 더하는 공개 명세. 페이로드는 JSON이 아니라 Google 프로토콜 버퍼로 인코딩된다 [5].
  • NBIRTH / DBIRTH / NDATA / DDATA / NCMD / DCMD / NDEATH / DDEATH — Sparkplug의 노드/장치 동사들: 탄생은 모든 메트릭을 정의하고, DATA는 바뀐 값만 보고하며(예외 보고), CMD는 호스트가 메트릭을 되쓰고, 사망은 오프라인 전환을 알린다.
  • seq / bdSeq — 페이로드 시퀀스 번호(seq, 0→255 후 도는; 간극은 놓친 메시지를 알리고 재탄생을 촉발한다)와 탄생/사망 시퀀스 번호(bdSeq, NBIRTH와 그 NDEATH 유언에서 동일하여, 철 지난 사망을 올바른 탄생에 맞출 수 있다) [5].
  • 별칭(Alias) — 탄생이 각 메트릭에 이름과 함께 부여하는 작은 정수. 이후 DATA 메시지는 대역폭을 아끼려고 별칭과 값만 보내는데, 클라이언트가 한 번 탐색한 OPC UA NodeId를 캐시하는 것과 같은 방식이다.
  • 기본 호스트(Primary host) / STATE — 에지 노드의 단일 자기 기록상 소비자. 유지되는 JSON STATE(online + timestamp)를 발행하여 에지 노드가 누군가 듣고 있는지 알 수 있게 하고, 호스트가 오프라인이 되면 장애 조치하게 한다 [5].
  • 구문적 대 의미적 전송(Syntactic vs semantic transport) — 바이트를 안전하게 옮기기 대 의미(값 + 단위 + 시간 + 품질)를 온전하게 옮기기.
  • RDF 트리플 / SHACL 형상 / PROV-O — DataValue가 지식 그래프에 도착하면 RDF 트리플(주어-술어-목적어 사실: 값, 단위, 타임스탬프, 품질)이 된다. SHACL 형상은 도착하는 모든 읽기가 그것들을 싣고 있는지 검증하고(Book 4의 릴리스 게이트가 재사용하는 폐쇄형 관문), PROV-O는 각 읽기를 그것을 방출한 서버나 엣지 노드에 귀속된 prov:Entity로 기록한다 — 데이터로서의 귀속 가능한 보관 연속성.
  • 배치 단위 떼어 두기 검증 / 적용 영역(Leave-one-batch-out validation / applicability domain) — 읽기를 (정직한 타임스탬프로) 배치별로 묶어, 소프트 센서가 배치 내 이웃을 누설하는 대신 떼어 둔 배치로 채점받게 하는 것. 품질 플래그는 적용 영역 신호도 겸하여, 모델이 결코 보정받은 적 없는 읽기를 표시한다. Book 5의 학습 문제MLOps와 생애 주기에 근거를 둔다.

다음 이야기

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