수정 이력(초판만, 2026년 11월 01일 공개)
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.23968376)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「OPC UA 입문 ── PLC와 장치의 데이터를 Windows 앱으로 수집하기」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.23968376 https://comcomponent.com/ko/blog/opc-ua-windows-data-collection/
- DOI(최신 버전)
- 10.5281/zenodo.23968376
- DOI(이 버전)
- 10.5281/zenodo.23968377
PLC나 장치의 데이터를 Windows 앱으로 수집할 때 장치에 OPC UA 서버가 있으면, 제조사마다 통신을 따로 만들지 않아도 되는 경우가 있습니다. 다만 접속해서 값을 읽는 것만으로는 부족합니다. 값과 함께 돌아오는 상태와 타임스탬프, 변화를 통지받는 구독, 인증서에 의한 접속 허가, 연결이 끊겼을 때의 복구 방법을 알아 두지 않으면 수집한 데이터를 나중에 신뢰할 수 없게 됩니다.
이 글에서는 OPC UA의 구조 중 데이터 수집에 필요한 부분을, C# 클라이언트로 실제로 실행한 결과와 함께 설명합니다. 클라이언트에는 OPC Foundation의 .NET 라이브러리를 쓰고, 상대로는 모의 장치 서버를 썼습니다. 특정 PLC나 장치에서의 동작은 시험하지 않았습니다. 장치별 설정은 그 장치의 자료에서 확인하십시오.
GigE Vision이나 USB3 Vision 카메라에서 이미지를 수집하는 이야기는 「Dropped Frames with GigE Vision and USB3 Vision Cameras」에, 시리얼 통신 장치 이야기는 「시리얼 통신 앱의 함정」에 정리했습니다.
OPC UA와 OPC Classic의 차이
OPC Classic 사양은 Windows의 COM/DCOM을 써서 소프트웨어 구성 요소 사이에서 데이터를 주고받습니다. 공정 데이터를 다루는 OPC DA, 알람을 다루는 OPC A&E, 이력을 다루는 OPC HDA가 각각 별도의 사양으로 정해져 있습니다1.
OPC Foundation은 2008년에 OPC UA(Unified Architecture)를 공개했습니다. OPC UA는 특정 플랫폼에 의존하지 않는 서비스 지향 아키텍처로, Classic 각 사양의 기능을 하나로 묶어 다룹니다. OPC Foundation은 다시 만든 이유로, Microsoft가 COM/DCOM보다 크로스 플랫폼 방식을 중시하게 된 점, Microsoft 이외의 시스템이나 임베디드 기기에서도 동작해야 했던 점, 방화벽을 넘어 안전하게 접속해야 했던 점 등을 들고 있습니다1.
OPC UA 사양은 IEC에서도 IEC 62541 시리즈로 규격화되어 있습니다2. 통신 포트는 IANA에 4840/TCP가 opcua-tcp로 등록되어 있습니다3.
flowchart TB
accTitle: OPC Classic과 OPC UA의 차이
accDescr: OPC Classic은 Windows의 COM과 DCOM을 토대로 DA, A&E, HDA의 별도 사양으로 나뉘어 있고, OPC UA는 플랫폼에 의존하지 않는 하나의 사양에 그 기능을 묶었음을 보여 주는 그림.
C["OPC Classic"] --> D["COM/DCOM(Windows) 위에서 동작"]
D --> DA["DA·A&E·HDA로 나뉜 사양"]
DA -->|"기능을 묶는다"| U["OPC UA(2008년)"]
U --> P["특정 플랫폼에 의존하지 않는다"]
P --> S["IEC 62541로 규격화"]
그림 1: OPC Classic은 COM/DCOM 위의 별도 사양들이고, OPC UA는 그것들을 하나로 묶었다.
기존 설비에 OPC DA 서버밖에 없는 경우에는 COM/DCOM을 쓰는 쪽의 구조가 필요합니다. 이 글은 OPC UA만 다룹니다.
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 13건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
시험한 구성
실행한 구성은 다음과 같습니다.
| 역할 | 사용한 것 |
|---|---|
| 클라이언트 | C#, .NET 10 SDK 10.0.114, NuGet 패키지 OPCFoundation.NetStandard.Opc.Ua.Client 2.0.1 |
| 서버 | Python 3.13과 asyncua 2.1.0으로 만든 모의 장치(같은 PC의 4840번 포트) |
| OS | Linux(Windows에서의 실행은 시험하지 않음) |
클라이언트 라이브러리는 OPC Foundation이 공개한 .NET 구현입니다. 2.0 계열의 사용법은 같은 저장소의 Getting started에 나온 방식에 맞췄습니다4.
flowchart TB
accTitle: 시험한 구성
accDescr: C# 클라이언트가 OPC Foundation의 .NET 라이브러리를 써서, 4840번 포트에서 대기하는 asyncua 모의 장치 서버에 암호화한 연결로 접속하는 구성을 보여 주는 그림.
A["C# 클라이언트(LineCollector)"] -->|"opc.tcp·SignAndEncrypt"| B["모의 장치 서버(asyncua)"]
A -.-> L["OPC Foundation의 .NET 라이브러리 2.0.1"]
B -.-> V["Line1의 변수 4개"]
그림 2: 같은 PC 안에서 C# 클라이언트로 모의 장치 서버에 접속했다.
모의 장치 서버
장치 대신 asyncua로 작은 서버를 만들었습니다5. Line1이라는 오브젝트 아래에 온도(Temperature), 생산 수(PartCount), 운전 중(Running), 설정값(Setpoint)의 변수 4개를 두었습니다. 온도는 0.5초마다, 생산 수는 2초마다 바뀝니다. 보안은 암호화 없음(None)과 서명 및 암호화(Basic256Sha256의 SignAndEncrypt) 두 가지를 준비했습니다.
# 동작 확인용 모의 장치. 실제 장치나 PLC를 대신하지는 않는다.
import asyncio, math, random, socket
from pathlib import Path
from cryptography.x509.oid import ExtendedKeyUsageOID
from asyncua import Server, ua
from asyncua.crypto.cert_gen import setup_self_signed_certificate
HERE = Path(__file__).parent
APP_URI = "urn:komurasoft:demo-plc"
async def main():
key, cert = HERE / "server_key.pem", HERE / "server_cert.der"
await setup_self_signed_certificate(
key, cert, APP_URI, socket.gethostname(),
[ExtendedKeyUsageOID.SERVER_AUTH], {"commonName": "DemoPlcServer"})
server = Server()
await server.init()
server.set_endpoint("opc.tcp://0.0.0.0:4840/demo/")
server.set_server_name("DemoPlcServer")
await server.set_application_uri(APP_URI)
server.set_security_policy([
ua.SecurityPolicyType.NoSecurity,
ua.SecurityPolicyType.Basic256Sha256_SignAndEncrypt,
])
await server.load_certificate(str(cert))
await server.load_private_key(str(key))
ns = await server.register_namespace("urn:komurasoft:demo-plc:line1")
line1 = await server.nodes.objects.add_object(ua.NodeId("Line1", ns), "Line1")
temperature = await line1.add_variable(ua.NodeId("Line1.Temperature", ns), "Temperature", 25.0)
part_count = await line1.add_variable(
ua.NodeId("Line1.PartCount", ns), "PartCount", ua.Variant(0, ua.VariantType.UInt32))
await line1.add_variable(ua.NodeId("Line1.Running", ns), "Running", True)
setpoint = await line1.add_variable(ua.NodeId("Line1.Setpoint", ns), "Setpoint", 30.0)
await setpoint.set_writable()
async with server:
print("listening", flush=True)
t, count = 0, 0
while True:
await asyncio.sleep(0.5)
t += 1
value = 25.0 + 2.0 * math.sin(t / 4) + random.uniform(-0.05, 0.05)
await temperature.write_value(round(value, 2))
if t % 4 == 0:
count += 1
await part_count.write_value(ua.Variant(count, ua.VariantType.UInt32))
asyncio.run(main())
이 서버는 접속해 온 클라이언트의 인증서를 검사하지 않고 받아들입니다. 시험용 설정이며, 실제 장치의 동작과는 다릅니다.
C# 클라이언트
프로젝트 파일에는 다음 두 패키지를 넣었습니다.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="2.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
</ItemGroup>
</Project>
클라이언트는 접속, 브라우즈, 읽기, 쓰기, 구독을 차례로 수행합니다. 각 부분의 의미는 다음 절부터 차례로 설명합니다.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Client.Subscriptions;
using Opc.Ua.Client.Subscriptions.Streaming;
using MonitoredItemOptions = Opc.Ua.Client.Subscriptions.MonitoredItems.MonitoredItemOptions;
string url = args.Length > 0 ? args[0] : "opc.tcp://localhost:4840/demo/";
int notifications = args.Length > 1 ? int.Parse(args[1]) : 6;
const string LineNamespace = "urn:komurasoft:demo-plc:line1";
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.Services
.AddOpcUa()
.AddClient(options =>
{
options.ApplicationName = "LineCollector";
options.ApplicationUri = "urn:localhost:LineCollector";
options.ProductUri = "urn:komurasoft:LineCollector";
options.PkiRoot = Path.Combine(AppContext.BaseDirectory, "pki");
})
.AddDiscoveryAndConnect(options =>
{
options.DiscoveryUrl = url;
options.SecurityMode = MessageSecurityMode.SignAndEncrypt;
options.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
});
using IHost host = builder.Build();
await host.StartAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
CancellationToken ct = timeout.Token;
try
{
var connect = host.Services
.GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession session = await connect(ct);
await using (session)
{
Console.WriteLine($"接続: {session.ConfiguredEndpoint?.EndpointUrl}");
// 네임스페이스 인덱스는 서버가 정한다. URI로 조회해서 NodeId를 만든다
ushort ns = (ushort)session.NamespaceUris.GetIndex(LineNamespace);
var line1 = new NodeId("Line1", ns);
var temperature = new NodeId("Line1.Temperature", ns);
var partCount = new NodeId("Line1.PartCount", ns);
var setpoint = new NodeId("Line1.Setpoint", ns);
// 브라우즈: Line1 아래에 있는 변수를 나열한다
var browser = new Browser(session)
{
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)NodeClass.Variable,
};
Console.WriteLine("Line1 の変数:");
foreach (ReferenceDescription child in await browser.BrowseAsync(line1, ct))
{
Console.WriteLine($" {child.DisplayName} {child.NodeId}");
}
// 읽기: 값뿐 아니라 상태와 두 타임스탬프를 본다
DataValue t = await session.ReadValueAsync(temperature, ct);
Console.WriteLine($"Temperature = {t.WrappedValue} 状態 {t.StatusCode}");
Console.WriteLine($" Source {Hms(t.SourceTimestamp)} / Server {Hms(t.ServerTimestamp)}");
// 존재하지 않는 노드를 읽으면 값이 아니라 Bad 상태가 돌아온다
try
{
await session.ReadValueAsync(new NodeId("Line1.Pressure", ns), ct);
}
catch (ServiceResultException ex)
{
Console.WriteLine($"Line1.Pressure の読み取り: {ex.StatusCode}");
}
// 쓰기: Setpoint를 32.5로 한다
var write = new WriteValue
{
NodeId = setpoint,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(32.5)),
};
WriteResponse wr = await session.WriteAsync(null, new[] { write }, ct);
Console.WriteLine($"Setpoint の書き込み: {wr.Results[0]}");
// 구독: 서버에 500ms마다의 샘플링을 맡기고 변화를 통지받는다
Console.WriteLine($"購読({notifications}件まで):");
await foreach (DataValueChange change in session.DefaultStreaming
.SubscribeDataChangesAsync(new[] { temperature, partCount }, options: new MonitoredItemOptions
{
SamplingInterval = TimeSpan.FromMilliseconds(500),
QueueSize = 5,
}, ct: ct)
.TakeAsync(notifications)
.WithCancellation(ct))
{
DataValue v = change.Value;
string name = change.MonitoredItem?.Name.Split("s=")[^1] ?? "?";
Console.WriteLine($" {Hms(v.SourceTimestamp)} {name,-17} {v.WrappedValue,6} {v.StatusCode}");
}
}
}
catch (ServiceResultException ex)
{
Console.Error.WriteLine($"OPC UA エラー {ex.StatusCode}: {ex.Message}");
Environment.ExitCode = 1;
}
finally
{
await host.StopAsync();
}
static string Hms(DateTimeUtc time) => time.ToDateTime().ToString("HH:mm:ss.fff");
처음 실행했을 때는 접속하지 못했습니다. 이유는 「인증서로 접속을 허가한다」 절에서 설명합니다. 서버의 인증서를 신뢰한 뒤의 출력은 다음과 같습니다. 라이브러리의 경고 로그는 생략했습니다.
接続: opc.tcp://localhost:4840/demo/
Line1 の変数:
Temperature ns=2;s=Line1.Temperature
PartCount ns=2;s=Line1.PartCount
Running ns=2;s=Line1.Running
Setpoint ns=2;s=Line1.Setpoint
Temperature = 23.09 状態 Good [0x00000000]
Source 13:40:51.302 / Server 13:40:51.302
Line1.Pressure の読み取り: BadNodeIdUnknown [0x80340000]
Setpoint の書き込み: Good [0x00000000]
購読(6件まで):
13:40:51.302 Line1.Temperature 23.09 Good [0x00000000]
13:40:50.801 Line1.PartCount 67 Good [0x00000000]
13:40:51.802 Line1.Temperature 23.05 Good [0x00000000]
13:40:52.304 Line1.Temperature 23.07 Good [0x00000000]
13:40:52.806 Line1.Temperature 23.2 Good [0x00000000]
13:40:52.806 Line1.PartCount 68 Good [0x00000000]
시각은 UTC입니다.
장치의 데이터는 주소 공간의 노드로 보인다
OPC UA 서버는 가지고 있는 데이터를 노드의 집합으로 공개합니다. 이 집합을 주소 공간이라고 부릅니다. 노드에는 폴더나 장치를 나타내는 오브젝트, 값을 가지는 변수 등의 종류가 있고, 노드끼리는 참조로 이어집니다. 클라이언트는 Browse 서비스로 참조를 따라가며 어떤 노드가 있는지 조사합니다6.
위 출력에서는 Line1에서 계층 참조를 따라가 변수 4개를 찾았습니다.
flowchart TB
accTitle: 모의 장치의 주소 공간
accDescr: Objects 폴더 아래에 Line1 오브젝트가 있고, 그 아래에 Temperature, PartCount, Running, Setpoint의 변수 4개가 있음을 보여 주는 그림.
O["Objects"] --> L["Line1(오브젝트)"]
L --> T["Temperature(변수)"]
L --> P["PartCount(변수)"]
L --> R["Running(변수)"]
L --> S["Setpoint(변수·쓰기 가능)"]
그림 3: 모의 장치의 주소 공간. 클라이언트는 Browse로 참조를 따라가 노드를 찾는다.
NodeId는 네임스페이스와 식별자로 이루어진다
노드는 NodeId로 식별합니다. 출력의 ns=2;s=Line1.Temperature는 네임스페이스 인덱스가 2이고, 식별자가 문자열 Line1.Temperature임을 나타냅니다.
네임스페이스의 정체는 URI입니다. 사양에서는 네임스페이스의 URI를 서비스 안에서는 숫자로 나타내며, 그 숫자는 서버의 NamespaceArray에서의 위치에 해당한다고 정하고 있습니다. 인덱스 0은 OPC UA의 네임스페이스 http://opcfoundation.org/UA/입니다7.
그래서 인덱스의 숫자를 코드에 적어 두지 않고 URI로 인덱스를 조회합니다. 위 코드에서는 session.NamespaceUris.GetIndex(LineNamespace)로 URI에서 2를 구하고, 거기서 NodeId를 만들고 있습니다.
flowchart TB
accTitle: 네임스페이스 URI에서 NodeId를 만드는 흐름
accDescr: 클라이언트가 네임스페이스의 URI를 서버의 NamespaceArray에서 조회해 인덱스를 얻고, 그 인덱스와 식별자를 조합해 NodeId를 만드는 것을 보여 주는 그림.
U["네임스페이스의 URI"] --> A["서버의 NamespaceArray"]
A --> I["인덱스(이 예에서는 2)"]
I --> N["NodeId ns=2;s=Line1.Temperature"]
K["식별자 Line1.Temperature"] --> N
그림 4: 네임스페이스 인덱스는 URI로 조회하고, 식별자와 조합해 NodeId로 만든다.
읽기와 쓰기에서는 값과 함께 상태와 타임스탬프가 돌아온다
변수의 값은 Read와 Write 서비스로 읽고 씁니다8. 읽기에서 돌아오는 것은 값만이 아닙니다. DataValue라는 구조로 값, 상태 코드, 소스 타임스탬프, 서버 타임스탬프가 돌아옵니다9.
| 항목 | 의미 |
|---|---|
| 값 | 변수의 값. 상태 코드가 오류를 나타낼 때는 값을 무시하며, 서버는 null로 한다 |
| 상태 코드(StatusCode) | 서버가 그 값을 취득·제공할 수 있었는가 |
| SourceTimestamp | 데이터의 발생원이 값에 붙인 시각 |
| ServerTimestamp | 서버가 값을 받은, 또는 올바르다고 확인한 시각 |
상태 코드의 상위 2비트는 Good(성공), Uncertain(부분적 성공으로, 용도에 따라서는 쓸 수 없음), Bad(실패)를 나타냅니다10. 출력의 Good [0x00000000]은 Good입니다.
존재하지 않는 Line1.Pressure를 읽었을 때는 값 대신 BadNodeIdUnknown [0x80340000]이 돌아왔습니다. 이 라이브러리의 ReadValueAsync는 상태가 Bad일 때 예외를 던집니다4. 값을 기록하는 구조에서는 값과 나란히 상태 코드도 남겨 두면, Bad인 동안 기록된 값을 나중에 구별할 수 있습니다.
flowchart TB
accTitle: DataValue의 내용
accDescr: 읽기나 통지로 돌아오는 DataValue가 값, 상태 코드, 소스 타임스탬프, 서버 타임스탬프의 4가지를 가지며, 상태 코드가 Bad이면 값을 무시함을 보여 주는 그림.
D["DataValue"] --> V["값"]
D --> S["상태 코드"]
D --> TS["SourceTimestamp(발생원의 시각)"]
D --> TV["ServerTimestamp(서버의 시각)"]
S -->|"Bad이면"| X["값을 무시한다"]
그림 5: 읽기의 결과는 값뿐 아니라 상태 코드와 두 타임스탬프를 가진다.
쓰기에서는 Setpoint에 32.5를 쓰고, 결과의 상태 코드로 Good이 돌아왔습니다. 쓸 수 있는 변수인지는 서버가 정합니다. 이 모의 서버에서는 Setpoint만 쓰기 가능으로 했습니다. 장치의 설정값을 외부에서 바꿀 수 있게 할지는 장치의 안전 설계와 관련되므로, 이 글의 범위에서는 다루지 않습니다.
변화를 통지받는다: 구독과 MonitoredItem
값의 변화를 따라갈 때 클라이언트가 일정 간격으로 Read를 반복하는 방법도 있습니다. OPC UA에는 그와 별도로, 서버에 값의 감시를 맡기고 변화를 통지받는 구조가 있습니다. 그것이 구독(서브스크립션)과 MonitoredItem입니다.
MonitoredItem은 감시하는 하나의 대상으로, 샘플링 간격을 가집니다. 샘플링 간격은 서버가 발생원에서 값을 샘플링하는 가장 빠른 간격이며, 사양에서는 「베스트 에포트」 주기로 되어 있습니다11. 샘플이 필터의 조건을 만족하면 통지가 만들어집니다. 필터를 지정하지 않으면 값이나 상태가 바뀌었을 때 통지합니다12.
통지는 일단 MonitoredItem의 큐에 들어갑니다. 큐가 넘쳤을 때 오래된 것과 새것 중 어느 쪽을 버릴지 정해 두며, 큐의 크기가 2 이상이면 버렸음을 나타내는 Overflow 비트가 상태 코드에 섭니다13.
구독은 여러 MonitoredItem을 묶어, 퍼블리싱 간격마다 아직 보내지 않은 통지를 클라이언트로 보냅니다. 보낼 통지가 없는 주기가 이어지면, 구독이 살아 있음을 알리는 킵얼라이브를 보냅니다14.
flowchart TB
accTitle: 구독과 MonitoredItem의 흐름
accDescr: MonitoredItem이 샘플링 간격으로 발생원의 값을 샘플링하고, 필터를 통과한 통지를 큐에 넣으며, 구독이 퍼블리싱 간격으로 묶어서 클라이언트로 보내고, 통지가 없으면 킵얼라이브를 보내는 것을 보여 주는 그림.
S["발생원(장치의 값)"] --> M["MonitoredItem"]
M -->|"샘플링 간격"| F["필터(기본은 값이나 상태의 변화)"]
F --> Q["큐(넘치면 Overflow)"]
Q --> SUB["구독"]
SUB -->|"퍼블리싱 간격"| C["클라이언트"]
SUB -.->|"통지가 없으면"| K["킵얼라이브"]
그림 6: MonitoredItem이 샘플링해 큐에 모으고, 구독이 묶어서 클라이언트로 보낸다.
위 코드에서는 Temperature와 PartCount를 샘플링 간격 500밀리초, 큐 크기 5로 구독했습니다15. 출력에서는 온도가 약 0.5초마다, 생산 수가 약 2초마다 도착하고 있습니다. 처음 두 건은 구독을 시작한 시점에 각 변수가 가지고 있던 값이었습니다. 생산 수의 13:40:50.801은 구독을 시작하기 전에 값이 바뀐 시각입니다. 통지의 시각에는 소스 타임스탬프를 표시하고 있으므로, 통지를 받은 시각이 아니라 값이 바뀐 시각이 나옵니다.
반복 읽기와 구독의 구분
읽기를 반복하는 방법은 클라이언트 쪽에서 간격을 정할 수 있고 구조가 단순합니다. 그 대신 값이 바뀌지 않아도 매번 요청과 응답이 오가며, 간격보다 짧은 변화는 놓칩니다. 구독에서는 서버가 샘플링해서 변화만 보내므로, 변화가 적은 값이라면 통신이 잘 늘지 않습니다. 다만 구독에서도 샘플링 간격보다 짧은 변화는 통지되지 않을 수 있습니다. 사양은 지정보다 빠르게 샘플링하는 것을 허용하지만, 요구하지는 않습니다11.
flowchart TB
accTitle: 반복 읽기와 구독의 차이
accDescr: 읽기를 반복하는 방법은 클라이언트가 간격을 정해 매번 요청하고, 구독은 서버가 샘플링 간격으로 샘플링해 변화만 보내며, 어느 쪽이든 간격보다 짧은 변화를 잡지 못할 수 있음을 보여 주는 그림.
A["값의 변화를 따라간다"] --> R["읽기를 반복한다"]
A --> S["구독한다"]
R --> R1["클라이언트가 간격을 정해 매번 요청"]
S --> S1["서버가 샘플링해 변화만 보낸다"]
R1 --> X["간격보다 짧은 변화는 잡지 못할 수 있다"]
S1 --> X
그림 7: 어느 방법이든 간격보다 짧은 변화는 잡지 못할 수 있다.
인증서로 접속을 허가한다
OPC UA의 접속에서는 메시지 보호 수준을 MessageSecurityMode로 선택합니다. 값은 보호하지 않는 None, 서명만 하는 Sign, 서명하고 암호화하는 SignAndEncrypt입니다16. 서명과 암호화에 쓰는 알고리즘의 조합은 보안 정책으로 정하며, 이 글에서는 Basic256Sha256을 썼습니다.
서명과 암호화에는 애플리케이션마다의 인증서를 씁니다. 클라이언트와 서버는 서로의 인증서를 확인하고, 신뢰하지 않는 상대와의 접속을 거부합니다. OPC Foundation의 .NET 라이브러리로 만든 서버는 기본적으로 암호화하지 않는 None 정책을 제공하지 않습니다4.
첫 접속은 BadCertificateUntrusted로 멈췄다
첫 실행에서는 클라이언트가 서버의 인증서를 신뢰하지 않았기 때문에 접속이 거부되었습니다. 출력의 일부입니다.
fail: Opc.Ua.CertificateValidationCore[91]
Certificate [Subject=CN=DemoPlcServer, CN=urn:komurasoft:demo-plc, Thumbprint=4E236DDA629FE2AB32D185BF681609FD6C525363, NotBefore=2026-11-01, NotAfter=2027-11-01, KeyAlgorithm=1.2.840.113549.1.1.1] validation failed with suppressible errors but was rejected. Reason=Id: BadCertificateUntrusted
...
OPC UA エラー BadCertificateUntrusted [0x801A0000]: [801A0000] (BadCertificateUntrusted) 'Could not verify security on OpenSecureChannel response.'
이 라이브러리는 거부한 인증서를 rejected 저장소에 저장합니다. 관리자가 거기서 trusted 저장소로 옮기면 그 상대를 신뢰하게 됩니다17. 이번에는 pki/rejected/certs에 있던 서버의 인증서를 pki/trusted/certs로 옮기고, 다시 실행해서 접속할 수 있었습니다. 옮기기 전에 파일 이름에도 들어 있는 지문을 서버 관리자와 확인합니다4.
flowchart TB
accTitle: 인증서를 신뢰하기까지의 흐름
accDescr: 첫 접속에서 클라이언트가 서버의 인증서를 거부해 rejected 저장소에 저장하고, 지문을 확인한 뒤 trusted 저장소로 옮기면 다음 접속에서 신뢰하여 접속할 수 있음을 보여 주는 그림.
A["첫 접속"] --> B["BadCertificateUntrusted"]
B --> C["인증서를 rejected 저장소에 저장"]
C --> D["지문을 서버 관리자와 확인한다"]
D --> E["trusted 저장소로 옮긴다"]
E --> F["다음 접속에서 신뢰하여 접속할 수 있다"]
그림 8: 거부된 인증서를 확인한 뒤 trusted로 옮기면 그 상대를 신뢰한다.
신뢰하지 않는 인증서를 자동으로 받아들이는 AutoAcceptUntrustedCertificates라는 설정도 있습니다. 기본값은 false이며, 라이브러리의 자료는 개발 환경에서는 유용하지만 운영 환경에서는 쓰지 않도록 하라고 적고 있습니다17.
로그의 NotAfter=2027-11-01은 모의 서버 인증서의 유효 기한입니다. asyncua의 함수로 만든 자체 서명 인증서로, 만든 날로부터 1년 뒤가 기한이었습니다. 장치의 인증서에도 기한이 있으므로, 기한이 끝나기 전에 교체하고 상대가 신뢰하게 하는 절차를 운용 안에 정해 둡니다. Windows 인증서 저장소의 다루는 법은 「Windows 인증서 저장소 실무 가이드」에 정리했습니다.
사용자 인증
인증서로 확인하는 것은 애플리케이션끼리입니다. 그와 별도로, 세션을 열 때 사용자를 나타내는 토큰을 넘깁니다. 사양에는 익명, 사용자 이름과 비밀번호, X.509 인증서, 외부에서 발급된 토큰의 4가지가 있습니다18. 이번 클라이언트는 익명으로 접속했습니다. 서버가 어떤 토큰을 받아들이는지는 서버의 엔드포인트 정보에 포함되어 있습니다19.
현장의 데이터 수집에서 걸려 넘어지는 곳
여기부터는 장치의 데이터를 수집하는 구조에서 일어나기 쉬운 문제를, 이번 실험과 사양에 근거해 듭니다.
서버가 멈춰 있던 동안의 값은 오지 않는다
구독 도중에 서버를 멈추고, 약 15초 뒤에 다시 기동했습니다. 이 라이브러리의 ManagedSession은 연결이 끊기면 자동으로 재접속합니다20. 멈춰 있는 동안 클라이언트는 재접속을 반복했습니다.
warn: Opc.Ua.ChannelManager[6]
Reconnect failed. Endpoint=opc.tcp://localhost:4840/demo/, Attempt=3, Outcome=transient-failure, Status=BadNotConnected, ErrorMessage=Could not connect to the remote endpoint.
서버가 돌아오자 통지도 돌아왔습니다. 구독 출력의 일부입니다.
13:41:13.364 Line1.Temperature 25.61 Good [0x00000000]
13:41:13.865 Line1.Temperature 25.12 Good [0x00000000]
13:41:34.285 Line1.Temperature 26.55 Good [0x00000000]
13:41:33.784 Line1.PartCount 2 Good [0x00000000]
13:41:34.786 Line1.Temperature 26.16 Good [0x00000000]
13시 41분 13초부터 34초까지의 값은 한 건도 오지 않았습니다. 생산 수는 78에서 2로 돌아가 있습니다. 이는 모의 서버를 다시 기동해 수를 0부터 다시 셌기 때문입니다.
이 예에서는 서버 자체가 멈춰 있었으므로 그동안의 값은 어디에도 남아 있지 않습니다. 재접속 구조는 연결을 되돌리지만, 멈춰 있던 동안의 값을 나중에 메워 주지는 않습니다.
통신만 끊기고 서버는 계속 동작하고 있던 경우는 사정이 다릅니다. 사양에는 도착하지 않은 통지 메시지를 서버의 재전송용 큐에서 다시 보내 받는 Republish 서비스가 있습니다21. MonitoredItem의 큐에도 통지가 쌓이지만, 큐의 크기를 넘은 분량은 버려집니다13. 이번 실험에서는 이 경우를 시험하지 않았습니다. 어느 경우든 값이 오지 않은 시간대를 알 수 있도록 연결 끊김과 재접속의 시각을 기록에 남겨 둡니다. 시리얼 통신 장치에서의 재접속과 로그 설계는 「시리얼 통신 앱의 함정」에서도 다룹니다.
flowchart TB
accTitle: 서버를 멈췄을 때의 기록
accDescr: 구독 중에 서버가 멈추면 클라이언트는 재접속을 반복하고, 서버가 돌아오면 통지도 돌아오지만, 멈춰 있던 동안의 값은 오지 않으므로 연결 끊김과 재접속의 시각을 기록에 남기는 것을 보여 주는 그림.
A["구독 중"] --> B["서버가 멈춘다"]
B --> C["클라이언트가 재접속을 반복한다"]
C --> D["서버가 돌아오고 통지도 돌아온다"]
B -.-> E["멈춰 있던 동안의 값은 오지 않는다"]
E -.-> F["연결 끊김과 재접속의 시각을 기록한다"]
그림 9: 재접속으로 연결은 돌아오지만, 멈춰 있던 동안의 값은 오지 않는다.
어느 타임스탬프를 기록할 것인가
사양에서는 SourceTimestamp가 데이터의 발생원이 값에 붙인 시각이며, 값이 바뀐 시각을 나타내야 한다고 되어 있습니다. ServerTimestamp는 서버가 값을 받은, 또는 올바르다고 확인한 시각입니다9. 이번 모의 서버는 스스로 값을 만들고 있으므로 두 시각이 같았습니다.
장치에서 값이 바뀐 시각을 알고 싶다면 SourceTimestamp를 씁니다. 다만 그 시각은 발생원의 시계로 붙으므로, 장치나 서버의 시계가 어긋나 있으면 기록도 어긋납니다. 수집한 데이터를 다른 기록과 대조한다면 받은 PC의 시각도 나란히 남겨 두면 나중에 어긋남을 확인할 수 있습니다. Windows 쪽의 시각 동기화는 「Windows의 시각 동기화(w32time)와 업무 시스템」에 정리했습니다.
상태가 Good이 아닌 값을 그대로 숫자로 다루지 않는다
상태 코드가 Bad일 때 값은 무시하는 것으로 되어 있습니다9. Uncertain일 때는 용도에 따라서는 쓸 수 없는 값입니다10. 그래프나 집계에 넣기 전에 상태 코드로 나누고, Good이 아닌 값을 어떻게 다룰지 정해 둡니다. 큐가 넘쳤을 때의 Overflow 비트도 상태 코드 안에 섭니다13. 장치의 상태를 화면에 어떻게 표시할지는 「외부 기기의 상태 확인과 표시의 베스트 프랙티스」에서 다룹니다.
방화벽에서 포트를 연다
IANA에 등록된 OPC UA의 포트는 4840/TCP입니다3. 다만 모든 서버가 이 번호를 쓰는 것은 아닙니다. OPC Foundation의 .NET 샘플은 62541번 등의 포트를 씁니다4. 장치의 엔드포인트 URL에서 포트 번호를 확인하고, 그 포트로의 접속을 허용합니다. 서버가 Windows에서 동작한다면 그 Windows 방화벽에서, 중간에 네트워크 장비가 있다면 그 장비에서 허용합니다. 인바운드 규칙을 만드는 방법은 「Windows 방화벽과 업무 앱」에 정리했습니다.
flowchart TB
accTitle: 데이터 수집에서 걸려 넘어지는 곳
accDescr: 장치의 데이터 수집에서 걸려 넘어지는 곳을, 기록의 내용(서버가 멈춰 있던 동안의 값이 오지 않는 점, 어느 타임스탬프를 기록할지, Good이 아닌 값의 처리)과 접속 준비(방화벽의 포트, 인증서의 기한)로 나누어 보여 주는 그림.
A["데이터 수집에서 걸려 넘어지는 곳"] --> R["기록의 내용"]
A --> N["접속 준비"]
R --> B["정지 중의 값은 오지 않는다"]
B ~~~ C["어느 타임스탬프인가"]
C ~~~ D["Good이 아닌 값의 처리"]
N --> E["방화벽의 포트"]
E ~~~ F["인증서의 기한"]
그림 10: 접속된 뒤에도 기록의 누락, 시각, 상태, 포트, 인증서를 확인한다.
Modbus/TCP나 제조사 고유 통신과의 구분
PLC나 장치와의 통신에는 OPC UA 외에 Modbus/TCP나 PLC 제조사가 준비한 통신도 있습니다.
Modbus 사양은 데이터를 4개의 표로 다룹니다. 읽기 전용 1비트의 Discrete Input, 읽고 쓸 수 있는 1비트의 Coil, 읽기 전용 16비트의 Input Register, 읽고 쓸 수 있는 16비트의 Holding Register입니다22. IANA에는 Modbus의 애플리케이션 프로토콜(mbap)로 502/TCP가 등록되어 있습니다3.
이 표에 들어가는 것은 비트와 16비트 워드이므로, 어느 주소에 어떤 값이 어떤 형식으로 들어 있는지는 장치의 자료에서 확인해 클라이언트 쪽에 적습니다. OPC UA에서는 지금까지 본 것처럼 서버가 이름이 붙은 변수를 주소 공간에 공개하고, 클라이언트는 Browse로 그것을 조사할 수 있습니다. 값에는 상태 코드와 타임스탬프가 붙고, 접속에는 인증서에 의한 허가가 있습니다.
어느 것을 쓸지는 장치가 무엇을 제공하는지로 정해지는 경우가 많을 것입니다. 장치에 OPC UA 서버가 있으면 그것을 쓰고, Modbus/TCP밖에 없으면 주소 대응표를 만들어 읽습니다. 제조사의 통신이나 SDK밖에 없는 경우에는 그 자료를 따릅니다. USB 기기와의 통신 방법을 고르는 법은 「Windows 앱에서 USB 기기를 다루는 방법」에 정리했습니다.
flowchart TB
accTitle: 장치와의 통신을 고르는 법
accDescr: 장치에 OPC UA 서버가 있으면 OPC UA로 변수를 브라우즈해 수집하고, Modbus/TCP밖에 없으면 자료에서 주소와 형식을 확인해 레지스터를 읽으며, 제조사의 통신밖에 없으면 그 자료를 따르는 것을 보여 주는 그림.
A["장치가 제공하는 통신"] --> B{"무엇이 있나?"}
B -->|"OPC UA 서버"| C["변수를 브라우즈해 수집한다"]
B -->|"Modbus/TCP만"| D["자료에서 주소와 형식을 확인한다"]
B -->|"제조사의 통신만"| E["제조사의 자료와 SDK를 따른다"]
D --> F["레지스터를 읽어 값으로 바꾼다"]
그림 11: 무엇으로 수집할지는 장치가 무엇을 제공하는지로 정해지는 경우가 많다.
관련 글
- Dropped Frames with GigE Vision and USB3 Vision Cameras
- 시리얼 통신 앱의 함정 - 재접속과 로그 설계까지
- 외부 기기의 상태 확인과 표시의 베스트 프랙티스
- Windows 앱에서 USB 기기를 다루는 방법
- Windows 방화벽과 업무 앱
- Windows 인증서 저장소 실무 가이드
- Windows의 시각 동기화(w32time)와 업무 시스템
참고 링크
-
OPC Foundation, Classic. OPC Classic이 COM/DCOM에 기반한다는 점, DA·A&E·HDA의 각 사양, 2008년 OPC UA의 공개와 다시 만든 이유. ↩ ↩2
-
IEC, IEC TR 62541-1:2020 OPC Unified Architecture - Part 1: Overview and concepts. OPC UA의 개념과 개요를 다루는 IEC 62541 시리즈의 제1부(후속판으로 대체됨). ↩
-
IANA, Service Name and Transport Protocol Port Number Registry. 4840/TCP의
opcua-tcp와 502/TCP의mbap등록. ↩ ↩2 ↩3 -
OPC Foundation, UA-.NETStandard 2.0.1: Getting started. 2.0 계열 클라이언트의 작성법,
ReadValueAsync가 Bad에서 예외를 던지는 점, 거부와 신뢰의 저장소, 서버가 기본적으로None을 제공하지 않는 점, 샘플의 포트 번호. ↩ ↩2 ↩3 ↩4 ↩5 -
FreeOpcUa, asyncua(PyPI). 모의 장치 서버에 쓴 Python의 OPC UA 라이브러리. ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.9.2 Browse. 노드의 참조를 따라가는 서비스. ↩
-
OPC Foundation, OPC 10000-3 v1.05, 8.2.2 NamespaceIndex. 네임스페이스의 URI를 서비스 안에서는 숫자로 나타내며, 그 숫자가 NamespaceArray에서의 위치에 해당한다는 점. ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.11 Attribute Service Set. Read와 Write 서비스. ↩
-
OPC Foundation, OPC 10000-4 v1.05, 7.11 DataValue. 값, 상태 코드, SourceTimestamp, ServerTimestamp의 정의. ↩ ↩2 ↩3
-
OPC Foundation, OPC 10000-4 v1.05, 7.38.1 StatusCode General. 상위 2비트가 나타내는 Good·Uncertain·Bad. ↩ ↩2
-
OPC Foundation, OPC 10000-4 v1.05, 5.13.1.2 Sampling interval. 샘플링 간격이 베스트 에포트 주기라는 점. ↩ ↩2
-
OPC Foundation, OPC 10000-4 v1.05, 7.22.2 DataChangeFilter. 필터가 없으면 값이나 상태의 변화로 통지한다는 점. ↩
-
OPC Foundation, OPC 10000-4 v1.05, 5.13.1.5 Queue parameters. 큐의 크기, 버리는 방식, Overflow 비트. ↩ ↩2 ↩3
-
OPC Foundation, OPC 10000-4 v1.05, 5.14.1.1 Subscription model. 퍼블리싱 간격과 킵얼라이브. ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Subscriptions and Monitored Items Service Set.
SubscribeDataChangesAsync와MonitoredItemOptions의 샘플링 간격·큐 크기. ↩ -
OPC Foundation, OPC 10000-4 v1.05, 7.20 MessageSecurityMode. None·Sign·SignAndEncrypt의 의미. ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Certificates. rejected 저장소에서 신뢰하는 저장소로 옮기는 운용과,
AutoAcceptUntrustedCertificates를 운영 환경에서 쓰지 않는다는 점. ↩ ↩2 -
OPC Foundation, OPC 10000-4 v1.05, 7.40 UserIdentityToken parameters. 익명, 사용자 이름, X.509, 발급된 토큰의 4가지. ↩
-
OPC Foundation, OPC 10000-4 v1.05, 7.14 EndpointDescription. 엔드포인트가 받아들이는 사용자 토큰의 목록(userIdentityTokens). ↩
-
OPC Foundation, UA-.NETStandard 2.0.1: Sessions, Reconnection, and Subscription Engines.
ManagedSession이 재접속 구조를 내장하고 있다는 점. ↩ -
OPC Foundation, OPC 10000-4 v1.05, 5.14.6.1 Republish. 구독의 재전송용 큐에서 통지 메시지를 다시 보내 받는 서비스. ↩
-
Modbus-IDA, MODBUS Application Protocol Specification V1.1b(시카고 대학교 서버에 놓인 사본). 4.3절의 데이터 모델(Discrete Input·Coil·Input Register·Holding Register). ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 깊이 있게 쌓을 수 있습니다.
인수는 왜 깨지는가 ── Windows 명령줄 인수의 규칙
Windows에는 인수의 배열이 존재하지 않으며, CreateProcess에 전달되는 것은 한 줄의 문자열이고 분할은 받는 쪽이 합니다. CommandLineToArgvW·CRT·.NET의 분할 규칙과 .NET의 ArgumentList, C++에...
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해 둘 것
「스레드를 만들었더니 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 맡기기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken으로 정지 설계하기, UI 스레...
WMI/CIM을 C#·PowerShell에서 쓰기 ── 하드웨어 정보 가져오기·프로세스 모니터링·원격 조회의 실무 가이드
PC 시리얼 번호 조회, 디스크 여유 공간 모니터링, 프로세스 시작 감지의 흔한 답이 WMI/CIM입니다. Get-CimInstance 등 CIM cmdlet 사용법과 구 Get-WmiObject에서의 이전, C#의 System.Managemen...
MSMQ는 언제까지 쓸 수 있는가 ── 「deprecated조차 아닌」 레거시 큐의 마이그레이션 판단
MSMQ는 공식 deprecated 목록에 없는 한편, System.Messaging은 .NET Framework에만 있어 .NET으로의 이전을 가로막습니다. 폐지 소문과 실제 현재 위치를 사실로 정리하고, 계속 쓸지·옮길지의 판단 기준과 이전 ...
Windows 세션 분리를 어떻게 이해할 것인가 ── Session 0·RDP·다중 사용자 동시 실행
Windows 앱 개발자가 혼동하기 쉬운 「세션」 개념을 정리합니다. 서비스가 UI를 띄울 수 없는 Session 0 분리의 이유, RDP 연결 시 세션의 동작, named object의 세션 분리, 공유 PC·RDS 환경에서 자주 나오는 설계 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용의 기술 토픽을 모은 입구입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
장치에서 데이터를 수집하는 Windows 앱을 구독 설정, 재접속, 타임스탬프와 상태의 기록까지 포함해 만드는 경우, Windows 앱 개발로 다룰 수 있습니다.
기술 상담 & 설계 리뷰
OPC UA로 수집할지, Modbus/TCP나 제조사의 통신을 쓸지, 인증서를 어떻게 관리할지 정하고 싶은 경우, 기술 상담·설계 리뷰로 다룰 수 있습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- OPC UA와 OPC Classic(OPC DA)은 무엇이 다릅니까?
- OPC Classic은 Windows의 COM/DCOM으로 데이터를 주고받는 사양입니다. OPC UA는 2008년에 공개된, 특정 OS에 의존하지 않는 사양으로, Classic의 기능을 하나로 묶어 다룹니다. OPC UA 클라이언트는 Windows 이외에서도 동작하며, DCOM 설정도 필요 없습니다.
- OPC UA로 값을 수집할 때 일정 간격으로 읽으면 됩니까?
- 값의 수가 적고 간격이 길면 읽기로도 수집할 수 있습니다. 값의 변화를 따라가려면 구독으로 서버에 샘플링을 맡기고 변화를 통지받는 방법이 있습니다. 어느 쪽이든 값과 함께 돌아오는 상태 코드와 타임스탬프를 기록합니다.
- 접속하면 BadCertificateUntrusted가 되는 이유는 무엇입니까?
- 클라이언트가 아직 서버의 인증서를 신뢰하지 않기 때문입니다. OPC Foundation의 .NET 라이브러리는 거부한 인증서를 rejected 저장소에 저장합니다. 인증서의 지문을 서버 관리자와 확인한 뒤 trusted 저장소로 옮깁니다. 인증서를 자동으로 받아들이는 설정은 개발 환경에서만 씁니다.
- OPC UA의 포트 번호는 몇 번입니까?
- IANA에는 4840/TCP가 opcua-tcp로 등록되어 있습니다. 다만 실제 서버는 다른 포트를 쓰기도 하므로 장치의 설정에서 확인합니다. 서버가 Windows에서 동작한다면 Windows 방화벽에서, 중간에 네트워크 장비가 있다면 그 장비에서 그 포트로의 통신을 허용해야 합니다.