
실습 2에 이어서 환경 구성을 더욱 운영 환경에 맞추어 설정하보겠습니다.
PLC 테스트 코드를 하나 더 증가, 다중 테스트
PLC 테스트, 메시지 전송하는 프로그램을 추가, 동일한 node로 메시지를 전송해보았습니다.
하나의 노드는 두 PLC 메시지를 정상적으로 처리했습니다.
노드를 쪼개서 관리하면 하나의 설비에서 상태 값 별로 서로 다른 메시지를 OPC-UA서버로 전달하는 구조가
가능할 것 같습니다.
장점
- 값별로 독립적으로 갱신 가능
- MES는 필요한 태그만 Subscription
- 서버가 동시 write 요청 처리 가능
단점
- 동시성 관리 필요
- Seq/Timestamp 설계 없으면 누락 인지 어려움
- 노드 수 증가, 관리/메모리 부담
- 설계 복잡도 증가
Message Value 단순 구조가 아닌, 실제 Message 포맷으로 변경
NodeSet이란 OPC UA 서버가 외부에 공개할 데이터/구조의 설계도 입니다.
2장, uanodesetimport.xml에 노드를 추가하면서 다루어본 경험이 있습니다.
노드를 쪼개기 위한 구조는 다음과 같습니다.
# 기존 구조
Factory → Line → PLC → Message(String)
Objects
└─ Factory
└─ LINE1
└─ EQP1
├─ Status (UInt16 or Int32) # 0=STOP,1=RUN,2=ALARM...
├─ Temperature (Double) # ex 36.7
├─ LastUpdate (DateTime) # 서버/설비 기준 시간
├─ Seq (UInt32) # 누락/순서 검증용
└─ RawMessage (String) # (선택) 원문/디버깅용
uanodesetimport.xml 설정 파일은 아래 더보기를 클릭해주세요.
<!-- ============================================================
OPC UA PoC Data Model (운영형 최소)
Objects(i=85) → Factory → LINE1 → EQP1 → Variables
- Factory가 LINE1을 "정방향 Organizes"로 반드시 참조해야 UAExpert 트리에 보임
- EQP1 아래 태그는 HasComponent로 구성(장비 태그 모델)
- 알람/Event/Historical은 이번 단계에서 제외(오버엔지니어링 방지)
============================================================ -->
<!-- Factory (Objects 아래에 매달리는 루트) -->
<UAObject NodeId="ns=1;s=Factory" BrowseName="1:Factory">
<DisplayName>Factory</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=61</Reference>
<!-- Objects(i=85) 아래에 붙기 위한 역방향 링크 -->
<Reference ReferenceType="Organizes" IsForward="false">i=85</Reference>
<!-- Factory 하위 구조 -->
<!-- 기존 구조를 유지할 거면 아래 라인은 살리고, 통일할 거면 제거 -->
<!-- <Reference ReferenceType="Organizes">ns=1;s=Factory.Line</Reference> -->
<!-- 반드시 필요: Factory → LINE1 정방향 연결 (이게 없으면 트리에 안 보일 수 있음) -->
<Reference ReferenceType="Organizes">ns=1;s=Factory.LINE1</Reference>
</References>
</UAObject>
<!-- Factory > Line > PLC > Message Structure -->
<!-- <UAObject NodeId="ns=1;s=Factory" BrowseName="1:Factory">
<DisplayName>Factory</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=61</Reference>
<Reference ReferenceType="Organizes" IsForward="false">i=85</Reference>
<Reference ReferenceType="Organizes">ns=1;s=Factory.Line</Reference>
</References>
</UAObject>
<UAObject NodeId="ns=1;s=Factory.Line" BrowseName="1:Line">
<DisplayName>Line</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=61</Reference>
<Reference ReferenceType="Organizes" IsForward="false">ns=1;s=Factory</Reference>
<Reference ReferenceType="Organizes">ns=1;s=Factory.Line.PLC</Reference>
</References>
</UAObject>
<UAObject NodeId="ns=1;s=Factory.Line.PLC" BrowseName="1:PLC">
<DisplayName>PLC</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=61</Reference>
<Reference ReferenceType="Organizes" IsForward="false">ns=1;s=Factory.Line</Reference>
<Reference ReferenceType="Organizes">ns=1;s=Factory.Line.PLC.Message</Reference>
</References>
</UAObject>
<UAVariable DataType="String" NodeId="ns=1;s=Factory.Line.PLC.Message" BrowseName="1:Message" AccessLevel="3">
<DisplayName>Message</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=63</Reference>
<Reference ReferenceType="Organizes" IsForward="false">ns=1;s=Factory.Line.PLC</Reference>
</References>
<Value>
<uax:String></uax:String>
</Value>
</UAVariable> -->
<!-- LINE1 -->
<UAObject NodeId="ns=1;s=Factory.LINE1" BrowseName="1:LINE1">
<DisplayName>LINE1</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=61</Reference>
<!-- 부모(Factory)로의 역방향 링크 -->
<Reference ReferenceType="Organizes" IsForward="false">ns=1;s=Factory</Reference>
<!-- LINE1 하위 설비 -->
<Reference ReferenceType="Organizes">ns=1;s=Factory.LINE1.EQP1</Reference>
</References>
</UAObject>
<!-- EQP1 -->
<UAObject NodeId="ns=1;s=Factory.LINE1.EQP1" BrowseName="1:EQP1">
<DisplayName>EQP1</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=61</Reference>
<!-- 부모(LINE1)로의 역방향 링크 -->
<Reference ReferenceType="Organizes" IsForward="false">ns=1;s=Factory.LINE1</Reference>
<!-- 장비 태그(Variables) -->
<Reference ReferenceType="HasComponent">ns=1;s=Factory.LINE1.EQP1.Status</Reference>
<Reference ReferenceType="HasComponent">ns=1;s=Factory.LINE1.EQP1.Temperature</Reference>
<Reference ReferenceType="HasComponent">ns=1;s=Factory.LINE1.EQP1.LastUpdate</Reference>
<Reference ReferenceType="HasComponent">ns=1;s=Factory.LINE1.EQP1.Seq</Reference>
<Reference ReferenceType="HasComponent">ns=1;s=Factory.LINE1.EQP1.RawMessage</Reference>
</References>
</UAObject>
<!-- Status (UInt16) -->
<UAVariable NodeId="ns=1;s=Factory.LINE1.EQP1.Status"
BrowseName="1:Status"
DataType="UInt16"
AccessLevel="3"
UserAccessLevel="3">
<DisplayName>Status</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=63</Reference>
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;s=Factory.LINE1.EQP1</Reference>
</References>
<Value>
<uax:UInt16>0</uax:UInt16>
</Value>
</UAVariable>
<!-- Temperature (Double) -->
<UAVariable NodeId="ns=1;s=Factory.LINE1.EQP1.Temperature"
BrowseName="1:Temperature"
DataType="Double"
AccessLevel="3"
UserAccessLevel="3">
<DisplayName>Temperature</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=63</Reference>
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;s=Factory.LINE1.EQP1</Reference>
</References>
<Value>
<uax:Double>0</uax:Double>
</Value>
</UAVariable>
<!-- LastUpdate (DateTime) -->
<UAVariable NodeId="ns=1;s=Factory.LINE1.EQP1.LastUpdate"
BrowseName="1:LastUpdate"
DataType="DateTime"
AccessLevel="3"
UserAccessLevel="3">
<DisplayName>LastUpdate</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=63</Reference>
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;s=Factory.LINE1.EQP1</Reference>
</References>
<Value>
<uax:DateTime>1601-01-01T00:00:00Z</uax:DateTime>
</Value>
</UAVariable>
<!-- Seq (UInt32) -->
<UAVariable NodeId="ns=1;s=Factory.LINE1.EQP1.Seq"
BrowseName="1:Seq"
DataType="UInt32"
AccessLevel="3"
UserAccessLevel="3">
<DisplayName>Seq</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=63</Reference>
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;s=Factory.LINE1.EQP1</Reference>
</References>
<Value>
<uax:UInt32>0</uax:UInt32>
</Value>
</UAVariable>
<!-- RawMessage (String) -->
<UAVariable NodeId="ns=1;s=Factory.LINE1.EQP1.RawMessage"
BrowseName="1:RawMessage"
DataType="String"
AccessLevel="3"
UserAccessLevel="3">
<DisplayName>RawMessage</DisplayName>
<References>
<Reference ReferenceType="HasTypeDefinition">i=63</Reference>
<Reference ReferenceType="HasComponent" IsForward="false">ns=1;s=Factory.LINE1.EQP1</Reference>
</References>
<Value>
<uax:String></uax:String>
</Value>
</UAVariable>
노드의 구조의 연결, 아이디 중복 등 문제가 발생한다면, 서버 구동 시 다음과 같이 에러가 납니다.
에러 로그 또한 남기 때문에, 원인 분석이 가능합니다.

