C#과 .NET을 활용한 OPC UA 기반 산업용 실시간 데이터 수집 및 고급 보안 아키텍처 구현

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)을 모니터링하는 백그라운드 작업을 추가하여 운영 중 단절을 방지합니다.

태그: csharp dotnet OpcUa IIoT Modbus

6월 10일 19:47에 게시됨