Files
ExperionCrawler/plans/이벤트-히스토리-테이블-코딩플랜.md

42 KiB

이벤트 기반 디지털 포인트 히스토리 — 상세 코딩 플랜

E2E 진단 수정 기록 (2026-05-11)

# 심각도 문제 위치 수정 내용
A 🔴 HIGH GetAreaByTagNameAsync에서 tagName(FIC101.instate0)을 base_tag로 그대로 조회 → tag_metadata.base_tag. 이전 기본 태그명(FIC101)만 저장하므로 area가 항상 null 반환 ExperionDbContext.cs:1228 tagName.Contains('.') 체크 후 tagName[..tagName.LastIndexOf('.')]로 baseTag 추출, baseTag 기준 조회로 변경
B 🟠 MED 디지털 태그 캐시 필드가 인스턴스 레벨(instance field) → ExperionDbServiceScoped 등록이므로 스코프마다 새 인스턴스 생성, 캐시 TTL 300초가 실질적으로 무효 ExperionDbContext.cs:193-196 캐시 필드 4개 모두 static으로 승격 (_digitalTagCache, _digitalTagCacheTime, _cacheRefreshTask, _digitalCacheLock) — 프로세스 수명 동안 캐시 유지
C 🟠 MED GetDigitalTagNamesCachedAsync의 lock 블록에서 _cacheRefreshTask = null 즉시 초기화 → 동시 호출 시 두 번째 호출자가 기존 Task를 재사용하지 못하고 별도 Refresh 시작 ExperionDbContext.cs:1323-1335 lock 내에서는 Task 할당/참조만, Task 완료 후 별도 lock에서 null 초기화 — 동시 호출자 모두 동일 Task await
D 🟡 LOW DigitalEventDetectorService.DetectAndRecordChangesAsync가 매 1초마다 GetDigitalPointsAsync() 호출 → 내부에서 GetDigitalTagNamesAsync()(tag_metadata 스캔)를 불필요하게 재실행, DB 쿼리 2회/초 DigitalEventDetectorService.cs:105 서비스 레벨(Singleton)에 _knownDigitalTags + _lastTagRefresh 캐시 추가, 5분마다만 GetDigitalTagNamesAsync() 호출, 평상시 GetRealtimeRecordsByTagNamesAsync(_knownDigitalTags)로 직접 조회 → DB 쿼리 1회/초로 절감
E 🟡 LOW DDL curr_value TEXTNOT NULL 제약 없음 → Entity string CurrValue = string.Empty(non-nullable)와 스키마 불일치, 수동 NULL 삽입 시 런타임 오류 가능 ExperionDbContext.cs:354 DDL을 curr_value TEXT NOT NULL DEFAULT ''로 변경
F 🟡 LOW BuildMetadatatagName.Contains("-il-") 등이 대소문자 구분 → Experion 태그명이 대문자인 경우 메타데이터 생성 누락 DigitalEventDetectorService.cs:198-199 StringComparison.OrdinalIgnoreCase 추가

빌드 검증 (E2E 수정 후)

  • dotnet build src/Web/ExperionCrawler.csproj성공 (0 Warning, 0 Error)
  • 수정 일시: 2026-05-11

초기 구현 진단 및 수정 기록 (2026-05-11)

# 심각도 문제 근거 수정 내용
1 🟠 MED Query/Summary 엔드포인트의 from/to 파라미터가 DateTime (nullable 아님) → 클라이언트가 생략 시 DateTime.MinValue(0001-01-01)로 바인딩되어 전체 데이터 조회 ExperionControllers.cs:1183-1184, 1218-1219 [FromQuery] DateTime? from, [FromQuery] DateTime? to로 변경, 생략 시 DateTime.UtcNow.AddDays(-1) ~ DateTime.UtcNow 기본값 적용
2 🟡 LOW RecordDigitalEventAsync가 개별 이벤트마다 SaveChangesAsync 호출 → 1초 주기 내 여러 태그 변경 시 순차적 DB round-trip 발생 ExperionDbContext.cs:1257-1258 BatchRecordDigitalEventsAsync(IEnumerable<DigitalEventRecord>) 추가, DigitalEventDetectorService에서 1초 주기 내 모든 이벤트를 수집 후 AddRangeAsync → 단일 SaveChangesAsync로 일괄 저장
3 🟡 LOW _cacheRefreshTask == null 체크와 할당이 atomic 아님 → 동시 요청 시 두 태스크가 동시에 캐시 리프레시 실행 가능 ExperionDbContext.cs:1303-1311 (수정 전) lock (this) 블록으로 _cacheRefreshTask 할당 및 할당 해제 로직 보호

빌드 검증 (초기 구현)

  • dotnet build src/Web/ExperionCrawler.csproj성공 (0 Warning, 0 Error)
  • 수정 일시: 2026-05-11

0. 전제 조건 및 기존 코드 매핑

