IT 제조

OPC-UA 실습 3 - 가상 환경, Message, StressTest, Pub/Sub

keun90 2026. 1. 21. 12:40

 

 

실습 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에 대한 고급 설정