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

62 KiB
Raw Blame History

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

구현 시 주의사항

  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 인터페이스 확장

// 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 에러)가 모두 수정되었습니다.