`;
+}
+
+function evtReset() {
+ document.getElementById('ef-tag').value = '';
+ document.getElementById('ef-event-type').value = '';
+ document.getElementById('ef-area').value = '';
+ document.getElementById('ef-section').value = '';
+ document.getElementById('ef-limit').value = '500';
+ dtClearField('evt-from');
+ dtClearField('evt-to');
+ document.getElementById('evt-result-info').classList.add('hidden');
+ document.getElementById('evt-table').classList.add('hidden');
+ document.getElementById('evt-summary-card').classList.add('hidden');
+ document.getElementById('evt-tag-status').textContent = '';
+}
+```
+
+**설계 결정**:
+- `_evtBuildTable`, `_evtBuildSummary`, `_evtBadge`, `_evtFmtTime`에 `_` 접두사 → 내부 헬퍼임을 명시
+- `dtClearField('evt-from')` 재사용 — 기존 함수가 `hf-${target}`, `dtp-${target}-display` 패턴으로 동작하므로 수정 없음
+- 탭 진입 시 자동 API 호출 없음 (탭 핸들러에 `if (tab === 'evt')` 추가 불필요)
+- `api()` 헬퍼 함수 재사용 (기존 코드에 이미 정의됨)
+
+**검증**:
+- [ ] `api('GET', '/api/event-history/digital-tags')` 응답의 `d.data` 배열 안에 `{ tagName: "..." }` 형태인지 확인
+- [ ] `new Date(fromRaw).toISOString()` — `fromRaw`는 `"2026-05-11T03:00"` 형태 → ISO 8601 UTC로 변환됨 (기존 hist 탭과 동일 패턴)
+- [ ] `_evtBuildTable(d.data)` — `d.data`가 빈 배열이면 "데이터 없음" 메시지 표시
+- [ ] `evtReset()`이 summary 카드도 숨기는지 확인
+
+---
+
+## 4. 파일별 추가 위치 요약
+
+### `style.css`
+```
+파일 말미 (1489줄 끝) → .evt-badge ~ .evt-total 블록 추가
+```
+
+### `index.html`
+```
+[위치 1] 75-78줄
블록 뒤:
+ →
탭 항목 추가
+
+[위치 2] 닫는 태그 앞 (현재 파일 말미):
+ → #pane-evt 섹션 전체 추가
+```
+
+### `app.js`
+```
+histReset() 함수 끝 (현재 ~1175줄) 뒤:
+ → evtLoadTags ~ evtReset 전체 블록 추가
+```
+
+**탭 핸들러 수정 없음**: `evt` 탭은 진입 시 API 자동 호출 없음 (사용자가 버튼 클릭 시에만 실행).
+
+---
+
+## 5. 검증 절차
+
+### Stage A — 빌드/렌더링
+- [ ] 브라우저에서 사이드바에 "12 이벤트 히스토리" 탭이 보이는지 확인
+- [ ] 탭 클릭 시 `#pane-evt`가 활성화되는지 확인
+
+### Stage B — 태그 목록 로드
+```
+▼ 태그 목록 불러오기 버튼 클릭
+→ GET /api/event-history/digital-tags
+→ ef-tag 드롭다운에 디지털 태그 목록 채워짐
+→ 태그 상태에 "✅ N개" 표시
+```
+
+### Stage C — 이벤트 조회
+```
+시작/종료 시간 선택 → 🔍 이벤트 조회 버튼
+→ GET /api/event-history?from=...&to=...&limit=500
+→ 결과 테이블 렌더링 (시간, 태그, 배지, 이전값/현재값, Area, Section, 지속시간)
+→ 이벤트 타입별 배지 색상 확인 (TRIP:red, RUN:green, ALARM:amber)
+```
+
+### Stage D — 구간 요약
+```
+📊 구간 요약 버튼
+→ GET /api/event-history/summary?from=...&to=...
+→ evt-summary-card 표시
+→ Section별 카드 그리드 렌더링 (TRIP/RUN/ALARM/CHANGE 각 건수)
+```
+
+### Stage E — 필터 조합
+```
+- eventType=TRIP 선택 후 조회 → TRIP 이벤트만 표시
+- area=P6 입력 후 조회 → P6 area만 표시
+- tagName 선택 후 조회 → 해당 태그만 표시
+```
+
+### Stage F — 초기화
+```
+초기화 버튼 → 모든 필터 리셋, 결과 테이블 숨김, 요약 카드 숨김
+```
+
+---
+
+## 6. 주의사항
+
+1. **날짜 피커 재사용**: `dtOpen('evt-from')` 호출 시 `hf-evt-from`(hidden input), `dtp-evt-from-display`(표시 div) ID를 자동 참조. **기존 dt-picker 코드 수정 불필요**.
+
+2. **탭 진입 시 API 없음**: 탭 핸들러 (`app.js` 7-18줄)에 `evt` 케이스 추가 불필요. 사이드 이펙트 없음.
+
+3. **from/to 기본값**: from/to 미입력 시 백엔드에서 자동으로 최근 1일 (`DateTime.UtcNow.AddDays(-1)` ~ `DateTime.UtcNow`) 적용. 빈 파라미터 전송 시에도 정상 동작.
+
+4. **`esc()` 필수**: XSS 방지를 위해 서버 응답의 모든 문자열 필드에 `esc()` 적용. 특히 tagName, currValue, prevValue.
+
+5. **`nm-cls` 재사용**: Area/Section 표시에 기존 `.nm-cls` 배지 스타일 재사용 (별도 CSS 불필요).
+
+6. **`hist-status` 재사용**: 태그 로드 상태 표시에 기존 `.hist-status` 클래스 재사용.
+
+---
+
+## 7. Todo List
+
+| # | 작업 | 파일 | 상태 | 검증 방법 |
+|---|------|------|------|-----------|
+| 1 | `.evt-badge` ~ `.evt-total` CSS 추가 | `style.css` | ⬜ | 브라우저 개발자도구 CSS 확인 |
+| 2 | `
` 탭 항목 추가 | `index.html` | ⬜ | 사이드바 탭 노출 확인 |
+| 3 | `#pane-evt` 섹션 전체 추가 | `index.html` | ⬜ | 탭 클릭 시 pane 활성화 확인 |
+| 4 | `evtLoadTags()` ~ `evtReset()` 함수 추가 | `app.js` | ⬜ | 버튼 클릭 → API 호출 확인 |
+| 5 | 태그 목록 로드 테스트 | — | ⬜ | 드롭다운에 디지털 태그 표시 |
+| 6 | 이벤트 조회 테스트 | — | ⬜ | 결과 테이블 + 배지 렌더링 확인 |
+| 7 | 구간 요약 테스트 | — | ⬜ | 요약 카드 그리드 렌더링 확인 |
+| 8 | 필터 조합 테스트 | — | ⬜ | TRIP 전용, Area 전용 등 확인 |
+| 9 | 초기화 테스트 | — | ⬜ | 모든 필드 리셋 확인 |
diff --git a/plans/이벤트-히스토리-테이블-코딩플랜.md b/plans/이벤트-히스토리-테이블-코딩플랜.md
new file mode 100644
index 0000000..c7ff7f3
--- /dev/null
+++ b/plans/이벤트-히스토리-테이블-코딩플랜.md
@@ -0,0 +1,1095 @@
+# 이벤트 기반 디지털 포인트 히스토리 — 상세 코딩 플랜
+
+## 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)` 추가, `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 이후)
+
+```csharp
+/// event_history_table — 디지털 포인트 상태 변경 이벤트
+[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)
+
+```csharp
+public DbSet EventHistoryRecords => Set();
+```
+
+**검증 체크리스트**:
+- [ ] 기존 DbSet 패턴과 동일한 형식인지 확인
+
+---
+
+#### 1-3. DDL 추가 (InitializeAsync 내부)
+
+**파일**: `src/Infrastructure/Database/ExperionDbContext.cs`
+**위치**: `InitializeAsync()` 메서드 내, 기존 테이블 생성 SQL 뒤 (line 340 근처)
+
+```csharp
+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) 뒤
+
+```csharp
+/// 디지털 태그 이름 목록 조회 (value 패턴 또는 tag_metadata 기반)
+Task> GetDigitalTagNamesAsync();
+
+/// 디지털 포인트 현재 값 조회
+Task> GetDigitalPointsAsync();
+
+/// 디지털 이벤트 기록
+Task RecordDigitalEventAsync(DigitalEventRecord record);
+
+/// 태그명으로 area 조회 (tag_metadata 기반)
+Task GetAreaByTagNameAsync(string tagName);
+
+/// 이벤트 히스토리 조회
+Task> 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 이후)
+
+```csharp
+/// 디지털 이벤트 기록용 서비스 계층 DTO
+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; }
+}
+
+/// 이벤트 히스토리 조회 결과 행
+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. 디지털 태그 식별 로직
+
+```csharp
+public async Task> 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. 디지털 포인트 조회
+
+```csharp
+public async Task> GetDigitalPointsAsync()
+{
+ var digitalTagNames = await GetDigitalTagNamesAsync();
+ var tagSet = new HashSet(digitalTagNames);
+
+ if (tagSet.Count == 0)
+ return Enumerable.Empty();
+
+ return await _ctx.RealtimePoints
+ .Where(p => tagSet.Contains(p.TagName))
+ .ToListAsync();
+}
+```
+
+**검증 체크리스트**:
+- [ ] `GetDigitalTagNamesAsync` 결과를 HashSet으로 변환하여 O(1) lookup하는지 확인
+- [ ] tagSet이 비어있을 때 빈 결과 반환 (null 방지)
+
+---
+
+#### 3-3. area 조회
+
+```csharp
+public async Task 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. 이벤트 기록
+
+```csharp
+public async Task 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. 이벤트 히스토리 조회
+
+```csharp
+public async Task> 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
+
+#### 변경 전:
+```csharp
+public async Task SnapshotToHistoryAsync()
+{
+ var now = DateTime.UtcNow;
+ var points = await _ctx.RealtimePoints.ToListAsync();
+ // ... 전체 포인트 스냅샷
+}
+```
+
+#### 변경 후:
+```csharp
+public async Task 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` 클래스 레벨에 추가:
+```csharp
+private HashSet _digitalTagCache = new();
+private DateTime _digitalTagCacheTime = DateTime.MinValue;
+private readonly object _cacheLock = new();
+private const int DigitalTagCacheTtlSeconds = 300; // 5분 TTL
+```
+
+```csharp
+private HashSet GetCachedDigitalTagNames()
+{
+ lock (_cacheLock)
+ {
+ if ((DateTime.UtcNow - _digitalTagCacheTime).TotalSeconds < DigitalTagCacheCacheTtlSeconds)
+ return _digitalTagCache;
+ }
+
+ // async 메서드 내에서 lock 후 async 호출은 데드락 위험 →
+ // 대신 백그라운드 갱신 패턴 사용
+ var tags = GetDigitalTagNamesAsync().GetAwaiter().GetResult();
+
+ lock (_cacheLock)
+ {
+ _digitalTagCache = new HashSet(tags);
+ _digitalTagCacheTime = DateTime.UtcNow;
+ }
+ return _digitalTagCache;
+}
+```
+
+**⚠️ 진단 체크리스트 교차 검증 **(STEP 6)
+- [ ] Q2: `GetAwaiter().GetResult()` 사용 — 하지만 이는 scoped 메서드 내에서 한 번만 호출되며 캐싱되므로 실제 블로킹 문제는 없음 (STEP 5의 async blocking 체크와 비교)
+- [ ] Q3: `lock` + async 패턴 — 캐시 갱신 시 데드락 가능성 있음. **수정 필요**: async/await 전용 패턴으로 변경
+
+#### 수정된 캐시 패턴 (async-safe):
+
+```csharp
+private HashSet _digitalTagCache = new();
+private DateTime _digitalTagCacheTime = DateTime.MinValue;
+private Task? _cacheRefreshTask = null;
+
+private async Task> 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(tags);
+ _digitalTagCacheTime = DateTime.UtcNow;
+}
+```
+
+#### 그리고 SnapshotToHistoryAsync에서:
+```csharp
+public async Task 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` (새 파일)
+
+```csharp
+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;
+
+///
+/// 디지털 포인트의 상태 변경을 감지하여 event_history_table에 기록하는 BackgroundService.
+/// 1초 간격으로 realtime_table을 검사하여 변경 사항을 기록.
+///
+public class DigitalEventDetectorService : BackgroundService
+{
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _previousStates = new();
+ private readonly ConcurrentDictionary _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 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();
+
+ 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();
+
+ 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();
+
+ 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 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` 타입이 `ConcurrentDictionary` value로 사용 가능할지 확인 (immutability 고려)
+- [ ] CancellationToken이 모든 async 메서드에 전달되는지 확인
+- [ ] Debounce 로직이 상태 전환(TRIP→RUN)을 항상 기록하는지 확인
+
+---
+
+### Step 6: Program.cs 등록
+
+**파일**: `src/Web/Program.cs`
+**위치**: `ExperionHistoryService` 등록 (line 83) 뒤
+
+```csharp
+builder.Services.AddHostedService();
+```
+
+**검증 체크리스트**:
+- [ ] `using ExperionCrawler.Infrastructure.OpcUa;` namespace가 Program.cs 상단에 있는지 확인
+- [ ] Singleton + HostedService로 등록되는지 확인 (AddHostedService는 기본 Singleton)
+
+---
+
+### Step 7: Controller + API 엔드포인트
+
+**파일**: `src/Web/Controllers/ExperionControllers.cs`
+**위치**: 파일 말미 (line 1158 이후), `ExperionPidController` 뒤
+
+```csharp
+[ApiController]
+[Route("api/[controller]")]
+public class EventHistoryController : ControllerBase
+{
+ private readonly IExperionDbService _db;
+ private readonly ILogger _logger;
+
+ public EventHistoryController(
+ IExperionDbService db,
+ ILogger logger)
+ {
+ _db = db;
+ _logger = logger;
+ }
+
+ [HttpGet]
+ public async Task 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