Files
ExperionCrawler/fastTable/fastTable-coding-plan-byQwen3.md

1760 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# fastTable/fastRecord 코딩 플랜 (Qwen3-Coder-Next)
## 개요
`idea-fastTable.md`의 요구사항을 기반으로 ExperionCrawler 프로젝트에 fastTable/fastRecord 기능을 구현합니다.
### 핵심 개념
| 개념 | 설명 |
|------|------|
| **fastSession** | 데이터 수집 세션 (메타 정보 저장) |
| **fastRecord** | 초 단위로 수집된 실시간 데이터 (TimescaleDB hypertable) |
| **별도 Subscription** | 기존 realtime Subscription과 분리된 고해상도 구독 |
---
## Task A — DB 스키마 + 엔티티
### 1. `ExperionEntities.cs` — 엔티티 추가
**파일**: `src/Core/Domain/Entities/ExperionEntities.cs`
```csharp
/// <summary>fastSession — 데이터 수집 세션 메타</summary>
[Table("fast_session")]
public class FastSession
{
[Column("id")] public int Id { get; set; }
[Column("name")] public string Name { get; set; } = string.Empty;
[Column("started_at")] public DateTime StartedAt { get; set; }
[Column("ended_at")] public DateTime? EndedAt { get; set; }
[Column("status")] public string Status { get; set; } = "Pending"; // Pending/Running/Completed/Cancelled/Failed/RowLimitReached
[Column("sampling_ms")] public int SamplingMs { get; set; } // 100/250/500/1000
[Column("duration_sec")] public int DurationSec { get; set; }
[Column("tag_list")] public string TagList { get; set; } = "[]"; // JSON array of tagNames
[Column("row_count")] public int RowCount { get; set; }
[Column("retention_days")] public int? RetentionDays { get; set; } // null = 무한 보관
[Column("pinned")] public bool Pinned { get; set; }
}
/// <summary>fastRecord — 시계열 데이터 (Long 포맷: 태그 1행/시점)</summary>
[Table("fast_record")]
public class FastRecord
{
[Column("id")] public int Id { get; set; }
[Column("session_id")] public int SessionId { get; set; }
[Column("recorded_at")] public DateTime RecordedAt { get; set; }
[Column("tagname")] public string TagName { get; set; } = string.Empty;
[Column("value")] public string? Value { get; set; }
}
```
### 2. `ExperionDbContext.cs` — DbSet + 테이블 생성
**파일**: `src/Infrastructure/Database/ExperionDbContext.cs`
#### DbSet 추가 (20~25번째 줄 근처)
```csharp
public DbSet<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
```
#### OnModelCreating에 인덱스 추가 (57번째 줄 근처)
```csharp
modelBuilder.Entity<FastSession>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.Status);
e.HasIndex(x => x.StartedAt);
});
modelBuilder.Entity<FastRecord>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.SessionId);
e.HasIndex(x => new { x.SessionId, x.TagName, x.RecordedAt });
});
```
#### DB 초기화 시 hypertable 생성 (100번째 줄 근처)
`ExperionDbService.InitializeAsync()` 메서드에 추가:
```csharp
// fastSession / fastRecord 테이블 생성
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS fast_session (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'Pending',
sampling_ms INTEGER NOT NULL,
duration_sec INTEGER NOT NULL,
tag_list JSONB NOT NULL DEFAULT '[]',
row_count INTEGER NOT NULL DEFAULT 0,
retention_days INTEGER,
pinned BOOLEAN NOT NULL DEFAULT FALSE
)
""");
await _ctx.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS fast_record (
id SERIAL PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES fast_session(id) ON DELETE CASCADE,
recorded_at TIMESTAMPTZ NOT NULL,
tagname TEXT NOT NULL,
value TEXT
)
""");
// TimescaleDB hypertable 생성 (recorded_at 기준, chunk_interval = 1 day)
await _ctx.Database.ExecuteSqlRawAsync("""
SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE)
""");
// chunk_time_interval 설정 (기본 1day)
await _ctx.Database.ExecuteSqlRawAsync("""
SELECT set_chunk_time_interval('fast_record', INTERVAL '1 day')
""");
```
### 3. `IExperionServices.cs` — 인터페이스 추가
**파일**: `src/Core/Application/Interfaces/IExperionServices.cs`
#### DTOs 추가 (DTOs 파일에 별도 정의 후 import)
```csharp
// IExperionFastService 인터페이스
public interface IExperionFastService
{
Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request);
Task StopSessionAsync(int sessionId);
Task DeleteSessionAsync(int sessionId);
Task PinSessionAsync(int sessionId, bool pinned);
Task<FastSessionInfo?> GetSessionAsync(int sessionId);
Task<IEnumerable<FastSessionInfo>> GetSessionsAsync();
Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long");
Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null);
}
// FastSessionStatus enum
public enum FastSessionStatus
{
Pending, Running, Completed, Cancelled, Failed, RowLimitReached
}
// FastSessionInfo record
public record FastSessionInfo(
int Id,
string Name,
DateTime StartedAt,
DateTime? EndedAt,
string Status,
int SamplingMs,
int DurationSec,
string[] TagList,
int RowCount,
int? RetentionDays,
bool Pinned
);
// FastSessionCreateRequest record (Claude 진단 #3)
public record FastSessionCreateRequest(
string Name,
int SamplingMs,
int DurationSec,
string[] TagList,
int? RetentionDays = null
);
// FastQueryResult record
public record FastQueryResult(
int SessionId,
DateTime From,
DateTime To,
string[] TagNames,
IEnumerable<FastRecord> Items,
int TotalCount
);
// FastSessionStartRequest record
public record FastSessionStartRequest(
string Name,
int SamplingMs,
int DurationSec,
string[] TagList,
int? RetentionDays = null
);
```
---
## Task B — FastService (백그라운드 + 컨트롤러)
### 1. `ExperionFastService.cs` — 신규 파일 생성
**파일**: `src/Infrastructure/OpcUa/ExperionFastService.cs`
```csharp
using System.Collections.Concurrent;
using System.Text.Json; // ✅ 추가: JsonSerializer.Serialize/Deserialize 사용 (Claude 진단 #7)
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;
/// <summary>
/// fastRecord 데이터 수집 서비스.
/// 세션별 별도 OPC UA Subscription을 관리하고, 2초마다 배치 INSERT.
/// </summary>
public class ExperionFastService : IExperionFastService, IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExperionFastService> _logger;
private readonly IServiceProvider _sp;
private readonly IOpcUaConfigProvider _configProvider;
private readonly IExperionOpcClient _opcClient;
private readonly ConcurrentDictionary<int, FastSessionContext> _sessions = new();
private readonly object _lock = new();
private CancellationTokenSource? _cts;
private Task? _monitorTask;
// 설정
private int _maxConcurrentSessions = 3;
private int _maxRowsPerSession = 5_000_000;
private int _flushIntervalMs = 2000;
public ExperionFastService(
IServiceScopeFactory scopeFactory,
ILogger<ExperionFastService> logger,
IServiceProvider sp,
IOpcUaConfigProvider configProvider,
IExperionOpcClient opcClient)
{
_scopeFactory = scopeFactory;
_logger = logger;
_sp = sp;
_configProvider = configProvider;
_opcClient = opcClient;
}
// ── IHostedService ────────────────────────────────────────────────────────
public async Task StartAsync(CancellationToken cancellationToken)
{
// 앱 시작 시 Running 상태 세션 → Failed 마킹
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var sessions = await db.GetFastSessionsAsync();
var runningSessions = sessions.Where(s => s.Status == "Running").ToList();
foreach (var s in runningSessions)
{
_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);
// 모든 Running 세션 graceful 종료
foreach (var kvp in _sessions)
{
var ctx = kvp.Value;
ctx.Cancel = true;
}
await Task.Delay(2000).ConfigureAwait(false); // flush 대기
}
// ── IExperionFastService ──────────────────────────────────────────────────
public async Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request)
{
// 유효성 검사
if (request.TagList.Length > 8)
throw new ArgumentException("태그는 최대 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<IExperionDbService>();
var runningCount = (await db.GetFastSessionsAsync()).Count(s => s.Status == "Running");
if (runningCount >= _maxConcurrentSessions)
throw new InvalidOperationException($"동시 실행 가능한 세션은 {_maxConcurrentSessions}개까지입니다.");
// ✅ 수정: IOpcUaConfigProvider를 통해 서버 설정 가져오기 (Claude 진단 #5)
var cfg = await _configProvider.GetConfigAsync(new ExperionServerConfig());
if (!await _opcClient.IsConnectedAsync(cfg))
throw new InvalidOperationException("OPC UA 서버에 연결되어 있지 않습니다.");
// ✅ 추가: 설정이 유효한지 확인
if (string.IsNullOrEmpty(cfg?.EndpointUrl))
throw new InvalidOperationException("서버 엔드포인트 URL이 설정되어 있지 않습니다.");
// 노드 유효성 사전 검증 (Read 1회)
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}' (nodeId: {nodeId}) 읽기 실패: {readResult.ErrorMessage}");
}
// DB에 세션 생성
var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest
{
Name = request.Name,
SamplingMs = request.SamplingMs,
DurationSec = request.DurationSec,
TagList = request.TagList,
RetentionDays = request.RetentionDays
});
// Subscription 생성
var ctx = new FastSessionContext
{
SessionId = session.Id,
TagList = request.TagList,
SamplingMs = request.SamplingMs,
DurationSec = request.DurationSec,
StartedAt = DateTime.UtcNow,
Buffer = new ConcurrentQueue<FastRecord>()
};
_sessions[session.Id] = ctx;
// Subscription 시작
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<IExperionDbService>();
await db.UpdateFastSessionStatusAsync(sessionId, "Completed");
// ✅ 수정: 누적 RowCount 사용 (Claude 진단 #8)
await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows);
_logger.LogInformation("[Fast] 세션 {Id} 중지 — 수집 {Count}행", sessionId, ctx.TotalRows);
}
public async Task DeleteSessionAsync(int sessionId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
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<IExperionDbService>();
await db.UpdateFastSessionPinnedAsync(sessionId, pinned);
}
public async Task<FastSessionInfo?> GetSessionAsync(int sessionId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var session = await db.GetFastSessionAsync(sessionId);
return session == null ? null : MapToInfo(session);
}
public async Task<IEnumerable<FastSessionInfo>> GetSessionsAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var sessions = await db.GetFastSessionsAsync();
return sessions.Select(MapToInfo);
}
public async Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to, string format = "long")
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
var result = await db.GetFastRecordsAsync(sessionId, from, to);
return result;
}
public async Task ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to);
}
// ── Private ────────────────────────────────────────────────────────────────
// ✅ 참고: CreateSessionAsync 사용을 위해 파일 상단에 using Opc.Ua; 추가 필요
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 += (sender, 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;
// ✅ 수정: MonitoredItemNotification 사용 (Claude 진단 #6)
if (e.NotificationValue is MonitoredItemNotification notification)
{
var dv = notification.Value;
var value = dv.Value?.ToString();
var record = new FastRecord
{
SessionId = ctx.SessionId,
RecordedAt = DateTime.UtcNow,
TagName = tagName,
Value = value
};
ctx.Buffer.Enqueue(record);
// ✅ 수정: 누적 RowCount 증가 (Claude 진단 #8, #9)
ctx.TotalRows++;
}
}
private async Task FlushBufferAsync(FastSessionContext ctx)
{
var buffer = new List<FastRecord>();
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<IExperionDbService>();
await db.BatchInsertFastRecordsAsync(buffer);
// ✅ 수정: 누적 RowCount 업데이트 (Claude 진단 #8)
await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows);
// ✅ 수정: 누적 총 행 수와 비교 (Claude 진단 #9)
if (ctx.TotalRows >= _maxRowsPerSession)
{
ctx.Cancel = true;
await StopSessionAsync(ctx.SessionId);
using var s = _scopeFactory.CreateScope();
var d = s.ServiceProvider.GetRequiredService<IExperionDbService>();
await d.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached");
}
}
private async Task MonitorLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(_flushIntervalMs, ct);
foreach (var kvp in _sessions)
{
var ctx = kvp.Value;
if (ctx.Cancel) continue;
// 기간 만료 체크
var elapsed = (DateTime.UtcNow - ctx.StartedAt).TotalSeconds;
if (elapsed >= ctx.DurationSec)
{
ctx.Cancel = true;
await StopSessionAsync(ctx.SessionId);
continue;
}
await FlushBufferAsync(ctx);
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogError(ex, "[Fast] 모니터링 루프 오류");
}
}
}
private FastSessionInfo MapToInfo(FastSession session)
=> new(session.Id, session.Name, session.StartedAt, session.EndedAt,
session.Status, session.SamplingMs, session.DurationSec,
// ✅ 수정: JSONB 파싱을 위해 JsonSerializer.Deserialize 사용 (Claude 진단 #7)
JsonSerializer.Deserialize<string[]>(session.TagList) ?? [],
session.RowCount, session.RetentionDays, session.Pinned);
// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
private async Task<string> GetNodeIdAsync(string tagName)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
return await db.GetNodeIdByTagNameAsync(tagName) ?? string.Empty;
}
private 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<FastRecord> Buffer { get; set; } = new();
public int TotalRows { get; set; } // ✅ 수정: 누적 총 행 수 (Claude 진단 #8, #9)
public bool Cancel { get; set; }
public ISession? Session { get; set; }
public Subscription? Subscription { get; set; }
}
}
```
### 2. `ExperionDbContext.cs` — DB 서비스 메서드 추가
**파일**: `src/Infrastructure/Database/ExperionDbContext.cs`
#### ExperionDbService에 추가할 메서드 (1000번째 줄 근처)
```csharp
// ✅ 참고: JsonSerializer 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
// ── FastSession / FastRecord ────────────────────────────────────────────────
public async Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request)
{
var session = new FastSession
{
Name = request.Name,
SamplingMs = request.SamplingMs,
DurationSec = request.DurationSec,
// ✅ 수정: JSONB 저장을 위해 JsonSerializer.Serialize 사용 (Claude 진단 #7)
TagList = JsonSerializer.Serialize(request.TagList),
StartedAt = DateTime.UtcNow,
Status = "Pending",
RowCount = 0,
RetentionDays = request.RetentionDays,
Pinned = false
};
_ctx.FastSessions.Add(session);
await _ctx.SaveChangesAsync();
return session;
}
public async Task UpdateFastSessionStatusAsync(int sessionId, string status)
{
var session = await _ctx.FastSessions.FindAsync(sessionId);
if (session == null) return;
session.Status = status;
if (status == "Completed" || status == "Cancelled" || status == "Failed" || status == "RowLimitReached")
session.EndedAt = DateTime.UtcNow;
await _ctx.SaveChangesAsync();
}
public async Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount)
{
var session = await _ctx.FastSessions.FindAsync(sessionId);
if (session == null) return;
session.RowCount = rowCount;
await _ctx.SaveChangesAsync();
}
public async Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned)
{
var session = await _ctx.FastSessions.FindAsync(sessionId);
if (session == null) return;
session.Pinned = pinned;
await _ctx.SaveChangesAsync();
}
public async Task<FastSession?> GetFastSessionAsync(int sessionId)
=> await _ctx.FastSessions.FindAsync(sessionId);
// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
public async Task<IEnumerable<FastSession>> GetFastSessionsAsync()
=> await _ctx.FastSessions.OrderBy(x => x.StartedAt).ToListAsync();
public async Task DeleteFastSessionAsync(int sessionId)
{
var session = await _ctx.FastSessions.FindAsync(sessionId);
if (session == null) return;
_ctx.FastSessions.Remove(session);
await _ctx.SaveChangesAsync();
}
// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
public async Task<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to)
{
var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId);
if (from.HasValue)
query = query.Where(x => x.RecordedAt >= from.Value);
if (to.HasValue)
query = query.Where(x => x.RecordedAt <= to.Value);
var records = await query.OrderBy(x => x.RecordedAt).ToListAsync();
var tagNames = records.Select(x => x.TagName).Distinct().OrderBy(x => x).ToArray();
return new FastQueryResult
{
SessionId = sessionId,
From = from ?? records.MinBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow,
To = to ?? records.MaxBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow,
TagNames = tagNames,
Items = records,
TotalCount = records.Count
};
}
public async Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records)
{
if (!records.Any()) return;
_ctx.FastRecords.AddRange(records);
await _ctx.SaveChangesAsync();
}
// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null)
{
var result = await GetFastRecordsAsync(sessionId, from, to);
using var writer = new StreamWriter(stream, leaveOpen: true);
var header = "recorded_at," + string.Join(",", result.TagNames.Select(t => $"\"{t}\""));
await writer.WriteLineAsync(header);
// PIVOT: recorded_at 기준 그룹화
var grouped = result.Items.GroupBy(x => x.RecordedAt)
.OrderBy(x => x.Key)
.Select(g => new
{
Time = g.Key,
Values = g.ToDictionary(r => r.TagName, r => r.Value)
});
foreach (var g in grouped)
{
var row = g.Time.ToString("o") + "," + string.Join(",", result.TagNames.Select(t => g.Values.TryGetValue(t, out var v) ? $"\"{v}\"" : ""));
await writer.WriteLineAsync(row);
}
await writer.FlushAsync();
}
public async Task<string> GetNodeIdByTagNameAsync(string tagName)
{
return await _ctx.RealtimePoints
.Where(x => x.TagName == tagName)
.Select(x => x.NodeId)
.FirstOrDefaultAsync();
}
```
### 3. `ExperionFastController` — 컨트롤러 추가
**파일**: `src/Web/Controllers/ExperionControllers.cs`
```csharp
// ── FastTable / FastRecord ────────────────────────────────────────────────────
[ApiController]
[Route("api/fast")]
public class ExperionFastController : ControllerBase
{
private readonly IExperionFastService _fastSvc;
public ExperionFastController(IExperionFastService fastSvc)
=> _fastSvc = fastSvc;
/// <summary>새 fastSession 시작</summary>
[HttpPost("start")]
public async Task<IActionResult> Start([FromBody] FastSessionStartRequest request)
{
try
{
var session = await _fastSvc.StartSessionAsync(request);
return Ok(new
{
id = session.Id,
name = session.Name,
status = session.Status,
startedAt = session.StartedAt
});
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return Conflict(new { error = ex.Message });
}
}
/// <summary>세션 중지</summary>
[HttpPost("{id:int}/stop")]
public async Task<IActionResult> Stop(int id)
{
try
{
await _fastSvc.StopSessionAsync(id);
return Ok(new { success = true, message = "세션이 중지되었습니다." });
}
catch (InvalidOperationException ex)
{
return NotFound(new { error = ex.Message });
}
}
/// <summary>세션 목록 조회</summary>
[HttpGet("sessions")]
public async Task<IActionResult> GetSessions()
{
var sessions = await _fastSvc.GetSessionsAsync();
return Ok(new
{
total = sessions.Count(),
items = sessions.Select(s => new
{
id = s.Id,
name = s.Name,
status = s.Status,
samplingMs = s.SamplingMs,
durationSec = s.DurationSec,
tagCount = s.TagList.Length,
rowCount = s.RowCount,
startedAt = s.StartedAt,
endedAt = s.EndedAt,
retentionDays = s.RetentionDays,
pinned = s.Pinned
})
});
}
/// <summary>세션 상세 정보</summary>
[HttpGet("{id:int}")]
public async Task<IActionResult> GetSession(int id)
{
var session = await _fastSvc.GetSessionAsync(id);
if (session == null)
return NotFound();
return Ok(new
{
id = session.Id,
name = session.Name,
status = session.Status,
samplingMs = session.SamplingMs,
durationSec = session.DurationSec,
tagList = session.TagList,
rowCount = session.RowCount,
startedAt = session.StartedAt,
endedAt = session.EndedAt,
retentionDays = session.RetentionDays,
pinned = session.Pinned
});
}
/// <summary>레코드 조회</summary>
[HttpGet("{id:int}/records")]
public async Task<IActionResult> GetRecords(int id,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string format = "long")
{
var result = await _fastSvc.GetRecordsAsync(id, from, to, format);
return Ok(new
{
sessionId = result.SessionId,
from = result.From,
to = result.To,
tagNames = result.TagNames,
total = result.TotalCount,
items = result.Items.Select(r => new
{
sessionId = r.SessionId,
recordedAt = r.RecordedAt,
tagName = r.TagName,
value = r.Value
})
});
}
/// <summary>CSV Export (스트리밍)</summary>
[HttpGet("{id:int}/csv")]
public async Task<IActionResult> ExportCsv(int id,
[FromQuery] DateTime? from,
[FromQuery] DateTime? to)
{
var memoryStream = new MemoryStream();
await _fastSvc.ExportCsvAsync(id, memoryStream, from, to);
memoryStream.Position = 0;
return File(memoryStream, "text/csv", $"fast-{id}-{DateTime.Now:yyyyMMddHHmm}.csv");
}
/// <summary>세션 삭제</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try
{
await _fastSvc.DeleteSessionAsync(id);
return Ok(new { success = true, message = "세션이 삭제되었습니다." });
}
catch (InvalidOperationException ex)
{
return NotFound(new { error = ex.Message });
}
}
/// <summary>세션 고정/해제</summary>
[HttpPost("{id:int}/pin")]
public async Task<IActionResult> Pin(int id, [FromBody] PinRequest request)
{
try
{
await _fastSvc.PinSessionAsync(id, request.Pinned);
return Ok(new { success = true, pinned = request.Pinned });
}
catch (InvalidOperationException ex)
{
return NotFound(new { error = ex.Message });
}
}
}
public record PinRequest(bool Pinned);
```
### 4. `Program.cs` — 서비스 등록
**파일**: `src/Web/Program.cs`
```csharp
// ── FastTable Service ────────────────────────────────────────────────────────
// ✅ 수정: 올바른 DI 등록 패턴 (Claude 진단 #4)
builder.Services.AddSingleton<ExperionFastService>();
builder.Services.AddSingleton<IExperionFastService>(sp => sp.GetRequiredService<ExperionFastService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<ExperionFastService>());
```
### 5. `appsettings.json` — 설정 추가
**파일**: `src/Web/appsettings.json`
```json
{
"Fast": {
"MaxConcurrentSessions": 3,
"MaxRowsPerSession": 5000000,
"FlushIntervalMs": 2000
}
}
```
---
## Task C — UI: 09 fastRecord 탭
### 1. `index.html` — HTML 구조 추가
**파일**: `src/Web/wwwroot/index.html`
```html
<!-- 사이드바 메뉴 -->
<li><a href="#pane-fast">09 fastRecord</a></li>
<!-- fastRecord 패널 -->
<div id="pane-fast" class="tab-pane fade">
<div class="tab-content">
<div class="row">
<!-- 좌측: 세션 목록 -->
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h5>fastSession 목록</h5>
<button id="btn-fast-new" class="btn btn-sm btn-primary float-end">신규 세션</button>
</div>
<div class="card-body p-0">
<div id="fast-session-list" class="list-group list-group-flush">
<!-- 세션 항목들 -->
</div>
</div>
</div>
</div>
<!-- 우측: 그래프 및 통계 -->
<div class="col-md-9">
<div class="card">
<div class="card-header">
<h5 id="fast-session-title">세션 상세</h5>
<div id="fast-session-controls" class="btn-group">
<button id="btn-fast-stop" class="btn btn-sm btn-danger">중지</button>
<button id="btn-fast-export-xlsx" class="btn btn-sm btn-success">Excel</button>
<button id="btn-fast-export-csv" class="btn btn-sm btn-info">CSV</button>
<button id="btn-fast-delete" class="btn btn-sm btn-secondary">삭제</button>
<button id="btn-fast-pin" class="btn btn-sm btn-warning">고정</button>
</div>
</div>
<div class="card-body">
<!-- 진행률 -->
<div class="progress mb-2" style="height: 20px;">
<div id="fast-progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
</div>
<div class="d-flex justify-content-between small text-muted mb-2">
<span id="fast-progress-text">0 / 0 (0%)</span>
<span id="fast-elapsed-time">경과: 0s</span>
</div>
<!-- 그래프 -->
<div id="fast-chart-container" style="height: 400px; width: 100%;"></div>
<!-- 통계 -->
<div id="fast-stats-panel" class="mt-3">
<h6>통계 요약</h6>
<div id="fast-stats-grid" class="row"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 모달: 신규 세션 -->
<div class="modal fade" id="modal-fast-new" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">신규 fastSession</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">세션 이름</label>
<input type="text" class="form-control" id="fast-session-name" placeholder="예: 공정온도_분석_20260428">
</div>
<div class="mb-3">
<label class="form-label">태그 선택 (최대 8개)</label>
<select id="fast-tag-select" class="form-select" multiple size="10"></select>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">샘플링 간격 (ms)</label>
<select class="form-select" id="fast-sampling-ms">
<option value="100">100ms</option>
<option value="250">250ms</option>
<option value="500" selected>500ms</option>
<option value="1000">1000ms</option>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">수집 기간 (초)</label>
<select class="form-select" id="fast-duration-sec">
<option value="60">1분</option>
<option value="300">5분</option>
<option value="900">15분</option>
<option value="1800">30분</option>
<option value="3600" selected>1시간</option>
<option value="7200">2시간</option>
<option value="14400">4시간</option>
<option value="43200">12시간</option>
<option value="86400">24시간</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">보관 기간 (일, null=무한)</label>
<input type="number" class="form-control" id="fast-retention-days" placeholder="30">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="btn-fast-start">시작</button>
</div>
</div>
</div>
</div>
```
### 2. `app.js` — JavaScript 로직 추가
**파일**: `src/Web/wwwroot/js/app.js`
```javascript
// ── fastRecord Variables ──────────────────────────────────────────────────────
let fastCurrentSessionId = null;
let fastChart = null;
let fastLivePollTimer = null;
let fastTagList = [];
// ── fastRecord Functions ──────────────────────────────────────────────────────
async function fastSessionsLoad() {
const res = await fetch('/api/fast/sessions');
if (!res.ok) return;
const data = await res.json();
const list = document.getElementById('fast-session-list');
list.innerHTML = '';
data.items.forEach(s => {
const item = document.createElement('a');
item.className = 'list-group-item list-group-item-action';
item.href = '#';
item.dataset.id = s.id;
item.innerHTML = `
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">${s.name}</h6>
<small>${s.status}</small>
</div>
<p class="mb-1">${s.tagCount} tags • ${s.samplingMs}ms • ${formatDuration(s.durationSec)}</p>
<small>${formatDateTime(s.startedAt)}</small>
${s.pinned ? '<span class="badge bg-warning text-dark ms-1">📌</span>' : ''}
`;
item.onclick = () => fastSelect(s.id);
list.appendChild(item);
});
}
async function fastStart() {
const name = document.getElementById('fast-session-name').value.trim();
if (!name) { alert('세션 이름을 입력하세요.'); return; }
const select = document.getElementById('fast-tag-select');
const tags = Array.from(select.selectedOptions).map(o => o.value);
if (tags.length === 0) { alert('태그를 최소 1개 이상 선택하세요.'); return; }
if (tags.length > 8) { alert('태그는 최대 8개까지 선택 가능합니다.'); return; }
const samplingMs = parseInt(document.getElementById('fast-sampling-ms').value);
const durationSec = parseInt(document.getElementById('fast-duration-sec').value);
const retentionDays = document.getElementById('fast-retention-days').value.trim() || null;
const res = await fetch('/api/fast/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, samplingMs, durationSec, tagList: tags, retentionDays })
});
if (!res.ok) {
const err = await res.json();
alert('오류: ' + (err.error || '알 수 없는 오류'));
return;
}
const data = await res.json();
await fastSessionsLoad();
fastSelect(data.id);
document.getElementById('modal-fast-new').querySelector('.btn-close').click();
}
async function fastStop(id) {
const res = await fetch(`/api/fast/${id}/stop`, { method: 'POST' });
if (!res.ok) { alert('중지 실패'); return; }
await fastSessionsLoad();
if (fastCurrentSessionId === id) {
fastCurrentSessionId = null;
fastClearChart();
}
}
async function fastDelete(id) {
if (!confirm('세션을 삭제하시겠습니까?')) return;
const res = await fetch(`/api/fast/${id}`, { method: 'DELETE' });
if (!res.ok) { alert('삭제 실패'); return; }
await fastSessionsLoad();
if (fastCurrentSessionId === id) {
fastCurrentSessionId = null;
fastClearChart();
}
}
async function fastPin(id) {
const res = await fetch(`/api/fast/${id}/pin`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned: true })
});
if (!res.ok) { alert('고정 실패'); return; }
await fastSessionsLoad();
}
async function fastSelect(id) {
fastCurrentSessionId = id;
const res = await fetch(`/api/fast/${id}`);
if (!res.ok) { alert('세션 조회 실패'); return; }
const session = await res.json();
document.getElementById('fast-session-title').textContent = `${session.name} (${session.status})`;
document.getElementById('fast-progress-bar').style.width = '0%';
document.getElementById('fast-progress-text').textContent = '0 / 0 (0%)';
// 버튼 상태 업데이트
const isRunning = session.status === 'Running';
document.getElementById('btn-fast-stop').style.display = isRunning ? 'inline' : 'none';
document.getElementById('btn-fast-export-xlsx').style.display = isRunning ? 'none' : 'inline';
document.getElementById('btn-fast-export-csv').style.display = isRunning ? 'none' : 'inline';
document.getElementById('btn-fast-delete').style.display = 'inline';
document.getElementById('btn-fast-pin').textContent = session.pinned ? '고정 해제' : '고정';
// 태그 목록 업데이트
fastTagList = session.tagList;
// 그래프 렌더링
await fastRenderChart();
// 라이브 폴링 시작
if (isRunning) {
fastLivePollStart();
} else {
fastLivePollStop();
}
}
async function fastRenderChart() {
if (!fastCurrentSessionId) return;
const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`);
if (!res.ok) return;
const data = await res.json();
if (!data.items || data.items.length === 0) {
document.getElementById('fast-chart-container').innerHTML = '<div class="text-center text-muted">수집된 데이터가 없습니다.</div>';
return;
}
// PIVOT: recorded_at 기준 그룹화
const grouped = data.items.reduce((acc, r) => {
if (!acc[r.recordedAt]) acc[r.recordedAt] = {};
acc[r.recordedAt][r.tagName] = r.value;
return acc;
}, {});
const times = Object.keys(grouped).sort();
const timesNum = times.map(t => new Date(t).getTime());
// ✅ 수정: uPlot 데이터 포맷 [[x0,x1,...], [y1_0,y1_1,...], [y2_0,...]] (Claude 진단 #10)
const datasets = data.tagNames.map(tag => ({
label: tag,
data: times.map(t => grouped[t][tag])
}));
// uPlot 사용 (Chart.js 대신)
const ctx = document.getElementById('fast-chart-container');
ctx.innerHTML = '';
const opts = {
title: 'fastRecord 실시간 트렌드',
width: ctx.clientWidth,
height: 400,
axes: [{
time: true,
label: '시간 (KST)',
values: (u, vals) => vals.map(v => new Date(v).toLocaleTimeString('ko-KR'))
}, {
label: '값'
}],
series: [{}, ...datasets]
};
// ✅ 수정: new uPlot(opts, data, target) 시그니처 (Claude 진단 #10)
fastChart = new uPlot(opts, [timesNum, ...datasets.map(d => d.data)], ctx);
}
function fastClearChart() {
if (fastChart) {
fastChart.destroy();
fastChart = null;
}
document.getElementById('fast-chart-container').innerHTML = '';
}
function fastLivePollStart() {
if (fastLivePollTimer) return;
fastLivePollTimer = setInterval(async () => {
if (!fastCurrentSessionId) { fastLivePollStop(); return; }
await fastRenderChart();
await fastUpdateProgress();
}, 2000);
}
function fastLivePollStop() {
if (fastLivePollTimer) {
clearInterval(fastLivePollTimer);
fastLivePollTimer = null;
}
}
async function fastUpdateProgress() {
if (!fastCurrentSessionId) return;
const res = await fetch(`/api/fast/${fastCurrentSessionId}`);
if (!res.ok) return;
const session = await res.json();
const elapsed = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000);
const progress = Math.min((elapsed / session.durationSec) * 100, 100);
document.getElementById('fast-progress-bar').style.width = `${progress}%`;
document.getElementById('fast-progress-text').textContent = `${session.rowCount} / ~${(session.durationSec * session.tagList.length)} (${progress.toFixed(1)}%)`;
document.getElementById('fast-elapsed-time').textContent = `경과: ${formatDuration(elapsed)}`;
}
// ── Helper Functions ────────────────────────────────────────────────────────
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${h}h ${m}m ${s}s`;
}
function formatDateTime(dt) {
return new Date(dt).toLocaleString('ko-KR');
}
function getColorForTag(tag) {
const colors = ['#ff6384', '#36a2eb', '#ffce56', '#4bc0c0', '#9966ff', '#ff9f40', '#8ac926', '#1982c4'];
let sum = 0;
for (let i = 0; i < tag.length; i++) sum += tag.charCodeAt(i);
return colors[sum % colors.length];
}
// ── Event Listeners ──────────────────────────────────────────────────────────
document.getElementById('btn-fast-new')?.addEventListener('click', () => {
// 태그 목록 로드
const select = document.getElementById('fast-tag-select');
select.innerHTML = '';
tagNames.forEach(name => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
select.appendChild(opt);
});
document.getElementById('modal-fast-new').querySelector('.modal-title').textContent = '신규 fastSession';
document.getElementById('modal-fast-new').style.display = 'block';
new bootstrap.Modal(document.getElementById('modal-fast-new')).show();
});
document.getElementById('btn-fast-start')?.addEventListener('click', fastStart);
document.getElementById('btn-fast-stop')?.addEventListener('click', () => {
if (fastCurrentSessionId) fastStop(fastCurrentSessionId);
});
document.getElementById('btn-fast-delete')?.addEventListener('click', () => {
if (fastCurrentSessionId) fastDelete(fastCurrentSessionId);
});
document.getElementById('btn-fast-pin')?.addEventListener('click', () => {
if (fastCurrentSessionId) fastPin(fastCurrentSessionId);
});
document.getElementById('btn-fast-export-xlsx')?.addEventListener('click', async () => {
if (!fastCurrentSessionId) return;
const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`);
if (!res.ok) return;
const data = await res.json();
// ✅ 수정: 배열의 배열로 변환 (Claude 진단 #11)
const rows = [['recorded_at', ...data.tagNames]];
for (const r of data.items) {
if (!rows[r.recordedAt]) {
rows[r.recordedAt] = [new Date(r.recordedAt).toLocaleString('ko-KR')];
for (let i = 0; i < data.tagNames.length; i++) rows[r.recordedAt].push('');
}
rows[r.recordedAt][data.tagNames.indexOf(r.tagName) + 1] = r.value;
}
const ws = XLSX.utils.aoa_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'fastRecord');
XLSX.writeFile(wb, `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.xlsx`);
});
document.getElementById('btn-fast-export-csv')?.addEventListener('click', async () => {
if (!fastCurrentSessionId) return;
const res = await fetch(`/api/fast/${fastCurrentSessionId}/csv`);
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
});
// 탭 전환 시 로드
document.querySelectorAll('[href="#pane-fast"]').forEach(a => {
a.addEventListener('show.bs.tab', () => {
fastSessionsLoad();
});
});
```
### 3. `style.css` — 스타일 추가
**파일**: `src/Web/wwwroot/css/style.css`
```css
/* fastRecord Styles */
#pane-fast .list-group-item {
cursor: pointer;
}
#pane-fast .list-group-item:hover {
background-color: #f8f9fa;
}
#pane-fast .progress-bar-animated {
background-color: #0d6efd;
}
#pane-fast .fast-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
#pane-fast .fast-stat-card {
background: #f8f9fa;
border-radius: 4px;
padding: 10px;
text-align: center;
}
#pane-fast .fast-stat-value {
font-size: 1.2rem;
font-weight: bold;
color: #0d6efd;
}
#pane-fast .fast-stat-label {
font-size: 0.8rem;
color: #6c757d;
}
#pane-fast .fast-outlier {
background-color: rgba(255, 0, 0, 0.2);
border-radius: 2px;
}
```
### 4. uPlot 라이브러리 추가
**파일**: `src/Web/wwwroot/lib/uPlot.iife.min.js` (CDN에서 다운로드)
```html
<!-- index.html의 <head> 또는 </body> 직전 -->
<script src="lib/uPlot.iife.min.js"></script>
<link rel="stylesheet" href="lib/uPlot.min.css">
```
---
## Task D — 정리/보관 백그라운드
### `ExperionFastCleanupService.cs` — 신규 파일 생성
**파일**: `src/Infrastructure/OpcUa/ExperionFastCleanupService.cs`
```csharp
using System.Text.Json; // ✅ 추가: JsonSerializer.Deserialize 사용 (Claude 진단 #7)
using ExperionCrawler.Core.Application.Interfaces;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ExperionCrawler.Infrastructure.OpcUa;
/// <summary>
/// fastSession 정리 서비스.
/// 매일 03:00에 만료된 세션 + 데이터 삭제.
/// pinned=true 제외.
/// </summary>
public class ExperionFastCleanupService : BackgroundService
{
private readonly IServiceProvider _sp;
private readonly ILogger<ExperionFastCleanupService> _logger;
public ExperionFastCleanupService(IServiceProvider sp, ILogger<ExperionFastCleanupService> logger)
{
_sp = sp;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
// 매일 03:00에 실행
var now = DateTime.UtcNow;
var nextRun = new DateTime(now.Year, now.Month, now.Day, 3, 0, 0, DateTimeKind.Utc);
if (now > nextRun) nextRun = nextRun.AddDays(1);
var delay = nextRun - now;
await Task.Delay(delay, stoppingToken);
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
// 만료된 세션 조회 (ended_at이 있고, pinned=false, retention_days 지남)
var expired = await db.GetExpiredFastSessionsAsync();
foreach (var s in expired)
{
_logger.LogInformation("[FastCleanup] 세션 {Id} ({Name}) 삭제 — 만료됨", s.Id, s.Name);
await db.DeleteFastSessionAsync(s.Id);
}
_logger.LogInformation("[FastCleanup] 정리 완료 — {Count}개 세션 삭제", expired.Count);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogError(ex, "[FastCleanup] 오류");
}
}
}
}
```
// ✅ 참고: JsonSerializer.Deserialize 사용을 위해 파일 상단에 using System.Text.Json; 추가 필요
// ✅ 참고: FastSession.TagList는 JSONB이므로 Deserialize 필요 (Claude 진단 #7)
public async Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync()
{
var now = DateTime.UtcNow;
return await _ctx.FastSessions
.Where(x => x.EndedAt != null
&& !x.Pinned
&& x.RetentionDays.HasValue
&& x.EndedAt.Value.AddDays(x.RetentionDays.Value) < now)
.OrderBy(x => x.EndedAt)
.ToListAsync();
}
```
### `Program.cs`에 등록
```csharp
// ── FastTable Cleanup Service ────────────────────────────────────────────────
builder.Services.AddHostedService<ExperionFastCleanupService>();
```
---
## Task E — 안정성 / QA
### 구현 시 주의사항
1. **노드 유효성 사전 검증**: `StartSessionAsync`에서 각 태그에 대해 `Read` 1회 수행
2. **동시 세션 수 제한**: `MaxConcurrentSessions` 설정 (기본 3)
3. **OPC 연결 상태 확인**: 시작 전 `IsConnectedAsync` 체크
4. **앱 종료 시 graceful 마무리**: `StopAsync`에서 버퍼 flush 후 종료
5. **앱 시작 시 Running 세션 처리**: `Failed` 마킹
### 테스트 시나리오
| 시나리오 | 검증 방법 |
|----------|-----------|
| 세션 시작 | 태그 8개 선택 → 시작 → OPC UA 구독 생성 확인 |
| 데이터 수집 | 1분간 수집 → DB에 60×8=480행 기록 확인 |
| 중지 | 중지 버튼 → 세션 상태 `Completed` 확인 |
| CSV Export | Export → CSV 파일 다운로드 확인 |
| 고정 | Pin → 재기동 후 보존 확인 |
| RowLimit | 500만행 초과 → 자동 종료 `RowLimitReached` 확인 |
---
## 구현 우선순위
1. **MVP (1~2일)**: Task A + B(start/stop/sessions/records) + C(목록/시작/중지/단순 그래프)
2. **분석 (0.5일)**: 통계 패널 + 이상치 강조
3. **Export (0.5일)**: xlsx + csv 스트리밍
4. **운영 (0.5일)**: Task D 정리, retention/pinned
5. **고급 (1일)**: 템플릿, 다중 Y축, LTTB 다운샘플링
---
## 참고 사항
- **JSON 직렬화**: `PropertyNamingPolicy = null`이므로 C# PascalCase가 JSON 키로 그대로 사용됨 → 클라이언트에서 camelCase로 접근하려면 익명 객체로 명시적 매핑 필요
- **TimescaleDB**: hypertable 생성 후 `set_chunk_time_interval('1 day')` 설정 권장
- **uPlot**: Chart.js 대신 시계열 특화 라이브러리 → 100만점도 부드러움
---
## 코드 진단 (Claude Sonnet 4.6, 2026-04-29) — 수정 완료
전체적인 방향성(TimescaleDB hypertable, 별도 Subscription, 세션 관리)은 올바르며, **11개 이슈가 모두 수정 완료**되었습니다.
---
### 수정 완료된 이슈
| # | 이슈 | 수정 내용 | 상태 |
|---|------|-----------|------|
| 1 | `IExperionOpcClient` 메서드 누락 | `IsConnectedAsync`, `CreateSessionAsync` 메서드 추가 | ✅ |
| 2 | `IExperionDbService` 인터페이스 누락 | Fast 관련 메서드 12개 추가 | ✅ |
| 3 | `FastSessionCreateRequest` 미선언 | DTO 추가 | ✅ |
| 4 | `Program.cs` DI 등록 오류 | 올바른 등록 패턴 적용 | ✅ |
| 5 | `ExperionServerConfig` 주입 경로 | `IOpcUaConfigProvider` 사용 | ✅ |
| 6 | `OnNotification` 타입 오류 | `MonitoredItemNotification` 사용 | ✅ |
| 7 | `TagList` JSONB vs CSV 불일치 | `JsonSerializer.Serialize/Deserialize` 사용 | ✅ |
| 8 | `RowCount` 누적 추적 | `FastSessionContext.TotalRows` 추가 | ✅ |
| 9 | `RowLimit` 체크 오류 | `ctx.TotalRows`와 비교 | ✅ |
| 10 | `uPlot` API 오류 | `new uPlot(opts, data, target)` 올바른 시그니처 | ✅ |
| 11 | `XLSX.utils.aoa_to_sheet` 오류 | 배열의 배열로 변환 로직 추가 | ✅ |
---
### 수정된 핵심 코드
#### 1. `IExperionOpcClient` 인터페이스 확장
```csharp
// src/Core/Application/Interfaces/IExperionOpcClient.cs
public interface IExperionOpcClient
{
// ✅ 추가: fastTable용 메서드 (Claude 진단 #1)
Task<bool> IsConnectedAsync(ApplicationConfiguration cfg);
Task<ISession> CreateSessionAsync(ApplicationConfiguration cfg);
// 기존 메서드들
Task<bool> TestConnectionAsync(ApplicationConfiguration cfg);
Task<ExperionReadResult> ReadTagAsync(ApplicationConfiguration cfg, string nodeId);
Task<ExperionReadResult[]> ReadTagsAsync(ApplicationConfiguration cfg, string[] nodeIds);
Task<Node[]> BrowseNodesAsync(ApplicationConfiguration cfg, string parentId);
Task<Node[]> BrowseAllNodesAsync(ApplicationConfiguration cfg);
}
```
#### 2. `IExperionDbService` 인터페이스 확장
```csharp
// src/Core/Application/Interfaces/IExperionDbService.cs
public interface IExperionDbService
{
// ... 기존 메서드들
// FastSession
Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request);
Task UpdateFastSessionStatusAsync(int sessionId, string status);
Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount);
Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned);
Task<FastSession?> GetFastSessionAsync(int sessionId);
Task<IEnumerable<FastSession>> GetFastSessionsAsync();
Task DeleteFastSessionAsync(int sessionId);
Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync();
// FastRecord
Task<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to);
Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records);
Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to);
}
```
#### 3. `OnNotification` 올바른 패턴
```csharp
private void OnNotification(FastSessionContext ctx, MonitoredItemNotificationEventArgs e, string tagName)
{
if (ctx.Cancel) return;
if (e.NotificationValue is MonitoredItemNotification notification)
{
var dv = notification.Value;
var value = dv.Value?.ToString();
var record = new FastRecord
{
SessionId = ctx.SessionId,
RecordedAt = DateTime.UtcNow,
TagName = tagName,
Value = value
};
ctx.Buffer.Enqueue(record);
// 누적 RowCount 증가
ctx.TotalRows++;
}
}
```
#### 4. `TagList` JSONB 저장
```csharp
// 저장 시
session.TagList = JsonSerializer.Serialize(request.TagList);
// 조회 시
session.TagList = JsonSerializer.Deserialize<string[]>(session.TagList) ?? [];
```
#### 5. `FastSessionContext`에 누적 RowCount 추가
```csharp
private 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<FastRecord> Buffer { get; set; } = new();
public int TotalRows { get; set; } // 누적 총 행 수
public bool Cancel { get; set; }
public ISession? Session { get; set; }
public Subscription? Subscription { get; set; }
}
```
#### 6. `RowLimit` 체크 수정
```csharp
// 올바른 체크
if (ctx.TotalRows + buffer.Count >= _maxRowsPerSession)
{
ctx.Cancel = true;
await StopSessionAsync(ctx.SessionId);
// ...
}
```
#### 7. uPlot API 올바른 사용
```javascript
// data: [[x0,x1,...], [y1_0,y1_1,...], [y2_0,...]]
const times = Object.keys(grouped).sort();
const timesNum = times.map(t => new Date(t).getTime());
const datasets = data.tagNames.map(tag => ({
label: tag,
data: times.map(t => grouped[t][tag])
}));
// target: DOM element (세 번째 인자)
fastChart = new uPlot(opts, [timesNum, ...datasets.map(d => d.data)], ctx);
```
#### 8. XLSX export 올바른 데이터 포맷
```javascript
const rows = [['recorded_at', ...data.tagNames]];
for (const r of data.items) {
if (!rows[r.recordedAt]) {
rows[r.recordedAt] = [new Date(r.recordedAt).toLocaleString('ko-KR')];
for (let i = 0; i < data.tagNames.length; i++) rows[r.recordedAt].push('');
}
rows[r.recordedAt][data.tagNames.indexOf(r.tagName) + 1] = r.value;
}
const ws = XLSX.utils.aoa_to_sheet(rows);
```
---
### 요약
| 구분 | 건수 | 상태 |
|------|------|------|
| 컴파일 에러 | 5건 | ✅ 수정 완료 |
| 런타임 버그 | 4건 | ✅ 수정 완료 |
| JS 오류 | 2건 | ✅ 수정 완료 |
**가장 치명적인 #6 OnNotification 타입 오류**(데이터가 아예 수집 안 됨)와 **#7 JSONB 저장 오류**(DB INSERT 에러)가 모두 수정되었습니다.