항목 파일 라인 비고
IExperionDbService 인터페이스 src/Core/Application/Interfaces/IExperionServices.cs 53-122 여기에 새 메서드 추가
ExperionDbService 구현체 src/Infrastructure/Database/ExperionDbContext.cs 174-1493 DbSet, DDL, 구현 추가
SnapshotToHistoryAsync src/Infrastructure/Database/ExperionDbContext.cs 730-748 디지털 제외 로직 추가
ExperionHistoryService src/Infrastructure/OpcUa/ExperionHistoryService.cs 1-63 60초 주기 스냅샷 호출
Entity 클래스 src/Core/Domain/Entities/ExperionEntities.cs 1-150 EventHistoryRecord 추가
HostedService 등록 src/Web/Program.cs 1-158 새 서비스 등록
Controller src/Web/Controllers/ExperionControllers.cs 1-1158 새 API 엔드포인트 추가
DB 초기화 DDL src/Infrastructure/Database/ExperionDbContext.cs 186-341 InitializeAsync() 내부

1. 구현 순서 (Dependency Graph)

Step 1: Entity + DDL
   ↓
Step 2: 인터페이스 확장 (IExperionDbService)
   ↓
Step 3: DbContext 구현 (디지털 식별 + 이벤트 기록)
   ↓
Step 4: SnapshotToHistoryAsync 수정 (디지털 제외)
   ↓
Step 5: DigitalEventDetectorService 구현
   ↓
Step 6: Program.cs 등록
   ↓
Step 7: Controller + API 엔드포인트
   ↓
Step 8: 검증 + 테스트

2. Step-by-Step 코딩 계획

Step 1: EventHistoryRecord Entity + DDL 생성

1-1. Entity 클래스 추가

파일: src/Core/Domain/Entities/ExperionEntities.cs 위치: 파일 말미 (line 150 이후)

/// <summary>event_history_table — 디지털 포인트 상태 변경 이벤트</summary>
[Table("event_history_table")]
public class EventHistoryRecord
{
    [Key]
    [Column("id")]              public long      Id              { get; set; }
    [Column("tagname")]         public string    TagName         { get; set; } = string.Empty;
    [Column("node_id")]         public string    NodeId          { get; set; } = string.Empty;
    [Column("prev_value")]      public string?   PrevValue       { get; set; }
    [Column("curr_value")]      public string    CurrValue       { get; set; } = string.Empty;
    [Column("event_type")]      public string    EventType       { get; set; } = string.Empty;
    [Column("event_time")]      public DateTime  EventTime       { get; set; } = DateTime.UtcNow;
    [Column("area")]            public string?   Area            { get; set; }
    [Column("section")]         public string?   Section         { get; set; }
    [Column("duration_seconds")] public int?     DurationSeconds { get; set; }
    [Column("metadata")]        public string?   Metadata        { get; set; }
    [Column("created_at")]      public DateTime  CreatedAt       { get; set; } = DateTime.UtcNow;
}

검증 체크리스트:

  • Idlong (BIGSERIAL 매칭)인지 확인
  • 모든 Column 속성이 SQL 컬럼명과 정확히 일치하는지 확인
  • using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; import가 파일 상단에 있는지 확인

1-2. DbSet 추가

파일: src/Infrastructure/Database/ExperionDbContext.cs 위치: 기존 DbSet 정의 영역 (line 18-30)

public DbSet<EventHistoryRecord> EventHistoryRecords => Set<EventHistoryRecord>();

검증 체크리스트:

  • 기존 DbSet 패턴과 동일한 형식인지 확인

1-3. DDL 추가 (InitializeAsync 내부)

파일: src/Infrastructure/Database/ExperionDbContext.cs 위치: InitializeAsync() 메서드 내, 기존 테이블 생성 SQL 뒤 (line 340 근처)

await _db.ExecuteSqlAsync(@"
    CREATE TABLE IF NOT EXISTS event_history_table (
        id BIGSERIAL PRIMARY KEY,
        tagname TEXT NOT NULL,
        node_id TEXT NOT NULL,
        prev_value TEXT,
        curr_value TEXT,
        event_type TEXT NOT NULL,
        event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        area TEXT,
        section TEXT,
        duration_seconds INT,
        metadata JSONB,
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    );

    CREATE INDEX IF NOT EXISTS idx_event_history_tagname_time
        ON event_history_table(tagname, event_time DESC);
    CREATE INDEX IF NOT EXISTS idx_event_history_area_time
        ON event_history_table(area, event_time DESC);
    CREATE INDEX IF NOT EXISTS idx_event_history_section_time
        ON event_history_table(section, event_time DESC);
    CREATE INDEX IF NOT EXISTS idx_event_history_event_type
        ON event_history_table(event_type, event_time DESC);
    CREATE INDEX IF NOT EXISTS idx_event_history_tagname_event_type
        ON event_history_table(tagname, event_type, event_time DESC);
");

검증 체크리스트:

  • IF NOT EXISTS로 기존 데이터베이스에 영향 없는지 확인
  • metadata 컬럼이 JSONB 타입인지 확인 (JSON 문자열이 아닌 JSONB)
  • Entity의 Metadata 속성이 string?인데 JSONB로 저장되는지 — Npgsql은 string → JSONB 자동 변환 지원 확인

Step 2: IExperionDbService 인터페이스 확장

파일: src/Core/Application/Interfaces/IExperionServices.cs 위치: IExperionDbService 인터페이스 내 (line 53-122), GetNodeIdByTagNameAsync() (line 121) 뒤

/// <summary>디지털 태그 이름 목록 조회 (value 패턴 또는 tag_metadata 기반)</summary>
Task<IEnumerable<string>> GetDigitalTagNamesAsync();

/// <summary>디지털 포인트 현재 값 조회</summary>
Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync();

/// <summary>디지털 이벤트 기록</summary>
Task<int> RecordDigitalEventAsync(DigitalEventRecord record);

/// <summary>태그명으로 area 조회 (tag_metadata 기반)</summary>
Task<string?> GetAreaByTagNameAsync(string tagName);

/// <summary>이벤트 히스토리 조회</summary>
Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
    string? tagName, string? area, string? section,
    string? eventType, DateTime from, DateTime to, int limit = 500);

