Files
ExperionCrawler/plans/roo-fasttable-implementation.md

60 KiB
Raw Blame History

Roo 작업 지시: fastTable/fastRecord 기능 구현


⚠️ 필독 — 컨텍스트 관리 규칙

이 작업은 파일 11개를 수정/생성하는 대규모 작업이다. 컨텍스트 오염 방지를 위해 아래 규칙을 철저히 따른다.

시작 전 (최초 1회)

  1. .roo.md 읽기
  2. .roo/rules-code/glm-code-rules.md 읽기
  3. task_state.md 생성 — 아래 초기 템플릿 그대로 작성

단위 작업(Step)마다 반드시

  1. 해당 파일 백업/.rooBackup/YYYYMMDD_HHMMSS_파일명 으로 복사
  2. read_file 로 수정 대상 파일 전체 확인
  3. 코드 수정
  4. dotnet build 실행 → 에러 0건 확인
  5. task_state.md 즉시 업데이트 (완료 표시 + 빌드 결과)

이관 트리거 (즉시 중단 후 이관)

  • Step 완료 시마다 컨텍스트 70% 이상 판단되면 즉시 이관
  • task_state.md 최신화 후 이관 신호 출력
  • 이관 후 첫 문장: "task_state.md 를 읽고 [다음 Step 번호]부터 이어서 진행하세요"

task_state.md 초기 템플릿

이 파일을 프로젝트 루트에 생성하고 시작한다:

## 작업명: fastTable/fastRecord 기능 구현
## 시작시각: [실제 시작 시각 기록]
## 참조: plans/roo-fasttable-implementation.md

## 전체 Step 목록
- [ ] Step 1: ExperionEntities.cs — 엔티티 2개 추가
- [ ] Step 2: IExperionServices.cs — IExperionDbService Fast 메서드 추가
- [ ] Step 3: IExperionServices.cs — IExperionFastService + DTOs 추가
- [ ] Step 4: ExperionDbContext.cs — DbSet + OnModelCreating 추가
- [ ] Step 5: ExperionDbContext.cs — InitializeAsync DDL 추가
- [ ] Step 6: ExperionDbContext.cs — Fast DB 메서드 구현 (Create/Update/Get)
- [ ] Step 7: ExperionDbContext.cs — Fast DB 메서드 구현 (Insert/Export/Expired)
- [ ] Step 8: ExperionFastService.cs — 신규 파일 생성 (상단부: 필드/생성자/IHostedService)
- [ ] Step 9: ExperionFastService.cs — StartSessionAsync 구현
- [ ] Step 10: ExperionFastService.cs — OPC UA Subscription + OnNotification 구현
- [ ] Step 11: ExperionFastService.cs — FlushBuffer + MonitorLoop + 헬퍼 구현
- [ ] Step 12: ExperionFastCleanupService.cs — 신규 파일 생성
- [ ] Step 13: ExperionControllers.cs — ExperionFastController 추가
- [ ] Step 14: Program.cs — 서비스 등록
- [ ] Step 15: appsettings.json — Fast 섹션 추가
- [ ] Step 16: index.html — 탭 메뉴 + 패널 + 모달 추가
- [ ] Step 17: app.js — Fast 함수 추가 + 탭 핸들러 연결
- [ ] Step 18: style.css — Fast 전용 스타일 추가

## 완료된 Step
(없음)

## 발견된 문제
| Step | 파일 | 내용 | 상태 |
|------|------|------|------|

배경

fastTable-coding-plan-byQwen3.md 에 구현 계획이 있다. Claude Sonnet 4.6이 기존 코드베이스와 비교 진단한 결과 버그 11건 이 발견되었다. 이 파일은 그 오류를 수정한 올바른 구현 지침이다. 원본 계획과 충돌 시 이 파일의 지시를 우선한다.


Step 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";
    [Column("sampling_ms")]    public int      SamplingMs    { get; set; }
    [Column("duration_sec")]   public int      DurationSec   { get; set; }
    [Column("tag_list")]       public string   TagList       { get; set; } = "[]";
    [Column("row_count")]      public int      RowCount      { get; set; }
    [Column("retention_days")] public int?     RetentionDays { get; set; }
    [Column("pinned")]         public bool     Pinned        { get; set; }
}

/// <summary>fastRecord — 시계열 데이터 (Long 포맷)</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; }
}

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 1 완료 기록


Step 2 — IExperionServices.cs IExperionDbService Fast 메서드 추가

파일: src/Core/Application/Interfaces/IExperionServices.cs

IExperionDbService 인터페이스의 마지막 메서드(GetRealtimeNodeDataTypesAsync) 바로 뒤에 추가:

    // ── FastSession / FastRecord ──────────────────────────────────────────────
    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<FastQueryResult>          GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to);
    Task                           BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records);
    Task                           ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to);
    Task<string?>                  GetNodeIdByTagNameAsync(string tagName);
    Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync();

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 2 완료 기록


Step 3 — IExperionServices.cs IExperionFastService + DTOs 추가

파일: src/Core/Application/Interfaces/IExperionServices.cs

파일 맨 끝에 추가:

// ── Fast Service ─────────────────────────────────────────────────────────────

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);
    Task                               ExportCsvAsync(int sessionId, Stream stream, DateTime? from = null, DateTime? to = null);
}

public record FastSessionStartRequest(
    string   Name,
    int      SamplingMs,
    int      DurationSec,
    string[] TagList,
    int?     RetentionDays = null
);

public record FastSessionCreateRequest(
    string   Name,
    int      SamplingMs,
    int      DurationSec,
    string[] TagList,
    int?     RetentionDays
);

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
);

public record FastQueryResult(
    int                     SessionId,
    DateTime                From,
    DateTime                To,
    string[]                TagNames,
    IEnumerable<FastRecord> Items,
    int                     TotalCount
);

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 3 완료 기록