또한, 자주 만날 수 있는 에러인 BadWriteNotSupported 가 있습니다.
- BadWriteNotSupported:
The server does not support writing the combination of value,
status and timestamps provided. - “서버는 값(Value), 상태(Status), 타임스탬프(Timestamp)가 함께 포함된 쓰기 요청을 지원하지 않는다.” 의미입니다.
이는 테스트 서버에서 Value만 쓰기를 허용해서 발생하는 것으로 운영 서버에서는 모두 지원하고 있습니다.
- Value
- Value + StatusCode
- Value + Timestamp
- Value + Status + Timestamp
OPC-UA 서버 구동 완료 + Node/Tag 등록이 완료되었다면, 이제 파이썬 코드로 Tag 값을 변경해보면 됩니다.
import asyncio
import time
from datetime import datetime, timezone
from asyncua import Client, ua
OPC_UA_ENDPOINT = "opc.tcp://keun90:48010"
# 서버 NamespaceArray에서 확인된 URI (당신 환경 기준)
TARGET_NAMESPACE_URI = "urn:UnifiedAutomation:CppDemoServer:UANodeSetXmlImport"
# 업데이트 대상 설비 경로
BASE = "Factory.LINE1.EQP1"
# 주기
INTERVAL_SEC = 1
async def get_ns_index(client: Client, namespace_uri: str) -> int:
"""서버 NamespaceArray(i=2255)에서 namespace_uri의 index(ns)를 찾는다."""
ns_array = await client.get_node(
ua.NodeId(ua.ObjectIds.Server_NamespaceArray)
).read_value()
return ns_array.index(namespace_uri)
def node_id_str(ns: int, s: str) -> str:
"""ns=<runtimeIndex>;s=<identifier> 형태로 NodeId 문자열 생성"""
return f"ns={ns};s={s}"
async def main():
async with Client(OPC_UA_ENDPOINT) as client:
ns = await get_ns_index(client, TARGET_NAMESPACE_URI)
# 태그 노드 핸들 (한 번만 만들어 재사용)
n_status = client.get_node(node_id_str(ns, f"{BASE}.Status"))
n_temp = client.get_node(node_id_str(ns, f"{BASE}.Temperature"))
n_ts = client.get_node(node_id_str(ns, f"{BASE}.LastUpdate"))
n_seq = client.get_node(node_id_str(ns, f"{BASE}.Seq"))
n_raw = client.get_node(node_id_str(ns, f"{BASE}.RawMessage"))
seq = 0
while True:
seq += 1
# ---- 값 생성 (의미 없는 로직 없이 최소) ----
status = seq % 3 # 0,1,2 반복 (STOP/RUN/ALARM 같은 상태코드라고 가정)
temp = 36.0 + (seq % 10) * 0.1 # 36.0 ~ 36.9
ts = datetime.now(timezone.utc)
raw = (
f"LINE1|EQP1|status={status}|temp={temp:.2f}|"
f"seq={seq}|ts={int(time.time())}"
)
# ---- 모든 태그 업데이트 ----
# 모든 태그 업데이트 (Value only write)
await n_status.write_attribute(
ua.AttributeIds.Value,
ua.DataValue(ua.Variant(status, ua.VariantType.UInt16))
)
await n_temp.write_attribute(
ua.AttributeIds.Value,
ua.DataValue(ua.Variant(temp, ua.VariantType.Double))
)
await n_ts.write_attribute(
ua.AttributeIds.Value,
ua.DataValue(ua.Variant(ts, ua.VariantType.DateTime))
)
await n_seq.write_attribute(
ua.AttributeIds.Value,
ua.DataValue(ua.Variant(seq, ua.VariantType.UInt32))
)
await n_raw.write_attribute(
ua.AttributeIds.Value,
ua.DataValue(ua.Variant(raw, ua.VariantType.String))
)
print("UPDATED", BASE, "seq", seq, "status", status, "temp", temp)
await asyncio.sleep(INTERVAL_SEC)
if __name__ == "__main__":
asyncio.run(main())
Stress Test
서버의 옳바른 스펙을 산출하는 것도 시니어 엔지니어의 역할이라고 생각합니다.
대표적으로 아래 두 가지 부하 테스트를 진행해보겠습니다.
- PLC → Server Write 부하
- Client Subscription 부하
Wrtie 부하 테스트 환경은 다음과 같습니다.
- 로컬 Desktop의 스펙