2-1. DigitalEventRecord DTO 추가

파일: src/Core/Application/Interfaces/IExperionServices.cs 위치: 파일 말미 (기존 result record 클래스들 뒤, line 322 이후)

/// <summary>디지털 이벤트 기록용 서비스 계층 DTO</summary>
public class DigitalEventRecord
{
    public string TagName { get; set; } = "";
    public string NodeId { get; set; } = "";
    public string? PrevValue { get; set; }
    public string CurrValue { get; set; } = "";
    public string EventType { get; set; } = "";
    public DateTime EventTime { get; set; }
    public int? DurationSeconds { get; set; }
    public string? Area { get; set; }
    public string? Section { get; set; }
    public string? Metadata { get; set; }
}

/// <summary>이벤트 히스토리 조회 결과 행</summary>
public class EventHistoryRow
{
    public long Id { get; set; }
    public string TagName { get; set; } = "";
    public string NodeId { get; set; } = "";
    public string? PrevValue { get; set; }
    public string CurrValue { get; set; } = "";
    public string EventType { get; set; } = "";
    public DateTime EventTime { get; set; }
    public string? Area { get; set; }
    public string? Section { get; set; }
    public int? DurationSeconds { get; set; }
    public string? Metadata { get; set; }
}

검증 체크리스트:

  • 인터페이스에 추가한 메서드 시그니처가 구현체와 정확히 일치하는지 확인
  • EventHistoryRow가 controller에서 camelCase anonymous object로 변환될 것임을 명시 (AGENTS.md 규칙)

Step 3: DbContext 구현

파일: src/Infrastructure/Database/ExperionDbContext.cs 위치: ExperionDbService 클래스 내, SnapshotToHistoryAsync() (line 730) 바로 앞

3-1. 디지털 태그 식별 로직

public async Task<IEnumerable<string>> GetDigitalTagNamesAsync()
{
    // tag_metadata에서 data_type='i=7594'인 태그를 우선 조회
    // fallback: realtime_table에서 value가 '{'로 시작하는 태그
    var fromMetadata = await _ctx.TagMetadata
        .Where(m => m.Value == "i=7594")
        .Select(m => m.BaseTag)
        .Distinct()
        .ToListAsync();

    if (fromMetadata.Any())
        return fromMetadata;

    // Fallback: realtime_table의 LiveValue 패턴으로 판단
    return await _ctx.RealtimePoints
        .Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{"))
        .Select(p => p.TagName)
        .Distinct()
        .ToListAsync();
}

검증 체크리스트:

  • tag_metadata에 data_type 정보가 있는지 확인 (현재 tag_metadata 구조에서 Attribute/Value 매핑 확인)
  • Fallback 로직이 tag_metadata가 비어있을 때도 동작하는지 확인
  • Distinct()로 중복 제거하는지 확인

3-2. 디지털 포인트 조회

public async Task<IEnumerable<RealtimePoint>> GetDigitalPointsAsync()
{
    var digitalTagNames = await GetDigitalTagNamesAsync();
    var tagSet = new HashSet<string>(digitalTagNames);

    if (tagSet.Count == 0)
        return Enumerable.Empty<RealtimePoint>();

    return await _ctx.RealtimePoints
        .Where(p => tagSet.Contains(p.TagName))
        .ToListAsync();
}

검증 체크리스트:

  • GetDigitalTagNamesAsync 결과를 HashSet으로 변환하여 O(1) lookup하는지 확인
  • tagSet이 비어있을 때 빈 결과 반환 (null 방지)

3-3. area 조회

public async Task<string?> GetAreaByTagNameAsync(string tagName)
{
    // tag_metadata에서 base_tag=tagName, attribute='area' 조회
    // value = "{12 | P6 | }" → "P6" 파싱
    var meta = await _ctx.TagMetadata
        .Where(m => m.BaseTag == tagName && m.Attribute == "area")
        .Select(m => m.Value)
        .FirstOrDefaultAsync();

    if (string.IsNullOrEmpty(meta)) return null;

    // "{12 | P6 | }" 패턴에서 area 코드 추출
    var match = System.Text.RegularExpressions.Regex.Match(meta, @"{\s*\d+\s*\|\s*(\w+)\s*\|");
    return match.Success ? match.Groups[1].Value : null;
}

검증 체크리스트:

  • tag_metadata 테이블에 area attribute가 실제로 존재하는지 DB에서 확인
  • 정규식이 {12 | P6 | } 형태에서 정확히 "P6"를 추출하는지 테스트

3-4. 이벤트 기록

public async Task<int> RecordDigitalEventAsync(DigitalEventRecord record)
{
    var row = new EventHistoryRecord
    {
        TagName         = record.TagName,
        NodeId          = record.NodeId,
        PrevValue       = record.PrevValue,
        CurrValue       = record.CurrValue,
        EventType       = record.EventType,
        EventTime       = record.EventTime,
        DurationSeconds = record.DurationSeconds,
        Area            = record.Area,
        Section         = record.Section,
        Metadata        = record.Metadata
    };

    await _ctx.EventHistoryRecords.AddAsync(row);
    return await _ctx.SaveChangesAsync();
}

검증 체크리스트:

  • Metadata가 string → JSONB로 자동 변환되는지 확인 (Npgsql 설정)
  • SaveChangesAsync 호출 후 반환 값이 실제 저장된 행 수인지 확인

3-5. 이벤트 히스토리 조회

public async Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
    string? tagName, string? area, string? section,
    string? eventType, DateTime from, DateTime to, int limit = 500)
{
    var query = _ctx.EventHistoryRecords
        .Where(r => r.EventTime >= from && r.EventTime <= to);

    if (!string.IsNullOrEmpty(tagName))
        query = query.Where(r => r.TagName == tagName);
    if (!string.IsNullOrEmpty(area))
        query = query.Where(r => r.Area == area);
    if (!string.IsNullOrEmpty(section))
        query = query.Where(r => r.Section == section);
    if (!string.IsNullOrEmpty(eventType))
        query = query.Where(r => r.EventType == eventType);

    var records = await query
        .OrderByDescending(r => r.EventTime)
        .Take(limit)
        .ToListAsync();

    return records.Select(r => new EventHistoryRow
    {
        Id = r.Id,
        TagName = r.TagName,
        NodeId = r.NodeId,
        PrevValue = r.PrevValue,
        CurrValue = r.CurrValue,
        EventType = r.EventType,
        EventTime = r.EventTime,
        Area = r.Area,
        Section = r.Section,
        DurationSeconds = r.DurationSeconds,
        Metadata = r.Metadata
    });
}

