OPC UA와 .NET Standard 스택을 선택한 기술적 배경
산업 4.0 및 IIoT(산업용 사물인터넷) 환경에서 OPC UA는 복잡한 정보 모델링, 강력한 보안, 그리고 Pub/Sub 아키텍처를 지원하는 사실상의 표준 프로토콜입니다. .NET 8 및 .NET 9 기반의 최신 산업용 애플리케이션을 구축할 때, OPC Foundation에서 제공하는 공식 오픈소스 라이브러리인 OPCFoundation.NetStandard.Opc.Ua를 사용하는 것이 가장 효율적입니다.
- 크로스 플랫폼 및 호환성: 순수 .NET Standard 2.0+ 기반으로 작성되어 최신 .NET 버전과 완벽하게 호환되며 상용 라이선스가 필요 없습니다.
- 고급 기능 지원: 비동기 I/O, 모니터링 아이템(Monitored Items)을 통한 구독, 자동 재연결, 그리고 X.509 인증서 관리를 기본으로 제공합니다.
- 경량화 및 유지보수성: 상용 SDK 대비 가볍고 커뮤니티 업데이트가 활발하여 Siemens, Beckhoff, KEPServerEX 등 다양한 PLC 및 게이트웨이와 연동하기 용이합니다.
프로젝트에 핵심 패키지를 추가하려면 다음 CLI 명령어를 사용합니다:
dotnet add package OPCFoundation.NetStandard.Opc.Ua --version 1.5.*
비동기 및 재연결 지원을 위한 OPC UA 데이터 페처 캡슐화
네트워크 불안정에 대비한 재연결 로직과 다중 노드 일괄 읽기 기능을 포함하는 OpcUaDataFetcher 클래스를 설계합니다. 기존 코드 대비 성능을 최적화하기 위해 노드 매핑 시 딕셔너리 탐색 대신 인덱스 기반 리스트를 사용합니다.
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Microsoft.Extensions.Logging;
public class OpcUaDataFetcher : IAsyncDisposable
{
private Session? _uaSession;
private readonly string _serverEndpoint;
private readonly ILogger<OpcUaDataFetcher> _logger;
private readonly ApplicationConfiguration _appConfig;
public OpcUaDataFetcher(string serverEndpoint, ILogger<OpcUaDataFetcher> logger, ApplicationConfiguration appConfig)
{
_serverEndpoint = serverEndpoint;
_logger = logger;
_appConfig = appConfig;
}
public async Task<bool> EstablishConnectionAsync()
{
try
{
var endpointDescription = new EndpointDescription(_serverEndpoint);
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDescription);
_uaSession = await Session.Create(
_appConfig,
configuredEndpoint,
updateBeforeConnect: false,
sessionName: "IndustrialFetcherSession",
sessionTimeout: 60000,
identity: new UserIdentity(),
preferredLocales: null);
_logger.LogInformation("OPC UA 서버 연결 성공: {Endpoint}", _serverEndpoint);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "OPC UA 서버 연결 실패");
return false;
}
}
public async Task<Dictionary<string, object>> FetchNodeValuesAsync(IEnumerable<string> targetNodeIds)
{
if (_uaSession == null || !_uaSession.Connected)
throw new InvalidOperationException("세션이 연결되지 않았습니다.");
var readCollection = new ReadValueIdCollection();
var orderedNodeIds = new List<string>();
foreach (var nodeIdStr in targetNodeIds)
{
readCollection.Add(new ReadValueId
{
NodeId = new NodeId(nodeIdStr),
AttributeId = Attributes.Value
});
orderedNodeIds.Add(nodeIdStr);
}
var response = await _uaSession.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Both,
nodesToRead: readCollection,
cancellationToken: CancellationToken.None);
var fetchedData = new Dictionary<string, object>();
for (int i = 0; i < response.Results.Count; i++)
{
var dataValue = response.Results[i];
string currentNodeId = orderedNodeIds[i];
if (dataValue.StatusCode == StatusCodes.Good)
{
fetchedData[currentNodeId] = dataValue.Value;
}
else
{
_logger.LogWarning("노드 읽기 실패 {NodeId}: 상태 코드 {Status}", currentNodeId, dataValue.StatusCode);
}
}
return fetchedData;
}
public async ValueTask DisposeAsync()
{
if (_uaSession != null)
{
await _uaSession.CloseAsync();
_uaSession.Dispose();
}
}
}
기존 수집 엔진과의 통합 및 다중 프로토콜 지원
Modbus와 OPC UA를 동시에 처리할 수 있도록 BackgroundService를 확장합니다. 각 프로토콜의 특성에 맞게 데이터를 수집하고 단일 데이터 포인트로 병합합니다.
public class UnifiedDataCollectionService : BackgroundService
{
private readonly ModbusTcpFetcher _modbusFetcher;
private readonly OpcUaDataFetcher? _opcUaFetcher;
private readonly ILogger<UnifiedDataCollectionService> _logger;
public UnifiedDataCollectionService(
ModbusTcpFetcher modbusFetcher,
ILogger<UnifiedDataCollectionService> logger,
OpcUaDataFetcher? opcUaFetcher = null)
{
_modbusFetcher = modbusFetcher;
_logger = logger;
_opcUaFetcher = opcUaFetcher;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await EnsureAllConnectionsAsync(stoppingToken);
var aggregatedData = await CollectAndMergeDataAsync();
// 데이터 저장소 또는 메시징 큐로 전송 로직
await PersistDataAsync(aggregatedData);
await Task.Delay(1000, stoppingToken);
}
}
private async Task<TelemetryDataPoint> CollectAndMergeDataAsync()
{
var dataPoint = new TelemetryDataPoint { CapturedAt = DateTimeOffset.UtcNow };
if (_modbusFetcher.IsConnected)
{
var registers = await _modbusFetcher.ReadHoldingRegistersAsync(1, 100, 2);
dataPoint.Metrics["Modbus_Temperature"] = ConvertRegistersToFloat(registers);
}
if (_opcUaFetcher != null)
{
var nodesToFetch = new[] { "ns=2;s=PLC_Main.Temp", "ns=2;s=PLC_Main.Pressure" };
var opcData = await _opcUaFetcher.FetchNodeValuesAsync(nodesToFetch);
if (opcData.TryGetValue("ns=2;s=PLC_Main.Temp", out var tempValue))
dataPoint.Metrics["OpcUa_Temperature"] = tempValue;
if (opcData.TryGetValue("ns=2;s=PLC_Main.Pressure", out var pressValue))
dataPoint.Metrics["OpcUa_Pressure"] = pressValue;
}
return dataPoint;
}
private async Task EnsureAllConnectionsAsync(CancellationToken ct)
{
if (!_modbusFetcher.IsConnected) await _modbusFetcher.ConnectAsync();
if (_opcUaFetcher != null) await _opcUaFetcher.EstablishConnectionAsync();
}
// ... 기타 헬퍼 메서드
}
모니터링 아이템을 활용한 이벤트 기반 구독
주기적인 폴링(Polling) 방식은 네트워크 대역폭을 낭비하고 지연 시간을 유발합니다. OPC UA의 구독(Subscription) 모델을 활용하면 서버에서 데이터 변경 시에만 이벤트를 푸시하는 효율적인 아키텍처를 구축할 수 있습니다.
public async Task RegisterMonitoredItemsAsync(IEnumerable<string> nodeIds, Action<string, object> onDataChanged)
{
if (_uaSession == null) return;
var monitoredItems = new MonitoredItemCollection();
uint handleCounter = 1;
foreach (var nodeId in nodeIds)
{
monitoredItems.Add(new MonitoredItem
{
StartNodeId = new NodeId(nodeId),
AttributeId = Attributes.Value,
SamplingInterval = 500,
QueueSize = 10,
DiscardOldest = true,
ClientHandle = handleCounter++
});
}
var subscription = new Subscription
{
PublishingInterval = 1000,
KeepAliveCount = 30,
LifetimeCount = 60,
Priority = 0
};
subscription.AddItems(monitoredItems);
await _uaSession.AddSubscriptionAsync(subscription);
await subscription.CreateAsync();
subscription.Publish += (sender, eventArgs) =>
{
foreach (var notification in eventArgs.NotificationMessage.NotificationData)
{
if (notification is MonitoredItemNotification itemNotification)
{
var matchedItem = subscription.MonitoredItems
.FirstOrDefault(i => i.ClientHandle == itemNotification.ClientHandle);
if (matchedItem != null)
{
onDataChanged.Invoke(matchedItem.StartNodeId.ToString(), itemNotification.Value.Value);
}
}
}
};
}
이 구독 로직은 System.Threading.Channels.Channel과 결합하여 비동기 데이터 스트림 파이프라인으로 쉽게 확장할 수 있습니다.
의존성 주입 및 호스트 구성
Program.cs에서 서비스를 등록하여 애플리케이션 수명 주기와 함께 관리되도록 합니다.
builder.Services.AddSingleton<ApplicationConfiguration>(sp =>
BuildSecurityConfiguration().GetAwaiter().GetResult());
builder.Services.AddSingleton<OpcUaDataFetcher>(sp =>
new OpcUaDataFetcher(
"opc.tcp://192.168.1.100:4840",
sp.GetRequiredService<ILogger<OpcUaDataFetcher>>(),
sp.GetRequiredService<ApplicationConfiguration>()));
builder.Services.AddHostedService<UnifiedDataCollectionService>();
프로덕션 환경을 위한 인증서 및 고급 보안 관리
산업 현장에서는 데이터 변조와 탈취를 방지하기 위해 암호화와 서명이 필수적입니다. 다음 코드는 X.509 인증서를 활용하여 SignAndEncrypt 보안 정책을 적용하는 완전한 구성 예제입니다.
private async Task<ApplicationConfiguration> BuildSecurityConfiguration()
{
var config = new ApplicationConfiguration
{
ApplicationName = "IndustrialEdgeGateway",
ApplicationUri = $"urn:{System.Net.Dns.GetHostName()}:IndustrialEdgeGateway",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = "./pki/own",
SubjectName = $"CN=IndustrialEdgeGateway, DC={System.Net.Dns.GetHostName()}"
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = "./pki/issuer"
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = "./pki/trusted"
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = "./pki/rejected"
},
SupportedSecurityPolicies = new StringCollection
{
SecurityPolicies.Basic256Sha256
},
AutoAcceptUntrustedCertificates = false,
MinimumCertificateKeySize = 2048
},
ClientConfiguration = new ClientConfiguration
{
DefaultSessionTimeout = 60000
}
};
await config.Validate(ApplicationType.Client);
bool certificateValid = await config.CheckApplicationInstanceCertificate(
silent: false,
keySize: CertificateFactory.DefaultKeySize,
lifeTimeInMonths: CertificateFactory.DefaultLifeTime);
if (!certificateValid)
{
throw new InvalidOperationException("애플리케이션 인스턴스 인증서가 유효하지 않거나 생성에 실패했습니다.");
}
return config;
}
권장되는 PKI 디렉터리 구조
컨테이너 환경(Kubernetes, Docker)에서의 볼륨 마운트와 권한 관리를 위해 다음과 같은 디렉터리 구조를 사용하는 것이 좋습니다.
./pki/
├── own/ # 클라이언트 애플리케이션의 인증서 및 개인 키
│ ├── cert.der
│ └── privatekey.pem
├── trusted/ # 신뢰할 수 있는 OPC UA 서버의 인증서
├── issuer/ # 신뢰할 수 있는 CA(인증 기관) 루트 및 중간 인증서
└── rejected/ # 유효성 검사에 실패하여 거부된 인증서 자동 저장소
상용화 전 보안 점검 목록
AutoAcceptUntrustedCertificates옵션은 반드시false로 설정하여 수동으로 서버 인증서를 신뢰 목록에 추가해야 합니다.- 보안 정책은
Basic256Sha256이상을 사용하며, 최소 RSA 2048비트 키 길이를 강제합니다. - 세션 연결 시
MessageSecurityMode.SignAndEncrypt를 명시적으로 지정합니다. ./pki/own경로에 있는 개인 키는 분실 시 세션 복구가 불가능하므로 반드시 안전하게 백업해야 합니다.- 인증서 만료일(
NotAfter)을 모니터링하는 백그라운드 작업을 추가하여 운영 중 단절을 방지합니다.