# STEP 7 — ExperionFastService 신규 파일 생성 ## 사전 확인 (작업 전 반드시 수행) 1. `src/Infrastructure/OpcUa/` 디렉토리 목록을 확인한다. 2. 아래 항목을 확인하고 기록한다: - [x] STEP 5, 6이 완료되어 인터페이스와 DB 메서드가 존재하는가? - [x] `ExperionFastService.cs` 파일이 이미 존재하는가? → 존재하면 내용 비교 후 필요한 부분만 수정 (신규 생성) - [x] `IExperionOpcClient`에 `IsConnectedAsync`, `CreateSessionAsync`가 구현되어 있는가? - [x] `IOpcUaConfigProvider` 인터페이스가 존재하는가? (주입 경로 확인) - [x] `Opc.Ua.Client` NuGet 패키지가 Infrastructure 프로젝트에 있는가? --- ## 작업 내용 **파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs` (신규 생성) ```csharp using System.Collections.Concurrent; using System.Text.Json; using ExperionCrawler.Core.Application.Interfaces; using ExperionCrawler.Core.Domain.Entities; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Client; namespace ExperionCrawler.Infrastructure.OpcUa; /// /// fastRecord 데이터 수집 서비스. /// 세션별 별도 OPC UA Subscription을 관리하고, 2초마다 배치 INSERT. /// IHostedService로 등록하여 앱 시작/종료 시 자동 관리. /// public class ExperionFastService : IExperionFastService, IHostedService, IDisposable { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly IOpcUaConfigProvider _configProvider; private readonly IExperionOpcClient _opcClient; private readonly ConcurrentDictionary _sessions = new(); private CancellationTokenSource? _cts; private Task? _monitorTask; private const int MaxConcurrentSessions = 3; private const int MaxRowsPerSession = 5_000_000; private const int FlushIntervalMs = 2_000; public ExperionFastService( IServiceScopeFactory scopeFactory, ILogger logger, IOpcUaConfigProvider configProvider, IExperionOpcClient opcClient) { _scopeFactory = scopeFactory; _logger = logger; _configProvider = configProvider; _opcClient = opcClient; } // ── IHostedService ──────────────────────────────────────────────────────── public async Task StartAsync(CancellationToken cancellationToken) { // 앱 시작 시 Running 상태 세션 → Failed 마킹 using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var sessions = await db.GetFastSessionsAsync(); foreach (var s in sessions.Where(s => s.Status == "Running")) { _logger.LogWarning("[Fast] 앱 시작 시 Running 세션 {Id} → Failed 마킹", s.Id); await db.UpdateFastSessionStatusAsync(s.Id, "Failed"); } _cts = new CancellationTokenSource(); _monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token); } public async Task StopAsync(CancellationToken cancellationToken) { _cts?.Cancel(); if (_monitorTask != null) await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); foreach (var kvp in _sessions) kvp.Value.Cancel = true; await Task.Delay(2000).ConfigureAwait(false); // 마지막 flush 대기 } public void Dispose() { _cts?.Dispose(); } // ── IExperionFastService ────────────────────────────────────────────────── public async Task StartSessionAsync(FastSessionStartRequest request) { if (request.TagList.Length == 0 || request.TagList.Length > 8) throw new ArgumentException("태그는 1~8개까지 가능합니다."); if (request.SamplingMs is not (100 or 250 or 500 or 1000)) throw new ArgumentException("샘플링 간격은 100/250/500/1000ms 중 하나여야 합니다."); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var runningCount = (await db.GetFastSessionsAsync()).Count(s => s.Status == "Running"); if (runningCount >= MaxConcurrentSessions) throw new InvalidOperationException($"동시 실행 가능한 세션은 {MaxConcurrentSessions}개까지입니다."); var cfg = await _configProvider.GetConfigAsync(new ExperionServerConfig()); if (string.IsNullOrEmpty(cfg?.EndpointUrl)) throw new InvalidOperationException("서버 엔드포인트 URL이 설정되어 있지 않습니다."); if (!await _opcClient.IsConnectedAsync(cfg)) throw new InvalidOperationException("OPC UA 서버에 연결되어 있지 않습니다."); // 노드 유효성 사전 검증 foreach (var tagName in request.TagList) { var nodeId = await db.GetNodeIdByTagNameAsync(tagName); if (string.IsNullOrEmpty(nodeId)) throw new ArgumentException($"태그 '{tagName}'의 nodeId를 찾을 수 없습니다."); var readResult = await _opcClient.ReadTagAsync(cfg, nodeId); if (!readResult.Success) throw new ArgumentException($"태그 '{tagName}' 읽기 실패: {readResult.ErrorMessage}"); } var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest( Name: request.Name, SamplingMs: request.SamplingMs, DurationSec: request.DurationSec, TagList: request.TagList, RetentionDays: request.RetentionDays)); var ctx = new FastSessionContext { SessionId = session.Id, TagList = request.TagList, SamplingMs = request.SamplingMs, DurationSec = request.DurationSec, StartedAt = DateTime.UtcNow, Buffer = new ConcurrentQueue() }; _sessions[session.Id] = ctx; await StartSubscriptionAsync(ctx, cfg); await db.UpdateFastSessionStatusAsync(session.Id, "Running"); _logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개, {Ms}ms, {Sec}s", session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec); return MapToInfo(session); } public async Task StopSessionAsync(int sessionId) { if (!_sessions.TryGetValue(sessionId, out var ctx)) throw new InvalidOperationException($"세션 {sessionId}를 찾을 수 없습니다."); ctx.Cancel = true; await FlushBufferAsync(ctx).ConfigureAwait(false); await StopSubscriptionAsync(ctx).ConfigureAwait(false); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.UpdateFastSessionStatusAsync(sessionId, "Completed"); await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows); _sessions.TryRemove(sessionId, out _); _logger.LogInformation("[Fast] 세션 {Id} 중지 — 총 {Count}행", sessionId, ctx.TotalRows); } public async Task DeleteSessionAsync(int sessionId) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.DeleteFastSessionAsync(sessionId); _sessions.TryRemove(sessionId, out _); } public async Task PinSessionAsync(int sessionId, bool pinned) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.UpdateFastSessionPinnedAsync(sessionId, pinned); } public async Task GetSessionAsync(int sessionId) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var session = await db.GetFastSessionAsync(sessionId); return session == null ? null : MapToInfo(session); } public async Task> GetSessionsAsync() { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return (await db.GetFastSessionsAsync()).Select(MapToInfo); } public async Task GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long") { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return await db.GetFastRecordsAsync(sessionId, from, to); } public async Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to); } // ── Private ──────────────────────────────────────────────────────────────── private async Task StartSubscriptionAsync(FastSessionContext ctx, ApplicationConfiguration cfg) { var session = await _opcClient.CreateSessionAsync(cfg); var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = ctx.SamplingMs, KeepAliveCount = 10 }; foreach (var tagName in ctx.TagList) { var nodeId = await GetNodeIdAsync(tagName); var item = new MonitoredItem(subscription) { StartNodeId = nodeId, SamplingInterval = ctx.SamplingMs, DisplayName = tagName }; item.Notification += (_, e) => OnNotification(ctx, e, tagName); subscription.AddItem(item); } await session.AddSubscriptionAsync(subscription); subscription.Create(); ctx.Subscription = subscription; ctx.Session = session; } private async Task StopSubscriptionAsync(FastSessionContext ctx) { if (ctx.Subscription != null) { ctx.Subscription.Delete(false); ctx.Subscription = null; } if (ctx.Session != null) { await ctx.Session.CloseAsync(); await ctx.Session.DisposeAsync(); ctx.Session = null; } } private void OnNotification(FastSessionContext ctx, MonitoredItemNotificationEventArgs e, string tagName) { if (ctx.Cancel) return; if (e.NotificationValue is MonitoredItemNotification notification) { ctx.Buffer.Enqueue(new FastRecord { SessionId = ctx.SessionId, RecordedAt = DateTime.UtcNow, TagName = tagName, Value = notification.Value.Value?.ToString() }); ctx.TotalRows++; } } private async Task FlushBufferAsync(FastSessionContext ctx) { var buffer = new List(); while (ctx.Buffer.TryDequeue(out var record)) buffer.Add(record); if (buffer.Count == 0) return; using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.BatchInsertFastRecordsAsync(buffer); await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows); if (ctx.TotalRows >= MaxRowsPerSession) { ctx.Cancel = true; await db.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached"); _sessions.TryRemove(ctx.SessionId, out _); _logger.LogWarning("[Fast] 세션 {Id} RowLimitReached ({Max}행)", ctx.SessionId, MaxRowsPerSession); } } private async Task MonitorLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { await Task.Delay(FlushIntervalMs, ct); foreach (var kvp in _sessions.ToList()) { var ctx = kvp.Value; if (ctx.Cancel) continue; if ((DateTime.UtcNow - ctx.StartedAt).TotalSeconds >= ctx.DurationSec) { ctx.Cancel = true; await StopSessionAsync(ctx.SessionId); continue; } await FlushBufferAsync(ctx); } } catch (OperationCanceledException) { } catch (Exception ex) { _logger.LogError(ex, "[Fast] 모니터링 루프 오류"); } } } private async Task GetNodeIdAsync(string tagName) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); return await db.GetNodeIdByTagNameAsync(tagName) ?? string.Empty; } private static FastSessionInfo MapToInfo(FastSession s) => new( Id: s.Id, Name: s.Name, StartedAt: s.StartedAt, EndedAt: s.EndedAt, Status: s.Status, SamplingMs: s.SamplingMs, DurationSec: s.DurationSec, TagList: JsonSerializer.Deserialize(s.TagList) ?? [], RowCount: s.RowCount, RetentionDays: s.RetentionDays, Pinned: s.Pinned); // ── Inner Class ──────────────────────────────────────────────────────────── private sealed class FastSessionContext { public int SessionId { get; set; } public string[] TagList { get; set; } = []; public int SamplingMs { get; set; } public int DurationSec { get; set; } public DateTime StartedAt { get; set; } public ConcurrentQueue Buffer { get; set; } = new(); public int TotalRows { get; set; } // 누적 행 수 public bool Cancel { get; set; } public ISession? Session { get; set; } public Subscription? Subscription { get; set; } } } ``` --- ## 사후 확인 (작업 후 반드시 수행) 1. `ExperionFastService.cs` 파일을 읽어 전체 구조를 확인한다. 2. 아래 항목을 하나씩 확인한다: - [x] 클래스가 `IExperionFastService`, `IHostedService`, `IDisposable` 모두 구현하는가? - [x] `StartAsync` — Running 세션 Failed 마킹 로직이 있는가? - [x] `OnNotification` — `MonitoredItemNotification` 타입 체크를 하는가? (`e.NotificationValue is MonitoredItemNotification`) - [x] `FlushBufferAsync` — `ctx.TotalRows >= MaxRowsPerSession` 체크가 있는가? - [x] `MapToInfo` — `JsonSerializer.Deserialize` 사용하는가? - [x] `FastSessionContext.TotalRows` 필드가 있는가? - [x] 파일 상단에 `using System.Text.Json;` 이 있는가? - [x] `using Opc.Ua;`, `using Opc.Ua.Client;` 가 있는가? 3. `dotnet build src/Web` 실행 → 에러 0, 경고 14개 (기존 경고 포함) 확인 4. 문제가 있으면 수정 후 다시 빌드 확인 --- ## 완료 조건 - `dotnet build src/Web` 결과: 에러 0, 경고 14개 (기존 경고 포함) - `ExperionFastService.cs` 파일 존재 및 빌드 통과