검증 체크리스트:

  • 모든 필터가 nullable 체크 후 Where 적용하는지 확인
  • OrderByDescending이 인덱스(idx_event_history_tagname_time)를 활용하는지 확인
  • limit 기본값이 500으로 합리적인지 확인

Step 4: SnapshotToHistoryAsync 수정 (디지털 제외)

파일: src/Infrastructure/Database/ExperionDbContext.cs 위치: line 730-748

변경 전:

public async Task<int> SnapshotToHistoryAsync()
{
    var now    = DateTime.UtcNow;
    var points = await _ctx.RealtimePoints.ToListAsync();
    // ... 전체 포인트 스냅샷
}

변경 후:

public async Task<int> SnapshotToHistoryAsync(bool includeDigital = false)
{
    var now = DateTime.UtcNow;
    var query = _ctx.RealtimePoints.AsQueryable();

    if (!includeDigital)
    {
        var digitalTagNames = GetCachedDigitalTagNames();
        if (digitalTagNames.Count > 0)
        {
            query = query.Where(p => !digitalTagNames.Contains(p.TagName));
        }
    }

    var points = await query.ToListAsync();
    if (points.Count == 0) return 0;

    var rows = points.Select(p => new HistoryRecord
    {
        TagName    = p.TagName,
        NodeId     = p.NodeId,
        Value      = p.LiveValue,
        RecordedAt = now
    }).ToList();

    await _ctx.HistoryRecords.AddRangeAsync(rows);
    var saved = await _ctx.SaveChangesAsync();
    return saved;
}

디지털 태그 캐시 (매 60초마다 조회하는 것을 방지)

ExperionDbService 클래스 레벨에 추가:

private HashSet<string> _digitalTagCache = new();
private DateTime _digitalTagCacheTime = DateTime.MinValue;
private readonly object _cacheLock = new();
private const int DigitalTagCacheTtlSeconds = 300; // 5분 TTL
private HashSet<string> GetCachedDigitalTagNames()
{
    lock (_cacheLock)
    {
        if ((DateTime.UtcNow - _digitalTagCacheTime).TotalSeconds < DigitalTagCacheCacheTtlSeconds)
            return _digitalTagCache;
    }

    // async 메서드 내에서 lock 후 async 호출은 데드락 위험 → 
    // 대신 백그라운드 갱신 패턴 사용
    var tags = GetDigitalTagNamesAsync().GetAwaiter().GetResult();
    
    lock (_cacheLock)
    {
        _digitalTagCache = new HashSet<string>(tags);
        _digitalTagCacheTime = DateTime.UtcNow;
    }
    return _digitalTagCache;
}

**⚠️ 진단 체크리스트 교차 검증 **(STEP 6)

  • Q2: GetAwaiter().GetResult() 사용 — 하지만 이는 scoped 메서드 내에서 한 번만 호출되며 캐싱되므로 실제 블로킹 문제는 없음 (STEP 5의 async blocking 체크와 비교)
  • Q3: lock + async 패턴 — 캐시 갱신 시 데드락 가능성 있음. 수정 필요: async/await 전용 패턴으로 변경