- stressTest 로직 (메시지 전송/초, 업데이트 대상 Tag 4개)
- stressTest 서비스 10개 수행
Write 부하까지, 테스트 결과는 CPU=00으로 전혀 부하를 주지 못하는 것을 확인했습니다.

Subscription 부하로 테스트를 이어가겠습니다.
OPC-UA에서 Pub/Sub의 개념을 알아야 하며, 이 개념은 Redis 등 폭 넓게 사용됩니다.
- Subscription이란, 클라이언트가 계속 Read를 반복하지 않고, 서버가 "변화"를 알려줍니다.
SubScription 테스트 환경은 다음과 같습니다.
- 노드에는 총 4개의 Tag가 있습니다.
- 총 4개의 태그에 subscription을 걸어두었습니다.
- 총 10개의 서비스를 수행했습니다.
Write + Subscription 테스트 결과 부하를 주지 못하는 것을 확인했습니다.

CPU 점유율을 높이기 위해서는 태그를 1,000 개 단위로 크게 올리는 것이 필요할 것으로 보입니다.
테스트를 수행하면서 작성한 테스트 코드는 아래 주소에 업로드했습니다.
https://github.com/KEUN-KEUN/plc_python_MessageChange/tree/main
GitHub - KEUN-KEUN/plc_python_MessageChange
Contribute to KEUN-KEUN/plc_python_MessageChange development by creating an account on GitHub.
github.com
실습 4에서는 아래 내용을 다루어보겠습니다.
- Pub/Sub에 대한 이벤트별 시나리오 설계
- OPC-UA에 대한 고급 설정
'IT 제조' 카테고리의 다른 글
| OPC-UA 실습 5 - 인터페이스 시나리오 설계 (0) | 2026.01.26 |
|---|---|
| OPC-UA 실습 4 - Pub/Sub 아키텍처 (0) | 2026.01.21 |
| OPC-UA 실습 2 - PLC 테스트 코드 → OPCUA 서버 → 클라이언트(Uaexpert) 확인 (0) | 2026.01.19 |
| 설비/단말/시스템 간 인터페이스 경험(Socket,API,Message Queue,OPC-UA), 현대차 SDF 관점 (0) | 2026.01.17 |
| OPC-UA와 개념(MES) (1) | 2026.01.17 |