62 KiB
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
/// <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번째 줄 근처)
public DbSet<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
OnModelCreating에 인덱스 추가 (57번째 줄 근처)
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() 메서드에 추가:
// 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)
// 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
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번째 줄 근처)
// ✅ 참고: 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
// ── 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
// ── 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
{
"Fast": {
"MaxConcurrentSessions": 3,
"MaxRowsPerSession": 5000000,
"FlushIntervalMs": 2000
}
}
Task C — UI: 09 fastRecord 탭
1. index.html — HTML 구조 추가
파일: src/Web/wwwroot/index.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
// ── 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
/* 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에서 다운로드)
<!-- 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
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> 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
구현 시 주의사항
- 노드 유효성 사전 검증:
StartSessionAsync에서 각 태그에 대해Read1회 수행 - 동시 세션 수 제한:
MaxConcurrentSessions설정 (기본 3) - OPC 연결 상태 확인: 시작 전
IsConnectedAsync체크 - 앱 종료 시 graceful 마무리:
StopAsync에서 버퍼 flush 후 종료 - 앱 시작 시 Running 세션 처리:
Failed마킹
테스트 시나리오
| 시나리오 | 검증 방법 |
|---|---|
| 세션 시작 | 태그 8개 선택 → 시작 → OPC UA 구독 생성 확인 |
| 데이터 수집 | 1분간 수집 → DB에 60×8=480행 기록 확인 |
| 중지 | 중지 버튼 → 세션 상태 Completed 확인 |
| CSV Export | Export → CSV 파일 다운로드 확인 |
| 고정 | Pin → 재기동 후 보존 확인 |
| RowLimit | 500만행 초과 → 자동 종료 RowLimitReached 확인 |
구현 우선순위
- MVP (1~2일): Task A + B(start/stop/sessions/records) + C(목록/시작/중지/단순 그래프)
- 분석 (0.5일): 통계 패널 + 이상치 강조
- Export (0.5일): xlsx + csv 스트리밍
- 운영 (0.5일): Task D 정리, retention/pinned
- 고급 (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 인터페이스 확장
// 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 인터페이스 확장
// 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 올바른 패턴
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 저장
// 저장 시
session.TagList = JsonSerializer.Serialize(request.TagList);
// 조회 시
session.TagList = JsonSerializer.Deserialize<string[]>(session.TagList) ?? [];
5. FastSessionContext에 누적 RowCount 추가
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 체크 수정
// 올바른 체크
if (ctx.TotalRows + buffer.Count >= _maxRowsPerSession)
{
ctx.Cancel = true;
await StopSessionAsync(ctx.SessionId);
// ...
}
7. uPlot API 올바른 사용
// 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 올바른 데이터 포맷
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 에러)가 모두 수정되었습니다.