수정된 캐시 패턴 (async-safe):

private HashSet<string> _digitalTagCache = new();
private DateTime _digitalTagCacheTime = DateTime.MinValue;
private Task? _cacheRefreshTask = null;

private async Task<HashSet<string>> GetDigitalTagNamesCachedAsync()
{
    if ((DateTime.UtcNow - _digitalTagCacheTime).TotalSeconds < DigitalTagCacheTtlSeconds)
        return _digitalTagCache;

    if (_cacheRefreshTask == null)
    {
        _cacheRefreshTask = RefreshDigitalTagCacheAsync();
    }
    else
    {
        var existing = _cacheRefreshTask;
        _cacheRefreshTask = null;
        await existing;
    }

    return _digitalTagCache;
}

private async Task RefreshDigitalTagCacheAsync()
{
    var tags = await GetDigitalTagNamesAsync();
    _digitalTagCache = new HashSet<string>(tags);
    _digitalTagCacheTime = DateTime.UtcNow;
}

그리고 SnapshotToHistoryAsync에서:

public async Task<int> SnapshotToHistoryAsync(bool includeDigital = false)
{
    var now = DateTime.UtcNow;
    var query = _ctx.RealtimePoints.AsQueryable();

    if (!includeDigital)
    {
        var digitalTagNames = await GetDigitalTagNamesCachedAsync();
        if (digitalTagNames.Count > 0)
        {
            query = query.Where(p => !digitalTagNames.Contains(p.TagName));
        }
    }

    var points = await query.ToListAsync();
    if (points.Count == 0) return 0;

    var rows = points.Select(p => new HistoryRecord
    {
        TagName    = p.TagName,
        NodeId     = p.NodeId,
        Value      = p.LiveValue,
        RecordedAt = now
    }).ToList();

    await _ctx.HistoryRecords.AddRangeAsync(rows);
    var saved = await _ctx.SaveChangesAsync();
    return saved;
}

검증 체크리스트:

  • 기존 ExperionHistoryService에서 SnapshotToHistoryAsync() 호출 시 기본값(includeDigital=false)으로 동작하는지 확인
  • 인터페이스 시그니처 변경으로 인한 breaking change 없는지 확인 (기본 파라미터 사용)
  • 캐시 TTL(5분)이 디지털 태그 변경 주기에 적합한지 확인

Step 5: DigitalEventDetectorService 구현

파일: src/Infrastructure/OpcUa/DigitalEventDetectorService.cs (새 파일)

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Text.Json;

namespace ExperionCrawler.Infrastructure.OpcUa;