Step 4 — ExperionDbContext.cs DbSet + OnModelCreating 추가

파일: src/Infrastructure/Database/ExperionDbContext.cs

4-a. DbSet 추가

기존 DbSet 선언들이 있는 위치(파일 상단부)에 두 줄 추가:

public DbSet<FastSession> FastSessions => Set<FastSession>();
public DbSet<FastRecord>  FastRecords  => Set<FastRecord>();

4-b. OnModelCreating 에 인덱스 추가

기존 modelBuilder.Entity<...> 블록들이 있는 OnModelCreating 메서드 안에 추가:

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 });
});

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 4 완료 기록


Step 5 — ExperionDbContext.cs InitializeAsync DDL 추가

파일: src/Infrastructure/Database/ExperionDbContext.cs

InitializeAsync() 메서드 내부, 기존 테이블 DDL 블록들 끝에 추가.

주의: tag_list 컬럼은 반드시 TEXT로 선언한다. 원본 계획의 JSONB는 EF Core string 타입과 충돌하여 INSERT 에러가 발생한다.

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       TEXT        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
    )
    """);

await _ctx.Database.ExecuteSqlRawAsync("""
    SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE)
    """);

await _ctx.Database.ExecuteSqlRawAsync("""
    SELECT set_chunk_time_interval('fast_record', INTERVAL '1 day')
    """);

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 5 완료 기록


Step 6 — ExperionDbContext.cs Fast DB 메서드 (Create / Update / Get)

파일: src/Infrastructure/Database/ExperionDbContext.cs

ExperionDbService 클래스 안, 기존 메서드들 끝에 아래 메서드들을 추가한다.

주의: TagList는 반드시 JsonSerializer.Serialize()로 저장한다. string.Join(',')은 JSON 형식이 아니라 파싱 불일치가 발생한다.

// ── FastSession / FastRecord ────────────────────────────────────────────────

public async Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request)
{
    var session = new FastSession
    {
        Name          = request.Name,
        SamplingMs    = request.SamplingMs,
        DurationSec   = request.DurationSec,
        TagList       = System.Text.Json.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 is "Completed" or "Cancelled" or "Failed" or "RowLimitReached")
        session.EndedAt = DateTime.UtcNow;
    await _ctx.SaveChangesAsync();
}

public async Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount)
{
    await _ctx.FastSessions
        .Where(x => x.Id == sessionId)
        .ExecuteUpdateAsync(s => s.SetProperty(x => x.RowCount, rowCount));
}

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);

public async Task<IEnumerable<FastSession>> GetFastSessionsAsync()
    => await _ctx.FastSessions.OrderByDescending(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();
}

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 6 완료 기록


Step 7 — ExperionDbContext.cs Fast DB 메서드 (Insert / Export / Expired)

파일: src/Infrastructure/Database/ExperionDbContext.cs

Step 6에서 추가한 메서드들 바로 뒤에 이어서 추가:

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();
    var minTime  = records.Count > 0 ? records[0].RecordedAt : DateTime.UtcNow;
    var maxTime  = records.Count > 0 ? records[^1].RecordedAt : DateTime.UtcNow;

    return new FastQueryResult(sessionId, from ?? minTime, to ?? maxTime,
                               tagNames, records, records.Count);
}

public async Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records)
{
    var list = records.ToList();
    if (list.Count == 0) return;
    _ctx.FastRecords.AddRange(list);
    await _ctx.SaveChangesAsync();
}

public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream,
    DateTime? from, DateTime? to)
{
    var result = await GetFastRecordsAsync(sessionId, from, to);
    using var writer = new StreamWriter(stream, leaveOpen: true);
    await writer.WriteLineAsync("recorded_at," + string.Join(",", result.TagNames));

    foreach (var g in result.Items.GroupBy(x => x.RecordedAt).OrderBy(g => g.Key))
    {
        var values = g.ToDictionary(r => r.TagName, r => r.Value);
        var row = g.Key.ToString("o") + "," +
                  string.Join(",", result.TagNames.Select(
                      t => values.TryGetValue(t, out var v) ? $"\"{v}\"" : ""));
        await writer.WriteLineAsync(row);
    }
    await writer.FlushAsync();
}

public async Task<string?> GetNodeIdByTagNameAsync(string tagName)
    => await _ctx.RealtimePoints
        .Where(x => x.TagName == tagName)
        .Select(x => x.NodeId)
        .FirstOrDefaultAsync();

public async Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync()
{
    var now = DateTime.UtcNow;
    return await _ctx.FastSessions
        .Where(x => x.EndedAt != null
                 && !x.Pinned
                 && x.RetentionDays.HasValue
                 && x.EndedAt.Value.AddDays(x.RetentionDays.Value) < now)
        .ToListAsync();
}

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 7 완료 기록


Step 8 — ExperionFastService.cs 신규 생성 (상단부)

파일 신규 생성: src/Infrastructure/OpcUa/ExperionFastService.cs

이 Step에서는 파일 전체 골격 + 필드 + 생성자 + IHostedService 구현만 작성한다.

using System.Collections.Concurrent;
using System.Text.Json;
using ExperionCrawler.Core.Application.Interfaces;
using ExperionCrawler.Core.Domain.Entities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using ISession = Opc.Ua.Client.ISession;

namespace ExperionCrawler.Infrastructure.OpcUa;

public class ExperionFastService : IExperionFastService, IHostedService, IDisposable
{
    private readonly IServiceScopeFactory         _scopeFactory;
    private readonly ILogger<ExperionFastService> _logger;
    private readonly IOpcUaConfigProvider         _configProvider;

    private readonly ConcurrentDictionary<int, FastSessionContext> _sessions = new();
    private CancellationTokenSource? _cts;
    private Task?                    _monitorTask;

    private readonly int _maxConcurrentSessions = 3;
    private readonly int _maxRowsPerSession     = 5_000_000;
    private readonly int _flushIntervalMs       = 2_000;

    // ExperionServerConfig 는 realtime_autostart.json 에서 읽음 (RealtimeService 와 공유)
    private static readonly string RealtimeFlagPath =
        Path.GetFullPath("realtime_autostart.json");

    public ExperionFastService(
        IServiceScopeFactory scopeFactory,
        ILogger<ExperionFastService> logger,
        IOpcUaConfigProvider configProvider)
    {
        _scopeFactory   = scopeFactory;
        _logger         = logger;
        _configProvider = configProvider;
    }

    // ── IHostedService ────────────────────────────────────────────────────────

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        var stale = (await db.GetFastSessionsAsync())
            .Where(s => s.Status == "Running").ToList();
        foreach (var s in stale)
        {
            _logger.LogWarning("[Fast] 앱 시작 — Running 세션 {Id} Failed 마킹", s.Id);
            await db.UpdateFastSessionStatusAsync(s.Id, "Failed");
        }
        _cts         = new CancellationTokenSource();
        _monitorTask = Task.Run(() => MonitorLoopAsync(_cts.Token), _cts.Token);
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        _cts?.Cancel();
        if (_monitorTask != null)
            await _monitorTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
        foreach (var kvp in _sessions)
            kvp.Value.Cancel = true;
        await Task.Delay(2_000).ConfigureAwait(false);
    }

    public void Dispose() { _cts?.Dispose(); }

    // ── 나머지 메서드는 Step 9~11 에서 추가 ──────────────────────────────────
}

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 8 완료 기록


Step 9 — ExperionFastService.cs IExperionFastService 공개 메서드 구현

파일: src/Infrastructure/OpcUa/ExperionFastService.cs

Dispose() 바로 뒤, // 나머지 메서드 주석을 지우고 아래로 교체한다:

    // ── IExperionFastService ──────────────────────────────────────────────────

    public async Task<FastSessionInfo> StartSessionAsync(FastSessionStartRequest request)
    {
        if (request.TagList.Length == 0 || request.TagList.Length > 8)
            throw new ArgumentException("태그는 1~8개여야 합니다.");
        if (request.SamplingMs is not (100 or 250 or 500 or 1000))
            throw new ArgumentException("샘플링 간격은 100/250/500/1000ms 중 하나여야 합니다.");

        var serverCfg = await ReadServerConfigAsync();
        if (serverCfg == null)
            throw new InvalidOperationException(
                "OPC UA 서버 설정을 찾을 수 없습니다. 실시간 구독을 먼저 시작하세요.");

        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}개입니다.");

        var nodeIds = new Dictionary<string, string>();
        foreach (var tag in request.TagList)
        {
            var nodeId = await db.GetNodeIdByTagNameAsync(tag);
            if (string.IsNullOrEmpty(nodeId))
                throw new ArgumentException($"태그 '{tag}'의 nodeId를 찾을 수 없습니다.");
            nodeIds[tag] = nodeId;
        }

        var session = await db.CreateFastSessionAsync(new FastSessionCreateRequest(
            request.Name, request.SamplingMs, request.DurationSec,
            request.TagList, request.RetentionDays));

        var ctx = new FastSessionContext
        {
            SessionId   = session.Id,
            TagList     = request.TagList,
            NodeIds     = nodeIds,
            SamplingMs  = request.SamplingMs,
            DurationSec = request.DurationSec,
            StartedAt   = DateTime.UtcNow,
        };
        _sessions[session.Id] = ctx;

        try
        {
            await StartSubscriptionAsync(ctx, serverCfg);
            await db.UpdateFastSessionStatusAsync(session.Id, "Running");
            _logger.LogInformation("[Fast] 세션 {Id} 시작 — 태그 {Count}개 {Ms}ms {Sec}s",
                session.Id, request.TagList.Length, request.SamplingMs, request.DurationSec);
        }
        catch (Exception ex)
        {
            _sessions.TryRemove(session.Id, out _);
            await db.UpdateFastSessionStatusAsync(session.Id, "Failed");
            throw new InvalidOperationException($"OPC UA 구독 시작 실패: {ex.Message}", ex);
        }

        var tags = JsonSerializer.Deserialize<string[]>(session.TagList) ?? [];
        return MapToInfo(session, tags);
    }

    public async Task StopSessionAsync(int sessionId)
    {
        if (!_sessions.TryGetValue(sessionId, out var ctx))
            throw new InvalidOperationException($"세션 {sessionId}를 찾을 수 없습니다.");

        ctx.Cancel = true;
        await FlushBufferAsync(ctx);
        await StopSubscriptionAsync(ctx);
        _sessions.TryRemove(sessionId, out _);

        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        await db.UpdateFastSessionStatusAsync(sessionId, "Completed");
        await db.UpdateFastSessionRowCountAsync(sessionId, ctx.TotalRows);
        _logger.LogInformation("[Fast] 세션 {Id} 수동 중지 — 총 {Count}행", sessionId, ctx.TotalRows);
    }

    public async Task DeleteSessionAsync(int sessionId)
    {
        if (_sessions.TryGetValue(sessionId, out var ctx))
        {
            ctx.Cancel = true;
            await StopSubscriptionAsync(ctx);
            _sessions.TryRemove(sessionId, out _);
        }
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        await db.DeleteFastSessionAsync(sessionId);
    }

    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 s = await db.GetFastSessionAsync(sessionId);
        if (s == null) return null;
        var tags = JsonSerializer.Deserialize<string[]>(s.TagList) ?? [];
        return MapToInfo(s, tags);
    }

    public async Task<IEnumerable<FastSessionInfo>> GetSessionsAsync()
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        return (await db.GetFastSessionsAsync()).Select(s =>
        {
            var tags = JsonSerializer.Deserialize<string[]>(s.TagList) ?? [];
            return MapToInfo(s, tags);
        });
    }

    public async Task<FastQueryResult> GetRecordsAsync(int sessionId, DateTime? from, DateTime? to)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        return await db.GetFastRecordsAsync(sessionId, from, to);
    }

    public async Task ExportCsvAsync(int sessionId, Stream stream,
        DateTime? from = null, DateTime? to = null)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        await db.ExportFastRecordsToCsvAsync(sessionId, stream, from, to);
    }

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 9 완료 기록


Step 10 — ExperionFastService.cs OPC UA Subscription + OnNotification

파일: src/Infrastructure/OpcUa/ExperionFastService.cs

Step 9에서 추가한 ExportCsvAsync 바로 뒤에 추가:

핵심 버그 수정: OnNotificatione.NotificationValue 타입은 MonitoredItemNotification 이다. 원본 계획의 DataChangeNotification 은 잘못된 타입이며, 이 오류로 데이터가 수집되지 않는다.

    // ── Private: OPC UA ────────────────────────────────────────────────────────

    private async Task StartSubscriptionAsync(
        FastSessionContext ctx, ExperionServerConfig serverCfg)
    {
        var appConfig = await _configProvider.GetConfigAsync(serverCfg);
        var endpoint  = await SelectEndpointAsync(appConfig, serverCfg.EndpointUrl);
        var identity  = new UserIdentity(
            serverCfg.UserName,
            System.Text.Encoding.UTF8.GetBytes(serverCfg.Password));

        ctx.OpcSession = await new DefaultSessionFactory(null).CreateAsync(
            appConfig, endpoint, false, "ExperionFastSession",
            60_000, identity, null, CancellationToken.None);

        var subscription = new Subscription(ctx.OpcSession.DefaultSubscription)
        {
            PublishingInterval = ctx.SamplingMs,
            KeepAliveCount     = 10,
            LifetimeCount      = 100,
        };

        foreach (var tag in ctx.TagList)
        {
            var nodeId = ctx.NodeIds[tag];
            var item = new MonitoredItem(subscription.DefaultItem)
            {
                StartNodeId      = new NodeId(nodeId),
                AttributeId      = Attributes.Value,
                SamplingInterval = ctx.SamplingMs,
                QueueSize        = 1,
                DiscardOldest    = true,
                DisplayName      = tag
            };
            // [버그 수정] MonitoredItemNotification — DataChangeNotification 아님
            item.Notification += (monItem, e) =>
            {
                if (ctx.Cancel) return;
                if (e.NotificationValue is MonitoredItemNotification notification)
                {
                    ctx.Buffer.Enqueue(new FastRecord
                    {
                        SessionId  = ctx.SessionId,
                        RecordedAt = DateTime.UtcNow,
                        TagName    = monItem.DisplayName,
                        Value      = notification.Value?.Value?.ToString()
                    });
                }
            };
            subscription.AddItem(item);
        }

        await ctx.OpcSession.AddSubscriptionAsync(subscription);
#pragma warning disable CS0618
        subscription.Create();
#pragma warning restore CS0618
        ctx.Subscription = subscription;
    }

    private static async Task<ConfiguredEndpoint> SelectEndpointAsync(
        ApplicationConfiguration appConfig, string endpointUrl)
    {
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        var endpointConfig = EndpointConfiguration.Create(appConfig);
        using var discovery = await DiscoveryClient.CreateAsync(
            appConfig, new Uri(endpointUrl), DiagnosticsMasks.All, cts.Token);
        var endpoints = await discovery.GetEndpointsAsync(null);
        var selected  = endpoints
            .OrderByDescending(e => e.SecurityLevel)
            .FirstOrDefault(e => e.SecurityPolicyUri.Contains("Basic256Sha256"))
            ?? endpoints[0];
        return new ConfiguredEndpoint(null, selected, endpointConfig);
    }

    private static async Task StopSubscriptionAsync(FastSessionContext ctx)
    {
        if (ctx.Subscription != null)
        {
#pragma warning disable CS0618
            ctx.Subscription.Delete(false);
#pragma warning restore CS0618
            ctx.Subscription = null;
        }
        if (ctx.OpcSession != null)
        {
            try { await ctx.OpcSession.CloseAsync(); } catch { }
            try { ctx.OpcSession.Dispose(); }          catch { }
            ctx.OpcSession = null;
        }
    }

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 10 완료 기록


Step 11 — ExperionFastService.cs FlushBuffer + MonitorLoop + 헬퍼

파일: src/Infrastructure/OpcUa/ExperionFastService.cs

Step 10에서 추가한 StopSubscriptionAsync 바로 뒤에 추가:

버그 수정: TotalRows 필드로 누적 관리. 원본 계획의 ctx.Buffer.Count 비교는 드레인 후 항상 0이라 작동하지 않음.

    // ── Private: Flush + Monitor ──────────────────────────────────────────────

    private async Task FlushBufferAsync(FastSessionContext ctx)
    {
        var batch = new List<FastRecord>();
        while (ctx.Buffer.TryDequeue(out var record))
            batch.Add(record);

        if (batch.Count == 0) return;

        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        await db.BatchInsertFastRecordsAsync(batch);

        // [버그 수정] 누적 총 행 수로 관리
        ctx.TotalRows += batch.Count;
        await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows);

        if (ctx.TotalRows >= _maxRowsPerSession)
        {
            _logger.LogWarning("[Fast] 세션 {Id} RowLimit 도달 — 자동 종료", ctx.SessionId);
            ctx.Cancel = true;
            await StopSubscriptionAsync(ctx);
            _sessions.TryRemove(ctx.SessionId, out _);
            await db.UpdateFastSessionStatusAsync(ctx.SessionId, "RowLimitReached");
        }
    }

    private async Task MonitorLoopAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(_flushIntervalMs, ct);

                foreach (var kvp in _sessions.ToArray())
                {
                    var ctx = kvp.Value;
                    if (ctx.Cancel) continue;

                    var elapsed = (DateTime.UtcNow - ctx.StartedAt).TotalSeconds;
                    if (elapsed >= ctx.DurationSec)
                    {
                        _logger.LogInformation("[Fast] 세션 {Id} 기간 만료 — 자동 종료", ctx.SessionId);
                        ctx.Cancel = true;
                        await FlushBufferAsync(ctx);
                        await StopSubscriptionAsync(ctx);
                        _sessions.TryRemove(ctx.SessionId, out _);

                        using var scope = _scopeFactory.CreateScope();
                        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
                        await db.UpdateFastSessionStatusAsync(ctx.SessionId, "Completed");
                        await db.UpdateFastSessionRowCountAsync(ctx.SessionId, ctx.TotalRows);
                        continue;
                    }

                    await FlushBufferAsync(ctx);
                }
            }
            catch (OperationCanceledException) { }
            catch (Exception ex)
            {
                _logger.LogError(ex, "[Fast] 모니터링 루프 오류");
            }
        }
    }

    private static async Task<ExperionServerConfig?> ReadServerConfigAsync()
    {
        if (!File.Exists(RealtimeFlagPath)) return null;
        try
        {
            var json = await File.ReadAllTextAsync(RealtimeFlagPath);
            return JsonSerializer.Deserialize<ExperionServerConfig>(json);
        }
        catch { return null; }
    }

    private static FastSessionInfo MapToInfo(FastSession s, string[] tags)
        => new(s.Id, s.Name, s.StartedAt, s.EndedAt, s.Status,
               s.SamplingMs, s.DurationSec, tags,
               s.RowCount, s.RetentionDays, s.Pinned);

    // ── Inner Class ───────────────────────────────────────────────────────────

    private sealed class FastSessionContext
    {
        public int SessionId { get; init; }
        public string[] TagList { get; init; } = [];
        public Dictionary<string, string> NodeIds { get; init; } = new();
        public int SamplingMs { get; init; }
        public int DurationSec { get; init; }
        public DateTime StartedAt { get; init; }
        public ConcurrentQueue<FastRecord> Buffer { get; } = new();
        public int  TotalRows  { get; set; }
        public bool Cancel     { get; set; }
        public ISession?      OpcSession   { get; set; }
        public Subscription?  Subscription { get; set; }
    }
}

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 11 완료 기록


Step 12 — ExperionFastCleanupService.cs 신규 생성

파일 신규 생성: src/Infrastructure/OpcUa/ExperionFastCleanupService.cs

using ExperionCrawler.Core.Application.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace ExperionCrawler.Infrastructure.OpcUa;

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
            {
                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);
                await Task.Delay(nextRun - now, stoppingToken);

                using var scope = _sp.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
                var expired = (await db.GetExpiredFastSessionsAsync()).ToList();

                foreach (var s in expired)
                {
                    _logger.LogInformation("[FastCleanup] 세션 {Id} ({Name}) 삭제", s.Id, s.Name);
                    await db.DeleteFastSessionAsync(s.Id);
                }

                if (expired.Count > 0)
                    _logger.LogInformation("[FastCleanup] 정리 완료 — {Count}개", expired.Count);
            }
            catch (OperationCanceledException) { }
            catch (Exception ex) { _logger.LogError(ex, "[FastCleanup] 오류"); }
        }
    }
}

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 12 완료 기록


Step 13 — ExperionControllers.cs ExperionFastController 추가

파일: src/Web/Controllers/ExperionControllers.cs

파일 끝에 추가. 모든 Ok(...) 응답은 명시적 camelCase 익명 객체 (PropertyNamingPolicy = null 규칙 준수).

// ── FastTable ─────────────────────────────────────────────────────────────────

[ApiController]
[Route("api/fast")]
public class ExperionFastController : ControllerBase
{
    private readonly IExperionFastService _fastSvc;

    public ExperionFastController(IExperionFastService fastSvc)
        => _fastSvc = fastSvc;

    [HttpPost("start")]
    public async Task<IActionResult> Start([FromBody] FastSessionStartRequest request)
    {
        try
        {
            var s = await _fastSvc.StartSessionAsync(request);
            return Ok(new { id = s.Id, name = s.Name, status = s.Status, startedAt = s.StartedAt });
        }
        catch (ArgumentException ex)      { return BadRequest(new { error = ex.Message }); }
        catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
    }

    [HttpPost("{id:int}/stop")]
    public async Task<IActionResult> Stop(int id)
    {
        try
        {
            await _fastSvc.StopSessionAsync(id);
            return Ok(new { success = true });
        }
        catch (InvalidOperationException ex) { return NotFound(new { error = ex.Message }); }
    }

    [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,
                tagList       = s.TagList,
                rowCount      = s.RowCount,
                startedAt     = s.StartedAt,
                endedAt       = s.EndedAt,
                retentionDays = s.RetentionDays,
                pinned        = s.Pinned
            })
        });
    }

    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetSession(int id)
    {
        var s = await _fastSvc.GetSessionAsync(id);
        if (s == null) return NotFound();
        return Ok(new
        {
            id            = s.Id,
            name          = s.Name,
            status        = s.Status,
            samplingMs    = s.SamplingMs,
            durationSec   = s.DurationSec,
            tagList       = s.TagList,
            rowCount      = s.RowCount,
            startedAt     = s.StartedAt,
            endedAt       = s.EndedAt,
            retentionDays = s.RetentionDays,
            pinned        = s.Pinned
        });
    }

    [HttpGet("{id:int}/records")]
    public async Task<IActionResult> GetRecords(int id,
        [FromQuery] DateTime? from, [FromQuery] DateTime? to)
    {
        var result = await _fastSvc.GetRecordsAsync(id, from, to);
        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
            })
        });
    }

    [HttpGet("{id:int}/csv")]
    public async Task<IActionResult> ExportCsv(int id,
        [FromQuery] DateTime? from, [FromQuery] DateTime? to)
    {
        var ms = new MemoryStream();
        await _fastSvc.ExportCsvAsync(id, ms, from, to);
        ms.Position = 0;
        return File(ms, "text/csv", $"fast-{id}-{DateTime.UtcNow:yyyyMMddHHmm}.csv");
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        await _fastSvc.DeleteSessionAsync(id);
        return Ok(new { success = true });
    }

    [HttpPost("{id:int}/pin")]
    public async Task<IActionResult> Pin(int id, [FromBody] FastPinRequest request)
    {
        await _fastSvc.PinSessionAsync(id, request.Pinned);
        return Ok(new { success = true, pinned = request.Pinned });
    }
}

public record FastPinRequest(bool Pinned);

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 13 완료 기록


Step 14 — Program.cs 서비스 등록

파일: src/Web/Program.cs

기존 OPC 서버 서비스 등록 블록 바로 뒤에 추가.

버그 수정: 원본 계획의 AddSingleton<IExperionFastService, ExperionFastService>()GetRequiredService<ExperionFastService>() 는 콘크리트 타입 미등록으로 에러가 발생한다. 아래 3줄 패턴을 반드시 사용한다.

// ── FastTable Service ─────────────────────────────────────────────────────────
builder.Services.AddSingleton<ExperionFastService>();
builder.Services.AddSingleton<IExperionFastService>(
    sp => sp.GetRequiredService<ExperionFastService>());
builder.Services.AddHostedService(
    sp => sp.GetRequiredService<ExperionFastService>());
builder.Services.AddHostedService<ExperionFastCleanupService>();

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 14 완료 기록


Step 15 — appsettings.json Fast 섹션 추가

파일: src/Web/appsettings.json

기존 JSON 객체 안에 항목 추가 (쉼표 위치 주의):

"Fast": {
  "MaxConcurrentSessions": 3,
  "MaxRowsPerSession": 5000000,
  "FlushIntervalMs": 2000
}

완료 후:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 15 완료 기록


Step 16 — index.html 탭 메뉴 + 패널 + 모달 추가

파일: src/Web/wwwroot/index.html

16-a. 사이드바 메뉴 항목 추가

기존 08 OPC UA 서버 메뉴 항목 바로 아래:

<li><a href="#" data-tab="fast">09 fastRecord</a></li>

16-b. 패널 추가

기존 pane-opcsvr 패널 바로 아래:

<div id="pane-fast" class="tab-pane" style="display:none">
  <h4>fastRecord 수집</h4>

  <div class="fast-toolbar">
    <button class="btn" onclick="fastNewModal()"> 신규 세션</button>
    <button class="btn" onclick="fastSessionsLoad()">↺ 새로고침</button>
  </div>

  <div id="fast-session-list" class="fast-session-list"></div>

  <div id="fast-detail" style="display:none">
    <div class="fast-detail-header">
      <span id="fast-detail-name"></span>
      <span id="fast-detail-status" class="fast-badge"></span>
      <div class="fast-detail-btns">
        <button class="btn btn-sm" id="btn-fast-stop"
                onclick="fastStop()" style="display:none">■ 중지</button>
        <button class="btn btn-sm" id="btn-fast-csv"
                onclick="fastExportCsv()">CSV</button>
        <button class="btn btn-sm" id="btn-fast-pin"
                onclick="fastTogglePin()">📌</button>
        <button class="btn btn-sm btn-danger"
                onclick="fastDelete()">삭제</button>
      </div>
    </div>
    <div class="fast-progress-wrap">
      <div class="fast-progress-bar" id="fast-progress-bar" style="width:0%"></div>
    </div>
    <div class="fast-progress-info">
      <span id="fast-progress-text">0행</span>
      <span id="fast-elapsed-text">경과: 0s</span>
    </div>
    <div id="fast-chart-container" class="fast-chart-container"></div>
  </div>
</div>

<!-- 신규 세션 모달 -->
<div id="modal-fast" class="dt-overlay" style="display:none"
     onclick="if(event.target===this)fastModalClose()">
  <div class="dt-popup fast-modal">
    <div class="fast-modal-title">신규 fastSession</div>
    <label>세션 이름</label>
    <input type="text" id="fast-name" class="fast-input"
           placeholder="예: 공정온도_분석">
    <label>태그 선택 (최대 8개, Ctrl+클릭 다중선택)</label>
    <select id="fast-tag-select" class="fast-input" multiple size="8"></select>
    <div class="fast-row2">
      <div>
        <label>샘플링 (ms)</label>
        <select id="fast-sampling-ms" class="fast-input">
          <option value="100">100ms</option>
          <option value="250">250ms</option>
          <option value="500" selected>500ms</option>
          <option value="1000">1000ms</option>
        </select>
      </div>
      <div>
        <label>수집 기간</label>
        <select id="fast-duration-sec" class="fast-input">
          <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="86400">24시간</option>
        </select>
      </div>
    </div>
    <label>보관 기간 (일, 비우면 무한)</label>
    <input type="number" id="fast-retention" class="fast-input" placeholder="30">
    <div class="fast-modal-footer">
      <button class="btn" onclick="fastModalClose()">취소</button>
      <button class="btn btn-primary" onclick="fastStart()">시작</button>
    </div>
  </div>
</div>

완료 후 브라우저 열어서 09 탭 표시 여부 확인 (빌드 불필요, HTML만 수정) → task_state.mdStep 16 완료 기록


Step 17 — app.js Fast 함수 추가 + 탭 핸들러 연결

파일: src/Web/wwwroot/js/app.js

17-a. 파일 끝에 Fast 함수 추가

버그 수정: uPlot/XLSX 제거. 단순 table 렌더링과 CSV만 사용한다.

// ── fastRecord ────────────────────────────────────────────────────────────────

let _fastCurrentId = null;
let _fastPollTimer = null;

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');
    if (!list) return;
    list.innerHTML = '';
    (data.items || []).forEach(s => {
        const div = document.createElement('div');
        div.className = 'fast-session-item' +
            (s.id === _fastCurrentId ? ' active' : '');
        div.onclick = () => fastSelect(s.id);
        const cls = s.status === 'Running' ? 'fast-badge-run' : 'fast-badge-done';
        div.innerHTML = `
            <div class="fast-si-top">
                <span class="fast-si-name">${s.name}</span>
                <span class="fast-badge ${cls}">${s.status}</span>
                ${s.pinned ? '<span>📌</span>' : ''}
            </div>
            <div class="fast-si-meta">
                ${s.tagCount}개 태그 · ${s.samplingMs}ms · ${_fastFmtDur(s.durationSec)}
            </div>
            <div class="fast-si-meta">${_fastFmtDt(s.startedAt)}</div>`;
        list.appendChild(div);
    });
}

async function fastSelect(id) {
    _fastCurrentId = id;
    fastLivePollStop();
    await fastSessionsLoad();

    const res = await fetch(`/api/fast/${id}`);
    if (!res.ok) return;
    const s = await res.json();

    document.getElementById('fast-detail').style.display = '';
    document.getElementById('fast-detail-name').textContent = s.name;
    const badge = document.getElementById('fast-detail-status');
    badge.textContent = s.status;
    badge.className = 'fast-badge ' +
        (s.status === 'Running' ? 'fast-badge-run' : 'fast-badge-done');
    document.getElementById('btn-fast-stop').style.display =
        s.status === 'Running' ? '' : 'none';
    document.getElementById('btn-fast-pin').textContent =
        s.pinned ? '📌 고정됨' : '📌 고정';

    await fastRenderChart(id);
    if (s.status === 'Running') fastLivePollStart(id);
}

async function fastRenderChart(id) {
    const res = await fetch(`/api/fast/${id}/records`);
    if (!res.ok) return;
    const data = await res.json();
    const container = document.getElementById('fast-chart-container');
    if (!container) return;

    if (!data.items || data.items.length === 0) {
        container.innerHTML =
            '<div class="fast-empty">수집된 데이터가 없습니다.</div>';
        return;
    }

    const latest = {};
    data.items.forEach(r => { latest[r.tagName] = r.value; });

    let html = `<table class="fast-table">
        <thead><tr><th>태그명</th><th>최신값</th></tr></thead><tbody>`;
    data.tagNames.forEach(t => {
        html += `<tr><td>${t}</td><td>${latest[t] ?? '-'}</td></tr>`;
    });
    html += `</tbody></table>`;
    html += `<div class="fast-rec-count">총 ${data.total.toLocaleString()}행</div>`;
    container.innerHTML = html;
}

function fastLivePollStart(id) {
    if (_fastPollTimer) return;
    _fastPollTimer = setInterval(async () => {
        if (!_fastCurrentId) { fastLivePollStop(); return; }
        const res = await fetch(`/api/fast/${_fastCurrentId}`);
        if (!res.ok) { fastLivePollStop(); return; }
        const s = await res.json();

        if (s.status !== 'Running') {
            fastLivePollStop();
            await fastSelect(_fastCurrentId);
            return;
        }
        const elapsed = Math.floor(
            (Date.now() - new Date(s.startedAt).getTime()) / 1000);
        const pct = Math.min(elapsed / s.durationSec * 100, 100).toFixed(1);
        const bar = document.getElementById('fast-progress-bar');
        if (bar) bar.style.width = pct + '%';
        const pt = document.getElementById('fast-progress-text');
        if (pt) pt.textContent = `${s.rowCount.toLocaleString()}행`;
        const et = document.getElementById('fast-elapsed-text');
        if (et) et.textContent = `경과: ${_fastFmtDur(elapsed)}`;
        await fastRenderChart(_fastCurrentId);
    }, 3000);
}

function fastLivePollStop() {
    if (_fastPollTimer) { clearInterval(_fastPollTimer); _fastPollTimer = null; }
}

async function fastNewModal() {
    const res = await fetch('/api/realtime/points');
    const select = document.getElementById('fast-tag-select');
    select.innerHTML = '';
    if (res.ok) {
        const data = await res.json();
        (data.items || []).forEach(p => {
            const opt = document.createElement('option');
            opt.value = p.tagName;
            opt.textContent = p.tagName;
            select.appendChild(opt);
        });
    }
    document.getElementById('fast-name').value = '';
    document.getElementById('modal-fast').style.display = '';
}

function fastModalClose() {
    document.getElementById('modal-fast').style.display = 'none';
}

async function fastStart() {
    const name = document.getElementById('fast-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('태그를 하나 이상 선택하세요.'); 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 retRaw      = document.getElementById('fast-retention').value.trim();

    const res = await fetch('/api/fast/start', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            Name: name, SamplingMs: samplingMs, DurationSec: durationSec,
            TagList: tags, RetentionDays: retRaw ? parseInt(retRaw) : null
        })
    });
    if (!res.ok) {
        const err = await res.json();
        alert('오류: ' + (err.error || '알 수 없는 오류'));
        return;
    }
    const data = await res.json();
    fastModalClose();
    await fastSessionsLoad();
    await fastSelect(data.id);
}

async function fastStop() {
    if (!_fastCurrentId) return;
    const res = await fetch(`/api/fast/${_fastCurrentId}/stop`, { method: 'POST' });
    if (!res.ok) { alert('중지 실패'); return; }
    fastLivePollStop();
    await fastSessionsLoad();
    await fastSelect(_fastCurrentId);
}

async function fastDelete() {
    if (!_fastCurrentId || !confirm('세션을 삭제하시겠습니까?')) return;
    await fetch(`/api/fast/${_fastCurrentId}`, { method: 'DELETE' });
    _fastCurrentId = null;
    fastLivePollStop();
    document.getElementById('fast-detail').style.display = 'none';
    await fastSessionsLoad();
}

async function fastTogglePin() {
    if (!_fastCurrentId) return;
    const res = await fetch(`/api/fast/${_fastCurrentId}`);
    if (!res.ok) return;
    const s = await res.json();
    await fetch(`/api/fast/${_fastCurrentId}/pin`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ Pinned: !s.pinned })
    });
    await fastSelect(_fastCurrentId);
}

async function fastExportCsv() {
    if (!_fastCurrentId) return;
    const res = await fetch(`/api/fast/${_fastCurrentId}/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-${_fastCurrentId}-${new Date().toISOString().slice(0,10)}.csv`;
    a.click();
    URL.revokeObjectURL(url);
}

function _fastFmtDur(sec) {
    if (sec < 60)   return sec + 's';
    if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
    return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
}

function _fastFmtDt(dt) {
    return new Date(dt).toLocaleString('ko-KR');
}

17-b. 탭 핸들러에 조건 추가

기존 showTab 함수(또는 data-tab 처리 구간)에서 탭 전환 조건 블록 안에 추가:

if (tab === 'fast') fastSessionsLoad();

완료 후task_state.mdStep 17 완료 기록


Step 18 — style.css Fast 전용 스타일 추가

파일: src/Web/wwwroot/css/style.css

파일 끝에 추가:

/* ── fastRecord ───────────────────────────────────────────────────────────── */
.fast-toolbar        { display:flex; gap:8px; margin-bottom:12px; }
.fast-session-list   { display:flex; flex-direction:column; gap:6px; margin-bottom:16px; }
.fast-session-item   { background:#1e1e1e; border:1px solid #333; border-radius:6px;
                       padding:10px 14px; cursor:pointer; }
.fast-session-item:hover,
.fast-session-item.active { border-color:#f59e0b; }
.fast-si-top         { display:flex; align-items:center; gap:8px; margin-bottom:4px; }
.fast-si-name        { font-weight:600; }
.fast-si-meta        { font-size:0.8rem; color:#888; }
.fast-badge          { font-size:0.75rem; padding:2px 8px; border-radius:10px; }
.fast-badge-run      { background:#166534; color:#86efac; }
.fast-badge-done     { background:#1e3a5f; color:#93c5fd; }
.fast-detail-header  { display:flex; align-items:center; gap:10px;
                       margin-bottom:10px; flex-wrap:wrap; }
.fast-detail-btns    { display:flex; gap:6px; margin-left:auto; flex-wrap:wrap; }
.fast-progress-wrap  { background:#333; border-radius:4px; height:12px;
                       margin-bottom:4px; overflow:hidden; }
.fast-progress-bar   { height:100%; background:#f59e0b; transition:width 0.5s; }
.fast-progress-info  { display:flex; justify-content:space-between;
                       font-size:0.8rem; color:#888; margin-bottom:12px; }
.fast-chart-container{ min-height:80px; }
.fast-table          { width:100%; border-collapse:collapse; font-size:0.9rem; }
.fast-table th,
.fast-table td       { border:1px solid #333; padding:6px 10px; }
.fast-table th       { background:#1e1e1e; }
.fast-rec-count      { margin-top:8px; font-size:0.8rem; color:#888; text-align:right; }
.fast-empty          { color:#666; padding:24px; text-align:center; }
.fast-modal          { background:#1a1a1a; border-radius:8px; padding:24px;
                       width:480px; max-width:95vw; }
.fast-modal-title    { font-size:1.1rem; font-weight:600; margin-bottom:16px; }
.fast-input          { width:100%; background:#111; border:1px solid #444; color:#eee;
                       border-radius:4px; padding:6px 8px; margin-bottom:10px;
                       box-sizing:border-box; }
.fast-row2           { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.fast-modal-footer   { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }

최종 빌드 검증:

dotnet build src/Web/ExperionCrawler.csproj --no-restore -v q

→ 에러 0건 확인 → task_state.mdStep 18 완료전체 작업 완료 기록


전체 완료 체크리스트

Step 파일 작업 내용
1 ExperionEntities.cs FastSession, FastRecord 엔티티 추가
2 IExperionServices.cs IExperionDbService Fast 메서드 추가
3 IExperionServices.cs IExperionFastService + DTOs 추가
4 ExperionDbContext.cs DbSet + OnModelCreating 추가
5 ExperionDbContext.cs InitializeAsync DDL 추가 (TEXT, not JSONB)
6 ExperionDbContext.cs Fast DB 메서드 (Create/Update/Get)
7 ExperionDbContext.cs Fast DB 메서드 (Insert/Export/Expired)
8 ExperionFastService.cs 신규 생성 — 골격 + IHostedService
9 ExperionFastService.cs IExperionFastService 공개 메서드
10 ExperionFastService.cs OPC UA Subscription + OnNotification (버그 수정)
11 ExperionFastService.cs FlushBuffer + MonitorLoop + 헬퍼 (TotalRows 누적)
12 ExperionFastCleanupService.cs 신규 생성
13 ExperionControllers.cs ExperionFastController (camelCase 응답)
14 Program.cs 올바른 DI 패턴 등록
15 appsettings.json Fast 섹션 추가
16 index.html 09 탭 + 패널 + 모달
17 app.js Fast 함수 + 탭 핸들러 연결
18 style.css Fast 전용 스타일