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) → ExperionDbService는 Scoped 등록이므로 스코프마다 새 인스턴스 생성, 캐시 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 TEXT에 NOT NULL 제약 없음 → Entity string CurrValue = string.Empty(non-nullable)와 스키마 불일치, 수동 NULL 삽입 시 런타임 오류 가능 |
ExperionDbContext.cs:354 |
DDL을 curr_value TEXT NOT NULL DEFAULT ''로 변경 |
| F | 🟡 LOW | BuildMetadata의 tagName.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;
}
검증 체크리스트:
Id가long(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) |
검증 체크리스트:
ConcurrentDictionary의GetValueOrDefault가 .NET 8에서 지원되는지 확인record타입이ConcurrentDictionaryvalue로 사용 가능할지 확인 (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: 이벤트 감지 검증
- 실제 디지털 태그 상태 변경 유발 (또는 DB에 직접 테스트 데이터 삽입)
- 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. 주의사항
- breaking change 방지:
SnapshotToHistoryAsync()의 기본 파라미터(includeDigital = false)를 사용하므로 기존 호출 코드 수정 불필요 - 기존 데이터 마이그레이션 불필요:
event_history_table은 새 테이블로 생성, 기존history_table에 영향 없음 - 서비스 재시작 시 상태 손실:
LoadCurrentStatesAsync가 재시작 시 현재 상태를 로드하여 첫 이벤트를 방지하지만, 재시작 직전의 상태는 손실됨 (LOW, 수용 가능) - JSONB 메타데이터: Npgsql에서
string→JSONB자동 변환이 동작하는지 실제 테스트 필요. 문제가 발생하면NpgsqlTypes.NpgsqlJson타입으로 변경