/// <summary>
/// 디지털 포인트의 상태 변경을 감지하여 event_history_table에 기록하는 BackgroundService.
/// 1초 간격으로 realtime_table을 검사하여 변경 사항을 기록.
/// </summary>
public class DigitalEventDetectorService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<DigitalEventDetectorService> _logger;
    private readonly ConcurrentDictionary<string, DigitalPointState> _previousStates = new();
    private readonly ConcurrentDictionary<string, string> _areaCache = new();
    private readonly int _checkIntervalMs = 1000;
    private readonly int _debounceSeconds = 5;

    private readonly JsonSerializerOptions _jsonOptions = new()
    {
        PropertyNamingPolicy = null  // PascalCase 유지 (JSONB 저장용)
    };

    private record DigitalPointState(string Value, DateTime Timestamp, string? EventType);

    public DigitalEventDetectorService(
        IServiceScopeFactory scopeFactory,
        ILogger<DigitalEventDetectorService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("[DigitalEventDetector] 시작 — 감지 간격: {Interval}ms", _checkIntervalMs);

        try
        {
            await LoadDigitalTagNamesAsync(stoppingToken);
            await LoadCurrentStatesAsync(stoppingToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[DigitalEventDetector] 초기화 실패 — 서비스 계속 실행 (감지 루프에서 재시도)");
        }

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(_checkIntervalMs, stoppingToken);
                await DetectAndRecordChangesAsync(stoppingToken);
            }
            catch (OperationCanceledException) { break; }
            catch (Exception ex)
            {
                _logger.LogError(ex, "[DigitalEventDetector] 감지 루프 오류");
            }
        }

        _logger.LogInformation("[DigitalEventDetector] 종료");
    }

    private async Task LoadDigitalTagNamesAsync(CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();

        var digitalTags = await db.GetDigitalTagNamesAsync();
        foreach (var tag in digitalTags)
        {
            // 이전 상태 초기화 (존재하지 않는 태그만)
            if (!_previousStates.ContainsKey(tag))
            {
                _previousStates.TryAdd(tag, null!);
            }
        }
        _logger.LogInformation("[DigitalEventDetector] 디지털 태그 {Count}개 로드됨", digitalTags.Count());
    }

    private async Task LoadCurrentStatesAsync(CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();
        
        var points = await db.GetDigitalPointsAsync();
        foreach (var p in points)
        {
            _previousStates[p.TagName] = new DigitalPointState(p.LiveValue ?? "", DateTime.UtcNow, null);
        }
        _logger.LogInformation("[DigitalEventDetector] 현재 상태 {Count}개 로드", _previousStates.Count);
    }

    private async Task DetectAndRecordChangesAsync(CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<IExperionDbService>();

        var currentPoints = await db.GetDigitalPointsAsync();

        foreach (var point in currentPoints)
        {
            if (ct.IsCancellationRequested) break;

            var tagName = point.TagName;
            var currValue = point.LiveValue ?? "";

            var prevState = _previousStates.GetValueOrDefault(tagName);

            // 첫 등장 시 이전 값 초기화 (이벤트 기록 안 함)
            if (prevState == null)
            {
                _previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, null);
                continue;
            }

            // 값 변경 감지
            if (prevState.Value != currValue)
            {
                var eventType = DetermineEventType(prevState.Value, currValue);
                var now = DateTime.UtcNow;
                var elapsed = (now - prevState.Timestamp).TotalSeconds;

                // Debounce: 동일 event_type + 동일 값으로의 짧은 시간 내 반복 방지
                // 상태 전환(TRIP→RUN 등)은 항상 기록
                if (prevState.EventType == eventType && elapsed < _debounceSeconds)
                {
                    _previousStates[tagName] = new DigitalPointState(currValue, now, eventType);
                    continue;
                }

                var duration = (int)elapsed;
                var area = await GetAreaAsync(db, tagName);
                var section = ExtractSection(tagName);

                await db.RecordDigitalEventAsync(new DigitalEventRecord
                {
                    TagName = tagName,
                    NodeId = point.NodeId,
                    PrevValue = prevState.Value,
                    CurrValue = currValue,
                    EventType = eventType,
                    EventTime = now,
                    DurationSeconds = duration,
                    Area = area,
                    Section = section,
                    Metadata = BuildMetadata(tagName, eventType, currValue)
                });

                _logger.LogDebug("[DigitalEventDetector] {Tag}: {Event} ({Prev} → {Curr}, {Duration}s)",
                    tagName, eventType, prevState.Value, currValue, duration);

                _previousStates[tagName] = new DigitalPointState(currValue, now, eventType);
            }
        }
    }

    private string DetermineEventType(string prevValue, string currValue)
    {
        if (currValue.Contains("L-STOP") || currValue.Contains("STOP") || currValue.Contains("TRIP"))
            return "TRIP";
        if (currValue.Contains("RUN") || currValue.Contains("START"))
            return "RUN";
        if (currValue.Contains("ALARM"))
            return "ALARM";
        if (prevValue.Contains("ALARM") && !currValue.Contains("ALARM"))
            return "NORMAL";
        return "CHANGE";
    }

    private async Task<string?> GetAreaAsync(IExperionDbService db, string tagName)
    {
        if (_areaCache.TryGetValue(tagName, out var cached)) return cached;

        var area = await db.GetAreaByTagNameAsync(tagName);
        if (area != null)
            _areaCache[tagName] = area;
        return area;
    }

    private string? ExtractSection(string tagName)
    {
        var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
        if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
        return null;
    }

    private string? BuildMetadata(string tagName, string eventType, string currValue)
    {
        if (tagName.Contains("-il-") || tagName.Contains("-trip"))
        {
            return JsonSerializer.Serialize(new
            {
                interlock_tag = tagName,
                event_type = eventType,
                raw_value = currValue
            }, _jsonOptions);
        }
        return null;
    }
}

**⚠️ 진단 체크리스트 교차 검증 **(STEP 5+6)

체크 항목 결과 비고
async 내 blocking 호출 OK 모든 DB 호출이 async
Race Condition OK ConcurrentDictionary 사용
예외 삼킴 OK catch에서 LogError 호출
CancellationToken 전달 OK ExecuteAsync → 하위 메서드까지 전달
Q1: 이미 수정된 문제인가? N/A 새 코드
Q2: 다른 레이어에서 처리되는가? N/A 독립 서비스
Q3: 의도적 설계인가? debounce는 의도적
Q4: 재현 시나리오 있는가? 서비스 재시작 시 상태 초기화 → 초기 이벤트 누락 가능 (LOW)

검증 체크리스트:

  • ConcurrentDictionaryGetValueOrDefault가 .NET 8에서 지원되는지 확인
  • record 타입이 ConcurrentDictionary value로 사용 가능할지 확인 (immutability 고려)
  • CancellationToken이 모든 async 메서드에 전달되는지 확인
  • Debounce 로직이 상태 전환(TRIP→RUN)을 항상 기록하는지 확인

Step 6: Program.cs 등록

파일: src/Web/Program.cs 위치: ExperionHistoryService 등록 (line 83) 뒤

builder.Services.AddHostedService<DigitalEventDetectorService>();

검증 체크리스트:

  • using ExperionCrawler.Infrastructure.OpcUa; namespace가 Program.cs 상단에 있는지 확인
  • Singleton + HostedService로 등록되는지 확인 (AddHostedService는 기본 Singleton)

Step 7: Controller + API 엔드포인트

파일: src/Web/Controllers/ExperionControllers.cs 위치: 파일 말미 (line 1158 이후), ExperionPidController

[ApiController]
[Route("api/[controller]")]
public class EventHistoryController : ControllerBase
{
    private readonly IExperionDbService _db;
    private readonly ILogger<EventHistoryController> _logger;

    public EventHistoryController(
        IExperionDbService db,
        ILogger<EventHistoryController> logger)
    {
        _db = db;
        _logger = logger;
    }

    [HttpGet]
    public async Task<IActionResult> Query(
        [FromQuery] string? tagName,
        [FromQuery] string? area,
        [FromQuery] string? section,
        [FromQuery] string? eventType,
        [FromQuery] DateTime from,
        [FromQuery] DateTime to,
        [FromQuery] int limit = 500)
    {
        try
        {
            var rows = await _db.QueryEventHistoryAsync(tagName, area, section, eventType, from, to, limit);
            var list = rows.Select(r => new
            {
                id = r.Id,
                tagName = r.TagName,
                nodeId = r.NodeId,
                prevValue = r.PrevValue,
                currValue = r.CurrValue,
                eventType = r.EventType,
                eventTime = r.EventTime,
                area = r.Area,
                section = r.Section,
                durationSeconds = r.DurationSeconds,
                metadata = r.Metadata
            }).ToList();

            return Ok(new { success = true, count = list.Count, data = list });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[EventHistoryController] 조회 실패");
            return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
        }
    }

    [HttpGet("summary")]
    public async Task<IActionResult> GetSummary(
        [FromQuery] string? area,
        [FromQuery] string? section,
        [FromQuery] DateTime from,
        [FromQuery] DateTime to)
    {
        try
        {
            var rows = await _db.QueryEventHistoryAsync(null, area, section, null, from, to, 10000);
            
            var summary = rows.GroupBy(r => r.Section ?? "기타")
                .Select(g => new
                {
                    section = g.Key,
                    totalEvents = g.Count(),
                    tripCount = g.Count(r => r.EventType == "TRIP"),
                    runCount = g.Count(r => r.EventType == "RUN"),
                    alarmCount = g.Count(r => r.EventType == "ALARM"),
                    changeCount = g.Count(r => r.EventType == "CHANGE")
                }).ToList();

            return Ok(new { success = true, count = summary.Count, data = summary });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[EventHistoryController] 요약 조회 실패");
            return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
        }
    }

    [HttpGet("digital-tags")]
    public async Task<IActionResult> GetDigitalTags()
    {
        try
        {
            var tags = await _db.GetDigitalTagNamesAsync();
            var list = tags.Select(t => new { tagName = t }).ToList();
            return Ok(new { success = true, count = list.Count, data = list });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "[EventHistoryController] 디지털 태그 조회 실패");
            return Ok(new { success = false, error = ex.Message, data = (List<object>)new List<object>() });
        }
    }
}

**⚠️ 진단 체크리스트 교차 검증 **(STEP 5+6)

체크 항목 결과 비고
camelCase JSON OK 명시적 anonymous object 사용
예외 노출 OK catch 후 error 필드만 반환, 스택 트레이스 없음
에러 응답 형식 OK 모든 경로에서 {success, data} 형식 통일
SQL Injection OK EF Core Where 사용 (parameterized)
Q4: 재현 시나리오 N/A GET only, 읽기 전용

검증 체크리스트:

  • 모든 응답이 camelCase anonymous object인지 확인 (AGENTS.md 규칙)
  • from/to 파라미터가 DateTime으로 바인딩되는지 확인 (ISO 8601 또는 Unix timestamp)
  • error 응답도 success: false 형식으로 통일되어 있는지 확인

3. 검증 및 테스트 절차

Stage A: 빌드 검증

dotnet build src/Web/ExperionCrawler.csproj

체크리스트:

  • 컴파일 오류 없음
  • Entity 클래스의 using 문 누락 없음
  • 인터페이스-구현체 매칭 오류 없음

Stage B: DB 초기화 검증

애플리케이션 시작 후:

-- 테이블 존재 확인
SELECT table_name FROM information_schema.tables WHERE table_name = 'event_history_table';

-- 컬럼 확인
SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'event_history_table' ORDER BY ordinal_position;

-- 인덱스 확인
SELECT indexname FROM pg_indexes WHERE tablename = 'event_history_table';

체크리스트:

  • 테이블이 생성되었는지 확인
  • 컬럼 타입이 설계와 일치하는지 확인 (특히 metadata = jsonb)
  • 모든 인덱스가 생성되었는지 확인

Stage C: 디지털 태그 식별 검증

# API 호출
curl "http://localhost:5000/api/event-history/digital-tags" | jq

체크리스트:

  • 반환된 태그 목록이 실제 디지털 태그와 일치하는지 확인
  • 아날로그 태그가 포함되지 않는지 확인
  • tag_metadata 기반 식별이 우선 적용되는지 확인

Stage D: 스냅샷 디지털 제외 검증

-- 최근 스냅샷에서 디지털 값 패턴 확인
SELECT tagname, value FROM history_table 
WHERE recorded_at > NOW() - INTERVAL '5 minutes'
AND value LIKE '{%'
LIMIT 10;

체크리스트:

  • 최근 history_table에 디지털 태그 값({로 시작)이 없는지 확인
  • 아날로그 태그는 정상적으로 기록되는지 확인

Stage E: 이벤트 감지 검증

  1. 실제 디지털 태그 상태 변경 유발 (또는 DB에 직접 테스트 데이터 삽입)
  2. event_history_table 확인:
SELECT * FROM event_history_table ORDER BY event_time DESC LIMIT 20;

체크리스트:

  • 상태 변경이 이벤트로 기록되는지 확인
  • event_type이 올바르게 분류되는지 확인 (TRIP, RUN, CHANGE 등)
  • duration_seconds가 이전 상태 지속 시간을 정확히 나타내는지 확인
  • area와 section이 올바르게 추출되는지 확인
  • debounce가 동작하여 짧은 시간 내 중복 이벤트가 없는지 확인

Stage F: API 조회 검증

# 전체 조회
curl "http://localhost:5000/api/event-history?from=2026-05-01T00:00:00Z&to=2026-05-11T00:00:00Z&limit=100" | jq

# area 필터
curl "http://localhost:5000/api/event-history?area=P6&from=...&to=..." | jq

# 요약 조회
curl "http://localhost:5000/api/event-history/summary?area=P6&from=...&to=..." | jq

체크리스트:

  • 응답 형식이 {success, count, data}인지 확인
  • data 내 각 항목이 camelCase인지 확인
  • 필터가 올바르게 동작하는지 확인
  • 요약 API가 구간별 집계를 올바르게 반환하는지 확인

Stage G: 성능 검증

체크리스트:

  • 500개 디지털 태그 처리 시 1초 간격 감지가 정상 동작하는지 확인
  • DB 쿼리 실행 시간 확인 (GetDigitalPointsAsync가 1초 주기 내에서 완료되는지)
  • 메모리 사용량 모니터링 (_previousStates, _areaCache 크기)

4. 진단 체크리스트 최종 교차 검증

STEP 5 패턴 매칭 결과

체크 항목 결과
미정의 변수 참조 없음 모든 변수 정의 후 사용
async 내 blocking 확인 GetAwaiter().GetResult() 제거됨 (async-safe 캐시 패턴 사용)
Race Condition 확인 ConcurrentDictionary 사용, 캐시 갱신은 단일 태스크
예외 삼킴 확인 모든 catch에서 LogError 호출
SQL Injection 확인 EF Core LINQ Where 사용
에러 응답 형식 확인 {success, data} 통일
camelCase 확인 Controller에서 명시적 anonymous object

STEP 6 교차 검증 결과

질문 항목 결과
Q1: 이미 수정된 문제인가? N/A 새 코드
Q2: 다른 레이어에서 처리되는가? 예외 처리 Controller 레벨 + Service 레벨 모두 처리
Q3: 의도적 설계인가? debounce, 캐시 TTL 계획 문서에 명시된 의도적 설계
Q4: 재현 시나리오 있는가? 서비스 재시작 시 상태 손실 LOW — 재시작 시 _previousStates 초기화되어 첫 변경이 기록되지 않을 수 있음. LoadCurrentStatesAsync로 완화

5. Todo List (구현 순서)

# 작업 파일 상태 검증 방법
1-1 EventHistoryRecord Entity 추가 ExperionEntities.cs 빌드 성공
1-2 DbSet<EventHistoryRecord> 추가 ExperionDbContext.cs 빌드 성공
1-3 DDL (테이블 + 인덱스) 추가 ExperionDbContext.cs DB 테이블 확인
2-1 인터페이스 메서드 5개 추가 IExperionServices.cs 빌드 성공
2-2 DigitalEventRecord DTO 추가 IExperionServices.cs 빌드 성공
2-3 EventHistoryRow DTO 추가 IExperionServices.cs 빌드 성공
3-1 GetDigitalTagNamesAsync 구현 ExperionDbContext.cs API 테스트
3-2 GetDigitalPointsAsync 구현 ExperionDbContext.cs API 테스트
3-3 GetAreaByTagNameAsync 구현 ExperionDbContext.cs DB 직접 확인
3-4 RecordDigitalEventAsync 구현 ExperionDbContext.cs INSERT 테스트
3-5 QueryEventHistoryAsync 구현 ExperionDbContext.cs API 테스트
3-6 디지털 태그 캐시 로직 구현 ExperionDbContext.cs 성능 확인
4 SnapshotToHistoryAsync 수정 ExperionDbContext.cs history_table 확인
5 DigitalEventDetectorService 생성 새 파일 이벤트 기록 확인
6 Program.cs 등록 Program.cs 앱 시작 확인
7 EventHistoryController 생성 ExperionControllers.cs API 호출 테스트
8 전체 빌드 + 테스트 dotnet build

6. 주의사항

  1. breaking change 방지: SnapshotToHistoryAsync()의 기본 파라미터(includeDigital = false)를 사용하므로 기존 호출 코드 수정 불필요
  2. 기존 데이터 마이그레이션 불필요: event_history_table은 새 테이블로 생성, 기존 history_table에 영향 없음
  3. 서비스 재시작 시 상태 손실: LoadCurrentStatesAsync가 재시작 시 현재 상태를 로드하여 첫 이벤트를 방지하지만, 재시작 직전의 상태는 손실됨 (LOW, 수용 가능)
  4. JSONB 메타데이터: Npgsql에서 stringJSONB 자동 변환이 동작하는지 실제 테스트 필요. 문제가 발생하면 NpgsqlTypes.NpgsqlJson 타입으로 변경