From de728f013ab829459642ff0a7dc0866afa657601 Mon Sep 17 00:00:00 2001 From: windpacer Date: Mon, 11 May 2026 15:54:17 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=9E=88?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=20=EA=B8=B0=ED=9A=8D=EC=84=9C=20?= =?UTF-8?q?=EB=B0=8F=20MiniMax=20=EC=A7=84=EB=8B=A8=20=EB=B3=B4=EA=B3=A0?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plans/이벤트-히스토리-테이블-UI플랜.md | 644 +++++++++++++ plans/이벤트-히스토리-테이블-코딩플랜.md | 1095 ++++++++++++++++++++++ plans/이벤트-히스토리-테이블-플랜.md | 603 ++++++++++++ 프로젝트진단-MiniMax-M2.5-Free.md | 260 +++++ 4 files changed, 2602 insertions(+) create mode 100644 plans/이벤트-히스토리-테이블-UI플랜.md create mode 100644 plans/이벤트-히스토리-테이블-코딩플랜.md create mode 100644 plans/이벤트-히스토리-테이블-플랜.md create mode 100644 프로젝트진단-MiniMax-M2.5-Free.md diff --git a/plans/이벤트-히스토리-테이블-UI플랜.md b/plans/이벤트-히스토리-테이블-UI플랜.md new file mode 100644 index 0000000..769f664 --- /dev/null +++ b/plans/이벤트-히스토리-테이블-UI플랜.md @@ -0,0 +1,644 @@ +# 이벤트 히스토리 UI 신설 — 상세 코딩 플랜 + +## 진단 보고서 (2026-05-11) + +diagnosis-checklist.md 8단계 순서대로 진단한 결과 **실행 가능한 플랜**임. 중대한 오진은 없음. 아래는 단계별 확인 사항과 발견된 소규모 이슈. + +### STEP 1 — 맥락 파악 + +- **역할**: 백엔드 API 3개는 구현·동작 중인데 UI 전무 → 접근 불가 문제 해결 +- **레이어**: 순수 프론트엔드 (HTML/JS/CSS) — 백엔드 수정 없음 +- **관련 문서**: AGENTS.md (camelCase 규칙), CODING_CONVENTIONS.md + +### STEP 2 — 구조 탐색 + +| 항목 | 실제 값 | 플랜 가정 | 일치 | +|------|---------|-----------|------| +| `style.css` 총 줄수 | 1489 | 1489 | ✅ | +| `index.html` 총 줄수 | 1205 | — | — | +| `index.html` `` 위치 | 1119 | 파일 말미 | ⚠️ | +| `app.js` 총 줄수 | 3075 | — | — | +| `histReset()` 위치 | 1158~1169 | ~1175 | ⚠️ | +| `data-tab="pid"` 위치 | 75-78 | 75-78 | ✅ | + +### STEP 3 — 코드 읽기 (핵심 검증) + +| 검증 대상 | 실제 상태 | 플랜 가정 | 일치 | +|-----------|-----------|-----------|------| +| `api()` 함수 존재 | `app.js:40` | 존재 | ✅ | +| `esc()` 함수 존재 | `app.js:22` | 존재 | ✅ | +| `dtOpen(target)` 패턴 | `hf-${target}`, `dtp-${target}-display` 참조 | 동일 | ✅ | +| `dtClearField(target)` 패턴 | 동일 패턴 | 동일 | ✅ | +| 탭 핸들러 자동 호출 없음 | `app.js:5-18` — evt 케이스 없음 | 불필요 | ✅ | +| CSS 변수 (`--s2`, `--bd`, `--r`, `--fm`, `--t0`, `--t2`) | 모두 정의됨 | 사용 | ✅ | +| `.hidden` 클래스 | `style.css:1274` | 재사용 | ✅ | +| `.nm-cls` 클래스 | `style.css:469` | 재사용 | ✅ | +| `.hist-status` 클래스 | `style.css:1216` | 재사용 | ✅ | +| `.nm-result-info` 클래스 | `style.css:454` | 재사용 | ✅ | +| 백엔드 camelCase 응답 | `ExperionControllers.cs:1192-1207` | 일치 | ✅ | +| 백엔드 digital-tags 응답 | `{ tagName: "..." }` | 일치 | ✅ | + +### STEP 4 — 호출 계층 지도 + +``` +사용자 클릭 (이벤트 조회) + → evtQuery() + → api('GET', '/api/event-history?params') + → EventHistoryController.Query() + → _db.QueryEventHistoryAsync() + → DB (external I/O) + → _evtBuildTable(d.data) ← 순수 함수, side effect 없음 + → DOM innerHTML 업데이트 +``` + +에러 처리: Controller 레벨 try-catch 전체 포착 → `{ success: false, error: "..." }` 반환. JS 측 `if (!d.success) throw`로 캐치. **계층 적절**. + +### STEP 5 — 패턴 매칭 결과 + +| 체크 | 항목 | 결과 | +|------|------|------| +| 미정의 변수 참조 | `api`, `esc`, `dtClearField` 모두 정의됨 | ✅ | +| XSS (내부 HTML) | `esc()`로 모든 서버 응답 문자열 이스케이프 | ✅ | +| `.innerHTML` + 서버 데이터 | `_evtBuildTable`에서 `esc()` 적용済み | ✅ | +| 날짜 변환 | `new Date(fromRaw).toISOString()` — 기존 hist 탭과 동일 패턴 | ✅ | + +### STEP 6 — 교차 검증 + +| 의심 항목 | Q1-Q4 결과 | 결론 | +|-----------|-----------|------| +| `histReset()` 줄번호 불일치 (1175 vs 1169) | Q3: 의도적 아님, 단순 추정치 차이 | LOW, 수정 제안만 | +| ``가 "파일 말미"가 아님 (1119/1205) | Q3: 설명상 불명확하나 삽입 로직은 정확 | LOW, 명시화 제안 | + +### 발견 사항 + +### 1. `histReset()` 종료 줄번호 불일치 (LOW) + +**문제**: 플랜 439줄 "histReset() 함수 끝 (현재 ~1175줄) 뒤"라고 했으나 실제 `histReset()`은 `app.js:1158-1169`에서 종료됨. 1171줄부터 "07-2 하이퍼테이블 관리" 섹션 시작. +**근거**: `app.js:1158-1173` +**영향**: 동작에는 영향 없음. 삽입 위치를 "histReset() 뒤"로 이해하면 정확함. 줄번호만 불일치. +**수정**: "~1175줄" → "1169줄"로 정정 + +### 2. ``가 파일 말미가 아님 (LOW) + +**문제**: 플랜 181줄 "#pane-pid 섹션 (현재 파일 말미) 바로 뒤, 닫는 태그 앞"이라고 했으나 ``는 `index.html:1119`에 위치하고 파일은 1205줄. 1119~1205줄 사이에 fastRecord 모달, dt-popup 등 외부 요소가 있음. +**근거**: `index.html:1117-1205` +**영향**: 삽입 위치 자체는 정확함 (`` 앞). "파일 말미"라는 표현이 혼동을 줄 수 있음. +**수정**: "파일 말미" → "1117줄 (#pane-pid 종료)"로 정정 + +--- + +**종합**: 플랜 실행 가능. CSS 변수, 헬퍼 함수, 클래스, 백엔드 응답 형식 등 모든 전제가 검증됨. 발견된 2개 이슈 모두 LOW (표현 명확화 수준). + +--- + +## 0. 현황 진단 + +### UI 반영 여부 +| 항목 | 상태 | +|------|------| +| 사이드바 탭 (`data-tab="evt"`) | ❌ 없음 | +| `#pane-evt` 섹션 | ❌ 없음 | +| JS 함수 (`evtQuery` 등) | ❌ 없음 | +| CSS 이벤트 배지 스타일 | ❌ 없음 | + +**결론**: 백엔드 API 3개(`GET /api/event-history`, `/summary`, `/digital-tags`)는 완전 구현·동작 중이나 **UI가 전무**하여 접근 불가. + +### 기존 API 엔드포인트 (구현 완료) +| 엔드포인트 | 설명 | 주요 파라미터 | +|-----------|------|--------------| +| `GET /api/event-history` | 이벤트 조회 | tagName, area, section, eventType, from, to, limit | +| `GET /api/event-history/summary` | 구간별 집계 | area, section, from, to | +| `GET /api/event-history/digital-tags` | 디지털 태그 목록 | — | + +### 응답 형식 +```json +// GET /api/event-history +{ + "success": true, + "count": 42, + "data": [ + { + "id": 1, + "tagName": "P6-FIC101.instate0", + "nodeId": "...", + "prevValue": "RUN", + "currValue": "L-STOP", + "eventType": "TRIP", + "eventTime": "2026-05-11T03:00:00Z", + "area": "P6", + "section": "1-2차", + "durationSeconds": 3600, + "metadata": null + } + ] +} + +// GET /api/event-history/summary +{ + "success": true, + "count": 3, + "data": [ + { + "section": "1-2차", + "totalEvents": 10, + "tripCount": 3, + "runCount": 3, + "alarmCount": 2, + "changeCount": 2 + } + ] +} +``` + +--- + +## 1. 구현 대상 파일 + +| 파일 | 수정 내용 | +|------|----------| +| `src/Web/wwwroot/index.html` | 탭 항목(12번) + `#pane-evt` 섹션 추가 | +| `src/Web/wwwroot/js/app.js` | `evtLoadTags`, `evtQuery`, `evtSummary`, `evtBuildTable`, `evtBuildSummary`, `evtReset` 함수 추가 | +| `src/Web/wwwroot/css/style.css` | `.evt-badge`, `.evt-summary-*` 스타일 추가 | + +--- + +## 2. 구현 순서 (Dependency Graph) + +``` +Step 1: CSS — .evt-badge, .evt-summary-grid, .evt-summary-item + ↓ +Step 2: HTML — 사이드바 탭 항목(12번) 추가 + ↓ +Step 3: HTML — #pane-evt 섹션 전체 구조 + ↓ +Step 4: JS — evtLoadTags(), evtQuery(), evtSummary() + ↓ +Step 5: JS — evtBuildTable(), evtBuildSummary(), evtReset() + ↓ +Step 6: 검증 +``` + +--- + +## 3. Step-by-Step 코딩 계획 + +### Step 1: CSS 추가 + +**파일**: `src/Web/wwwroot/css/style.css` +**위치**: 파일 말미 (현재 1489줄 끝) + +```css +/* ── Event History ─────────────────────────────────────────── */ +.evt-badge { + display: inline-block; + font-family: var(--fm); font-size: 10px; font-weight: 700; + letter-spacing: .06em; padding: 2px 8px; border-radius: 3px; + text-transform: uppercase; white-space: nowrap; +} +.evt-badge.trip { background: rgba(239,68,68,.18); color: #f87171; } +.evt-badge.run { background: rgba(16,185,129,.18); color: #34d399; } +.evt-badge.alarm { background: rgba(245,158,11,.18); color: #fbbf24; } +.evt-badge.normal { background: rgba(148,163,184,.18); color: #94a3b8; } +.evt-badge.change { background: rgba(96,165,250,.18); color: #60a5fa; } + +.evt-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.evt-summary-item { + background: var(--s2); + border: 1px solid var(--bd); + border-radius: var(--r); + padding: 14px 16px; +} + +.evt-summary-section { + font-family: var(--fm); font-size: 13px; font-weight: 700; + color: var(--t0); margin-bottom: 10px; +} + +.evt-summary-counts { + display: flex; gap: 10px; flex-wrap: wrap; +} + +.evt-count { + display: flex; align-items: center; gap: 5px; + font-family: var(--fm); font-size: 11px; color: var(--t2); +} + +.evt-count strong { font-size: 14px; color: var(--t0); } + +.evt-total { + font-family: var(--fm); font-size: 11px; color: var(--t2); + margin-top: 8px; padding-top: 8px; + border-top: 1px solid var(--bd); +} + +.evt-total strong { color: var(--t0); } +``` + +**검증**: +- [ ] `.evt-badge.trip` 의 색상이 `#f87171` (red-400) 계열인지 확인 +- [ ] `.evt-summary-grid`가 반응형으로 동작하는지 확인 (`auto-fill, minmax`) + +--- + +### Step 2: HTML — 사이드바 탭 항목 추가 + +**파일**: `src/Web/wwwroot/index.html` +**위치**: ` +``` + +**검증**: +- [ ] 탭 번호가 기존 11번(P&ID 추출) 다음인지 확인 +- [ ] `data-tab="evt"`가 `#pane-evt`와 매핑되는지 확인 (탭 클릭 핸들러가 `pane-${tab}` 패턴 사용) + +--- + +### Step 3: HTML — #pane-evt 섹션 추가 + +**파일**: `src/Web/wwwroot/index.html` +**위치**: `#pane-pid` 섹션 (현재 파일 말미) 바로 뒤, `` 닫는 태그 앞 + +```html + +
+
+
+

이벤트 히스토리

+

디지털 포인트 상태 변경 이벤트를 조회합니다. (event_history_table)

+
+
EVENT / DIGITAL
+
+ + +
+
조회 조건
+ + +
+ 태그 필터 + + +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
— 선택 안 함 —
+
+
+ + +
— 선택 안 함 —
+
+
+ +
+ + + +
+
+ + + + + + + +
+``` + +**설계 결정**: +- 날짜 피커: 기존 `dtOpen(target)` 재사용. target이 `'evt-from'`이면 `hf-evt-from`, `dtp-evt-from-display` ID를 자동으로 참조 — **기존 코드 수정 없음** +- `hist-status` 클래스 재사용 (기존 스타일 이미 존재) +- `.hidden` 클래스 재사용 (기존 스타일 이미 존재) +- `nm-result-info` 클래스 재사용 (조회 결과 건수 표시용) + +**검증**: +- [ ] `dtOpen('evt-from')` 호출 시 `hf-evt-from`, `dtp-evt-from-display` 를 정확히 참조하는지 확인 +- [ ] `class="pane"` (active 없음) — 탭 클릭 시 JS가 active 추가하므로 정상 +- [ ] `` 닫는 태그 앞에 삽입했는지 확인 + +--- + +### Step 4+5: JS 함수 추가 + +**파일**: `src/Web/wwwroot/js/app.js` +**위치**: `histReset()` 함수 (현재 1158줄) 블록 **뒤**에 추가 + +```javascript +// ── Event History ───────────────────────────────────────────────────────────── + +async function evtLoadTags() { + const statusEl = document.getElementById('evt-tag-status'); + statusEl.textContent = '⏳ 조회 중...'; + try { + const d = await api('GET', '/api/event-history/digital-tags'); + const tags = d.data || []; + const sel = document.getElementById('ef-tag'); + sel.innerHTML = '' + + tags.map(t => ``).join(''); + statusEl.textContent = `✅ ${tags.length}개`; + } catch (e) { + statusEl.textContent = `❌ ${e.message}`; + } +} + +async function evtQuery() { + const tag = document.getElementById('ef-tag').value; + const eventType = document.getElementById('ef-event-type').value; + const area = document.getElementById('ef-area').value.trim(); + const section = document.getElementById('ef-section').value.trim(); + const limit = document.getElementById('ef-limit').value || 500; + const fromRaw = document.getElementById('hf-evt-from').value; + const toRaw = document.getElementById('hf-evt-to').value; + + const params = new URLSearchParams(); + if (tag) params.set('tagName', tag); + if (eventType) params.set('eventType', eventType); + if (area) params.set('area', area); + if (section) params.set('section', section); + params.set('limit', limit); + if (fromRaw) params.set('from', new Date(fromRaw).toISOString()); + if (toRaw) params.set('to', new Date(toRaw).toISOString()); + + const infoEl = document.getElementById('evt-result-info'); + const tableEl = document.getElementById('evt-table'); + infoEl.textContent = '⏳ 조회 중...'; + infoEl.classList.remove('hidden'); + tableEl.classList.add('hidden'); + + try { + const d = await api('GET', `/api/event-history?${params}`); + if (!d.success) throw new Error(d.error || '조회 실패'); + infoEl.textContent = `총 ${d.count}건`; + tableEl.innerHTML = _evtBuildTable(d.data); + tableEl.classList.remove('hidden'); + } catch (e) { + infoEl.textContent = `❌ ${e.message}`; + } +} + +async function evtSummary() { + const area = document.getElementById('ef-area').value.trim(); + const section = document.getElementById('ef-section').value.trim(); + const fromRaw = document.getElementById('hf-evt-from').value; + const toRaw = document.getElementById('hf-evt-to').value; + + const params = new URLSearchParams(); + if (area) params.set('area', area); + if (section) params.set('section', section); + if (fromRaw) params.set('from', new Date(fromRaw).toISOString()); + if (toRaw) params.set('to', new Date(toRaw).toISOString()); + + const card = document.getElementById('evt-summary-card'); + const content = document.getElementById('evt-summary-content'); + content.textContent = '⏳ 집계 중...'; + card.classList.remove('hidden'); + + try { + const d = await api('GET', `/api/event-history/summary?${params}`); + if (!d.success) throw new Error(d.error || '조회 실패'); + content.innerHTML = _evtBuildSummary(d.data); + } catch (e) { + content.textContent = `❌ ${e.message}`; + } +} + +function _evtBadge(t) { + const cls = { TRIP:'trip', RUN:'run', ALARM:'alarm', NORMAL:'normal', CHANGE:'change' }[t] || 'change'; + return `${esc(t)}`; +} + +function _evtFmtTime(dt) { + if (!dt) return '—'; + return new Date(dt).toLocaleString('ko-KR', { + year:'numeric', month:'2-digit', day:'2-digit', + hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false + }); +} + +function _evtBuildTable(rows) { + if (!rows || !rows.length) + return '
데이터 없음
'; + const html = rows.map(r => ` + + ${_evtFmtTime(r.eventTime)} + ${esc(r.tagName)} + ${_evtBadge(r.eventType)} + ${esc(r.prevValue ?? '—')} + ${esc(r.currValue)} + ${r.area ? `${esc(r.area)}` : '—'} + ${r.section ? `${esc(r.section)}` : '—'} + ${r.durationSeconds != null ? r.durationSeconds + 's' : '—'} + `).join(''); + return ` + + + + + + + + + + + + ${html} +
시간태그명이벤트이전값현재값AreaSection지속(초)
`; +} + +function _evtBuildSummary(data) { + if (!data || !data.length) + return '
데이터 없음
'; + return `
${data.map(s => ` +
+
${esc(s.section)}
+
+
${_evtBadge('TRIP')} ${s.tripCount}
+
${_evtBadge('RUN')} ${s.runCount}
+
${_evtBadge('ALARM')} ${s.alarmCount}
+
${_evtBadge('CHANGE')} ${s.changeCount}
+
+
합계 ${s.totalEvents}
+
`).join('')}
`; +} + +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)new List() }); + } + } + + [HttpGet("summary")] + public async Task 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)new List() }); + } + } + + [HttpGet("digital-tags")] + public async Task 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)new List() }); + } + } +} +``` + +**⚠️ 진단 체크리스트 교차 검증 **(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: 빌드 검증 + +```bash +dotnet build src/Web/ExperionCrawler.csproj +``` + +**체크리스트**: +- [ ] 컴파일 오류 없음 +- [ ] Entity 클래스의 using 문 누락 없음 +- [ ] 인터페이스-구현체 매칭 오류 없음 + +--- + +### Stage B: DB 초기화 검증 + +애플리케이션 시작 후: +```sql +-- 테이블 존재 확인 +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: 디지털 태그 식별 검증 + +```bash +# API 호출 +curl "http://localhost:5000/api/event-history/digital-tags" | jq +``` + +**체크리스트**: +- [ ] 반환된 태그 목록이 실제 디지털 태그와 일치하는지 확인 +- [ ] 아날로그 태그가 포함되지 않는지 확인 +- [ ] tag_metadata 기반 식별이 우선 적용되는지 확인 + +--- + +### Stage D: 스냅샷 디지털 제외 검증 + +```sql +-- 최근 스냅샷에서 디지털 값 패턴 확인 +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 확인: +```sql +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 조회 검증 + +```bash +# 전체 조회 +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` 추가 | `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에서 `string` → `JSONB` 자동 변환이 동작하는지 실제 테스트 필요. 문제가 발생하면 `NpgsqlTypes.NpgsqlJson` 타입으로 변경 diff --git a/plans/이벤트-히스토리-테이블-플랜.md b/plans/이벤트-히스토리-테이블-플랜.md new file mode 100644 index 0000000..67ec8c8 --- /dev/null +++ b/plans/이벤트-히스토리-테이블-플랜.md @@ -0,0 +1,603 @@ +# 이벤트 기반 디지털 포인트 히스토리 테이블 플랜 + +## 1. 개요 + +### 목적 +- 기존 periodic snapshot (60초마다)에서 **event-driven** 방식으로 변경 +- 디지털 포인트의 상태 변경 時만 기록하여 스토리지 절약 + +### 현재 상태 +- `ExperionHistoryService`가 60초마다 `realtime_table` 전체를 `history_table`에 스냅샷 +- 디지털/아날로그 구분 없이 모든 포인트가 Periodic으로 기록 + +--- + +## 2. Phase 1: 기존 히스토리 루틴에서 디지털 포인트 제외 + +### 구현 위치 +`src/Infrastructure/Database/ExperionDbContext.cs:730` - `SnapshotToHistoryAsync()` + +### 변경 내용 +1. 디지털 태그 식별 기준 정의 +2. 기존 SnapshotToHistoryAsync 메서드에 Where 조건 추가 + +### 예상 코드 변경 + +```csharp +// 메서드 시그니처에 디지털 필터 옵션 추가 +public async Task SnapshotToHistoryAsync(bool includeDigital = false) +{ + var now = DateTime.UtcNow; + var query = _ctx.RealtimePoints.AsQueryable(); + + // 디지털 제외 (기본값) + if (!includeDigital) + { + var digitalTagNames = await GetDigitalTagNamesAsync(); + 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(); + _logger.LogDebug("[ExperionDb] history 스냅샷: {Count}건 @ {Time:HH:mm:ss}", saved, now); + return saved; +} + +// 디지털 태그 목록 조회 (캐싱 고려) +private async Task> GetDigitalTagNamesAsync() +{ + // node_map_master에서 data_type = i=7594 인 태그 조회 + // 또는 tag_metadata에서 value 패턴이 {X | STATE | } 형태인 태그 +} +``` + +--- + +## 3. Phase 2: 이벤트 히스토리 테이블 설계 + +### 테이블: event_history_table + +```sql +-- 이벤트 히스토리 테이블 생성 +CREATE TABLE event_history_table ( + id BIGSERIAL PRIMARY KEY, + tagname TEXT NOT NULL, + node_id TEXT NOT NULL, + prev_value TEXT, -- 이전 상태 (예: "{0 | L-STOP | }") + curr_value TEXT, -- 현재 상태 (예: "{0 | RUN | }") + event_type TEXT NOT NULL, -- CHANGE, TRIP, RUN, ALARM, NORMAL, INTERLOCK + event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + area TEXT, -- P1, P6, P8 등 (tag_metadata에서 조회) + section TEXT, -- 6-1차, 6-2차 (태그 번호 기반: 61xx=6-1차, 62xx=6-2차) + duration_seconds INT, -- 이전 상태 지속 시간 (초) + metadata JSONB, -- 추가 정보 (선택): {"alarm_priority": 3, "interlock_tag": "lica-6113-trip"} + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 인덱스 (쿼리 성능 최적화) +CREATE INDEX idx_event_history_tagname_time ON event_history_table(tagname, event_time DESC); +CREATE INDEX idx_event_history_area_time ON event_history_table(area, event_time DESC); +CREATE INDEX idx_event_history_section_time ON event_history_table(section, event_time DESC); +CREATE INDEX idx_event_history_event_type ON event_history_table(event_type, event_time DESC); +CREATE INDEX idx_event_history_tagname_event_type ON event_history_table(tagname, event_type, event_time DESC); + +-- 테이블 설명 주석 +COMMENT ON TABLE event_history_table IS '디지털 포인트 상태 변경 이벤트 히스토리'; +COMMENT ON COLUMN event_history_table.event_type IS 'CHANGE: 일반 변경, TRIP: 정지, RUN: 가동, ALARM: 알람, NORMAL: 정상복귀, INTERLOCK: 인터록'; +``` + +### event_type 정의 + +| event_type | 설명 | 트리거 조건 | +|------------|------|-------------| +| `CHANGE` | 일반 상태 변경 | prev_value != curr_value | +| `TRIP` | 장치 정지 | curr_value에 "L-STOP", "STOP", "TRIP" 포함 | +| `RUN` | 장치 가동 | curr_value에 "RUN", "START" 포함 | +| `ALARM` | 알람 발생 | alarm 상태 감지 | +| `NORMAL` | 정상 복귀 | alarm clear | +| `INTERLOCK` | 인터록 발생 | 인터록 관련 태그 (-il-rst, -trip) | +| `SHUTDOWN` | 계획정지 | 명시적 shutdown 신호 | +| `STARTUP` | 계획가동 | 명시적 startup 신호 | + +--- + +## 4. Phase 3: 디지털 포인트 상태변화 감지 및 기록 + +### 4.1 새 서비스: 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.Text.RegularExpressions; + +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 Dictionary _previousStates = new(); + private readonly HashSet _digitalTagNames = new(); + private readonly ConcurrentDictionary _areaCache = new(); + private readonly int _checkIntervalMs = 1000; + private readonly int _debounceSeconds = 5; // 동일 상태 반복 방지 + + 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); + + // 초기 디지털 태그 목록 로드 + await LoadDigitalTagNamesAsync(); + // 현재 상태 로드 (서비스 재시작 시 상태 손실 방지) + await LoadCurrentStatesAsync(); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(_checkIntervalMs, stoppingToken); + await DetectAndRecordChangesAsync(); + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + _logger.LogError(ex, "[DigitalEventDetector] 감지 오류"); + } + } + + _logger.LogInformation("[DigitalEventDetector] 종료"); + } + + private async Task LoadDigitalTagNamesAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var digitalTags = await db.GetDigitalTagNamesAsync(); + _digitalTagNames.UnionWith(digitalTags); + _logger.LogInformation("[DigitalEventDetector] 디지털 태그 {Count}개 로드됨", _digitalTagNames.Count); + } + + private async Task LoadCurrentStatesAsync() + { + 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() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // 디지털 포인트만 조회 + var currentPoints = await db.GetDigitalPointsAsync(); + + foreach (var point in currentPoints) + { + var tagName = point.TagName; + var currValue = point.LiveValue; + + // 첫 등장 시 이전 값 초기화 (이벤트 기록 안 함) + if (!_previousStates.TryGetValue(tagName, out var prevState)) + { + _previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, null); + continue; + } + + // 값 변경 감지 + if (prevState.Value != currValue) + { + var eventType = DetermineEventType(prevState.Value, currValue); + + // Debounce: 동일 상태로의 반복만 방지, 상태 전환(TRIP→RUN 등)은 항상 기록 + if (prevState.EventType == eventType && prevState.Value == currValue && + (DateTime.UtcNow - prevState.Timestamp).TotalSeconds < _debounceSeconds) + { + _previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, eventType); + continue; + } + + // 이벤트 기록 + var duration = (int)(DateTime.UtcNow - prevState.Timestamp).TotalSeconds; + await db.RecordDigitalEventAsync(new DigitalEventRecord + { + TagName = tagName, + NodeId = point.NodeId, + PrevValue = prevState.Value, + CurrValue = currValue, + EventType = eventType, + EventTime = DateTime.UtcNow, + DurationSeconds = duration, + Area = ExtractArea(tagName), + Section = ExtractSection(tagName), + Metadata = BuildMetadata(tagName, eventType, currValue) + }); + + _logger.LogDebug("[DigitalEventDetector] {Tag}: {Event} ({Prev} → {Curr})", + tagName, eventType, prevState.Value, currValue); + + _previousStates[tagName] = new DigitalPointState(currValue, DateTime.UtcNow, 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") == true && !currValue.Contains("ALARM")) + return "NORMAL"; + return "CHANGE"; + } + + private string? ExtractArea(string tagName) + { + if (_areaCache.TryGetValue(tagName, out var area)) return area; + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + area = db.GetAreaByTagNameAsync(tagName).GetAwaiter().GetResult(); + + if (area != null) + _areaCache[tagName] = area; + return area; + } + + private string? ExtractSection(string tagName) + { + // 태그 번호로 구간 추출: ficq-6101 → 6-1차, p-6202 → 6-2차 + // 첫 번째 숫자-두 번째 숫자 패턴 (일반화) + var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}"); + if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차"; + return "기타"; + } + + private string? BuildMetadata(string tagName, string eventType, string currValue) + { + // 인터록 태그인 경우 메타데이터 추가 + if (tagName.Contains("-il-") || tagName.Contains("-trip")) + { + return System.Text.Json.JsonSerializer.Serialize(new + { + interlock_tag = tagName, + event_type = eventType, + raw_value = currValue + }); + } + return null; + } +} +``` + +### 4.2 IExperionServices 인터페이스 확장 + +**참고**: `DigitalEventRecord`는 서비스 계층 DTO, `EventHistoryRecord`는 EF Core Entity 클래스로 별도 정의 필요 + +```csharp +// src/Core/Application/Interfaces/IExperionServices.cs에 추가 +public interface IExperionDbService +{ + // ... 기존 메서드 ... + + /// 디지털 태그 이름 목록 조회 + Task> GetDigitalTagNamesAsync(); + + /// 디지털 포인트 현재 값 조회 + Task> GetDigitalPointsAsync(); + + /// 디지털 이벤트 기록 + Task RecordDigitalEventAsync(DigitalEventRecord record); + + /// 태그명으로 area 조회 (tag_metadata 기반) + Task GetAreaByTagNameAsync(string tagName); +} + +// 새로운 모델 클래스 (서비스 계층 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; } +} +``` + +### 4.2.1 EventHistoryRecord Entity 클래스 정의 + +```csharp +// src/Core/Domain/Entities/ExperionEntities.cs에 추가 +/// event_history_table — 디지털 포인트 상태 변경 이벤트 +[Table("event_history_table")] +public class EventHistoryRecord +{ + [Column("id")] public int 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; +} +``` + +### 4.3 ExperionDbContext 구현 추가 + +```csharp +// src/Infrastructure/Database/ExperionDbContext.cs에 추가 + +public async Task> GetDigitalTagNamesAsync() +{ + // node_map_master에서 data_type = 'i=7594' 인 태그 조회 + // 또는 정규식으로 value 패턴이 {X | STATE | } 형태인 태그 + return await _ctx.RealtimePoints + .Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{")) + .Select(p => p.TagName) + .ToListAsync(); +} + +public async Task> GetDigitalPointsAsync() +{ + return await _ctx.RealtimePoints + .Where(p => p.LiveValue != null && p.LiveValue.StartsWith("{")) + .ToListAsync(); +} + +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; +} + +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(); +} + +// DbSet 추가 +public DbSet EventHistoryRecords => Set(); +``` + +### 4.4 Program.cs 등록 + +```csharp +// src/Web/Program.cs에 추가 +builder.Services.AddHostedService(); +``` + +--- + +## 5. Phase 4: 보고서 기능 설계 + +### 5.1 요구사항 분석 + +**입력 예시**: "지난밤 6차 플랜트의 현황 보고" + +**출력 형식**: +``` +[제목] 2026-05-10 6차 플랜트 현황 보고 +[시간] 2026-05-09 00:00:00 ~ 2026-05-10 00:00:00 + +[6-1차 (태그명: %-61%)] +- 00:00:00 p-6102 Trip 정지 → 00:05:30 가동 (정지 시간: 5분 30초) +- 알람 발생: 3회 +- ficq-6101.qv.value (투입량): 12,500 kg +- ficq-6118.qv.value (생산량): 11,200 kg + +[6-2차 (태그명: %-62%)] +- 02:30:00 p-6201 Trip 정지 → 02:45:00 가동 (정지 시간: 15분) +- 알람 발생: 1회 +``` + +### 5.2 필요한 데이터 + +| 데이터 | 출처 | 설명 | +|--------|------|------| +| 구간별 이벤트 | event_history_table | 6-1차 (61xx), 6-2차 (62xx) | +| Trip/Run 쌍 | event_history_table | 정지~가동 시간 계산 | +| 알람 횟수 | event_history_table | event_type = 'ALARM' count | +| 투입량 | history_table 또는 별도 테이블 | ficq-6101, ficq-6201 | +| 생산량 | history_table 또는 별도 테이블 | ficq-6118, ficq-6218 | + +### 5.3 구간(세션) 구분 로직 + +**참고**: Phase 3의 `ExtractSection`과 동일한 로직으로 통일 (진단 HIGH #1 반영) + +```csharp +private string? ExtractSection(string tagName) +{ + // 태그 번호로 구간 추출: ficq-6101 → 6-1차, p-6202 → 6-2차 + // 첫 번째 숫자-두 번째 숫자 패턴 (일반화) + var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}"); + if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차"; + return "기타"; +} +``` + +### 5.4 적산값 처리 + +**문제**: history_table의 periodic snapshot에서 차이를 계산하면 노이즈가 많음 + +**해결방안**: event_history_table에 적산 정보 기록 + +```csharp +// DigitalEventDetectorService에 추가 +// TRIP 발생 시 현재 적산값 저장 +// RUN 발생 시 (TRIP 시점 적산값 - 현재 적산값) = 정지 시간 동안의 미투입량 + +// 또는 별도 테이블 +CREATE TABLE accumulated_events ( + id BIGSERIAL PRIMARY KEY, + tagname TEXT NOT NULL, + event_type TEXT NOT NULL, -- TRIP_START, RUN_START + value_at_event DOUBLE, + event_time TIMESTAMPTZ NOT NULL +); +``` + +### 5.5 보고서 쿼리 예시 + +```sql +-- 6차 플랜트 6-1차 구간 이벤트 요약 +SELECT + section, + event_type, + COUNT(*) as count, + MIN(event_time) as first_occurrence, + MAX(event_time) as last_occurrence +FROM event_history_table +WHERE area = 'P6' + AND section = '6-1차' + AND event_time BETWEEN '2026-05-09 00:00:00' AND '2026-05-10 00:00:00' +GROUP BY section, event_type +ORDER BY event_type; + +-- Trip별 정지 시간 계산 (TRIP → RUN 쌍 찾기) +WITH trip_events AS ( + SELECT + tagname, + event_time as trip_time, + LEAD(event_time) OVER (PARTITION BY tagname ORDER BY event_time) as run_time, + LEAD(event_type) OVER (PARTITION BY tagname ORDER BY event_time) as next_event_type + FROM event_history_table + WHERE event_type = 'TRIP' AND area = 'P6' +) +SELECT + tagname, + trip_time, + run_time, + EXTRACT(EPOCH FROM (run_time - trip_time)) / 60 as duration_minutes +FROM trip_events +WHERE next_event_type = 'RUN'; +``` + +--- + +## 6. 구현 로드맵 + +| Phase | 작업 | 우선순위 | 예상 시간 | +|-------|------|----------|-----------| +| 1 | 디지털 태그 식별 로직 추가 | 높음 | 1일 | +| 1 | SnapshotToHistoryAsync 수정 | 높음 | 0.5일 | +| 2 | event_history_table 생성 | 높음 | 0.5일 | +| 3 | DigitalEventDetectorService 구현 | 높음 | 2일 | +| 3 | IExperionServices 확장 | 높음 | 1일 | +| 4 | 보고서 쿼리 작성 | 중간 | 2일 | +| 4 | API 엔드포인트 추가 | 중간 | 1일 | + +--- + +## 7. 고려사항 + +### 7.1 중복 이벤트 방지 +- Debounce 로직 적용 (기본 5초) +- 동일 상태로의 반복 발생 시 무시 + +### 7.2 마이그레이션 +- 기존 history_table의 디지털 데이터 보존 또는 별도 아카이브 +- 새로운 event_history_table로의 데이터 이전 (선택) + +### 7.3 성능 +- 디지털 태그 수: ~500개 +- 감지 주기: 1초 +- 예상 INSERT: 초당 수십 건 (변경 시) + +### 7.4 확장성 +- 알람 우선순위 정보 메타데이터에 추가 +- 인터록 체인 추적 (상위/interlock 원인 태그 기록) + +--- + +## 8. 부록: 테스트 계획 + +### 8.1 단위 테스트 +- DetermineEventType 메서드 테스트 +- ExtractSection 메서드 테스트 + +### 8.2 통합 테스트 +- 실제 OPC UA 연결 없이 모의 데이터로 동작 확인 +- event_history_table에 올바른 기록 확인 + +### 8.3 성능 테스트 +- 500개 디지털 태그 처리 시 CPU/메모리 사용량 +- DB INSERT 성능 + +--- + +## 9. 참고 자료 + +- OPC UA LocalizedText (i=7594): 로컬라이즈된 텍스트 타입, `{locale | text | }` 형태 +- Experion 태그 명명 규칙: {type}-{unit}{number}.{attribute} +- P6 플랜트 구조: 61xx = 6-1차, 62xx = 6-2차 \ No newline at end of file diff --git a/프로젝트진단-MiniMax-M2.5-Free.md b/프로젝트진단-MiniMax-M2.5-Free.md new file mode 100644 index 0000000..15a63ba --- /dev/null +++ b/프로젝트진단-MiniMax-M2.5-Free.md @@ -0,0 +1,260 @@ +# ExperionCrawler 프로젝트 문제점 진단 보고서 + +> 분석일: 2026-05-10 | 도구: MiniMax-M2.5-Free | 프로젝트: ExperionCrawler + +--- + +## 🔴 심각 (Critical) — 즉시 수정 필요 + +### 1. P&ID 컨트롤러 중복 충돌 + +- **위치**: `src/Web/Controllers/ExperionControllers.cs` (줄 981~1158) + `src/Web/Controllers/PidController.cs` (226줄) +- **문제**: 두 개의 컨트롤러가 동일한 라우트 `/api/pid`를 공유 +- **영향**: 의도치 않은 동작,,哪个 컨트롤러가 처리될지 불확실 +- **해결**: `ExperionPidController` 또는 `PidController` 중 하나 제거, Program.cs의 `ExcludedControllersFeatureProvider` 로직 재검토 + +### 2. SQL 인젝션 취약점 (EF1002) + +- **위치**: `src/Infrastructure/Database/ExperionDbContext.cs` + - 줄 1309, 1313, 1316, 1323, 1332, 1335, 1342, 1345, 1377, 1397, 1407 +- **문제**: `ExecuteSqlRawAsync` + 문자열 보간법 조합 + ```csharp + var createHypertableSql = $"SELECT create_hypertable('{request.TableName}'::regclass, ..."; + ``` +- **현황**: `#pragma warning disable EF1002`로 경고 억제 → 위험성 은폐 +- **평가**: `IsValidSqlIdentifier()` 검증이 있긴 하지만, 컬럼명 일부 검증 안함 +- **해결**: 모든 동적 식별자를 `NpgsqlParameter`로 전환, 또는 Raw SQL 대신 EF Core LINQ 사용 + +### 3. AssetLoader에 DbContext 이중 관리 + +- **위치**: `src/Infrastructure/Csv/AssetLoader.cs:14~17` +- **문제**: + - NpgsqlConnection을 직접 생성 (`configuration.GetConnectionString("DefaultConnection")`) + - ExperionDbService와 동시 접속 시 DB 연결 수 증가 + - DbContext 기반 로깅/트랜잭션 무시 +- **해결**: AssetLoader를 DbContext 기반으로 리팩토링 + +--- + +## 🟠 높음 (High) —尽快修正 + +### 4. ExperionControllers.cs 단일 파일 1158줄 + +- **위치**: `src/Web/Controllers/ExperionControllers.cs` +- **문제**: 9개 이상의 컨트롤러가 하나의 파일에 집중 (ExperionCertificate, Connection, Crawl, Database, PointBuilder, TagMetadata, Realtime, History, OpcServer, NodeMap, Hypertable, Fast, ExperionPid) +- **영향**: 유지보수困难, Merge 충돌 빈번, 코드 검색 어려움 +- **해결**: 각 컨트롤러를 별도 파일로 분리 + +### 5. ExperionDbContext.cs 단일 파일 1493줄 + +- **위치**: `src/Infrastructure/Database/ExperionDbContext.cs` +- **문제**: 서비스 + DbContext + 엔티티 config + DTO 레코드 + SQL 헬퍼가 모두 하나의 파일 +- **해결**: DbContext/Service 분리, DTO 레코드 → Core/Application/DTOs/ 이동 + +### 6. Console.WriteLine 디버그 코드 잔존 + +- **위치**: `src/Infrastructure/Database/ExperionDbContext.cs` 약 15개 이상 + ```csharp + Console.WriteLine($"[DEBUG] 하이퍼테이블 생성 SQL: {createHypertableSql}"); + Console.WriteLine($"[ERROR] {errorDetails}"); + ``` +- **문제**: 프로덕션에서 Console 출력이 로그로 출력됨, ILogger 미사용 +- **해결**: 모든 Console.WriteLine → _logger.LogInformation/Warning/Error로 교체 + +### 7. ExperionFastService MonitorLoop에서 매 반복마다 Scope 생성 + +- **위치**: `src/Infrastructure/OpcUa/ExperionFastService.cs:204~220` +- **문제**: + ```csharp + foreach (var kvp in _sessions.ToList()) + { + using var scope = _scopeFactory.CreateScope(); // 매 세션마다 new scope + var db = scope.ServiceProvider.GetRequiredService(); + } + ``` +- **영향**: 3개 세션 × 1초 간격 = 초당 3개 scope + 3개 connection +- **해결**: 모니터 루프 수준에서 scoped service를 재사용, 또는 배치 처리 + +### 8. McpClient HttpClient Timeout 30분 + +- **위치**: `src/Infrastructure/Mcp/McpClient.cs:26` + ```csharp + Timeout = TimeSpan.FromSeconds(1800) // 30분! + ``` +- **문제**: Python MCP 서버 응답 지연 시 최대 30분 대기 +- **해결**: Timeout合理化 (예: 60초), 요청 취소 시 HttpClient 정리 개선 + +### 9. ExperionRealtimeService 재진입 방지 로직 불완전 + +- **위치**: `src/Infrastructure/OpcUa/ExperionRealtimeService.cs:198~217` +- **문제**: + ```csharp + _restarting = true; + try { if (_running) { await StopAsync(); } } // StopAsync 안에서 _restarting=false + finally { _restarting = false; } // race condition 가능 + ``` +- **해결**: `_restarting` 플래그 관리 개선, CancellationToken 기반 제어 + +### 10. history_table 하이퍼테이블 자동 생성 안 됨 + +- **위치**: `src/Infrastructure/Database/ExperionDbContext.cs:328` + ```csharp + // history 테이블은 수동으로 하이퍼테이블 생성 필요 + ``` +- **문제**: InitializeAsync()에서 fast_record만 hypertable 생성, history_table은 일반 테이블로 존재 +- **영향**: TimescaleDB 시계열 최적화 미적용, 연속 집계/압축/보존 정책 미적용 +- **해결**: InitializeAsync()에 history_table hypertable 생성 로직 추가 + +--- + +## 🟡 중간 (Medium) — 개선 권장 + +### 11. ExperionServerConfig.ApplicationUri 미설정 + +- **위치**: `src/Web/Controllers/ExperionControllers.cs:111~118` (MapConfig) +- **문제**: `MapConfig()`에서 `ApplicationUri` 매핑 안 함 → 인증서 생성 시에만 수동 조성 +- **해결**: MapConfig에 ApplicationUri 포함, 또는 ExperionServerConfig 생성자에서 자동 설정 + +### 12. Singleton 서비스의 mutable 상태 (Thread-unsafe) + +- **위치**: `ExperionRealtimeService`, `ExperionOpcServerService` +- **문제**: + - `_running`, `_statusMsg`, `_subscribedCount`, `_currentCfg` → thread-unsafe + - `_startedAt`, `_endpointUrl` → thread-unsafe +- **해결**: `volatile` 키워드 또는 `lock` 사용, 또는 Stateless 서비스 설계 + +### 13. P&ID 서비스가 MCP에 강결합 + +- **위치**: `PidExtractorService`, `PidGraphService` — `McpClient` 직접 의존 +- **문제**: MCP 서버 죽으면 P&ID 추출 전체 실패, Fallback mechanism 없음 +- **해결**: 추출 실패 시 로컬 LLM (vLLM 직접 호출) 또는 파일 기반 백업 + +### 14. TextToSqlService 자연어 파싱 불안정 + +- **위치**: `src/Core/Application/Services/TextToSqlService.cs:226~349` +- **문제**: 350줄에 걸친 한국어 태그명 추출 로직, 테스트 케이스 없음, Edge case 시 null/빈 SQL 반환 +- **해결**: 단위 테스트 추가, 파싱 로직 분리 (TagExtractor 서비스) + +### 15. HistoryIntervalQuery에서 NpgsqlCommand 직접 사용 + +- **위치**: `ExperionDbContext.cs:810` + ```csharp + using var cmd = new NpgsqlCommand(sql, _ctx.Database.GetDbConnection() as NpgsqlConnection); + ``` +- **문제**: DbContext connection 직접 cast, EF Core 파이프라인 우회, transaction handling 없음 +- **해결**: FromSqlRaw 또는 Dapper 사용 고려 + +### 16. FastRecord PK 마이그레이션 로직 복잡 + +- **위치**: `ExperionDbContext.cs:219~234` — EF Core + Raw SQL 혼용 +- **문제**: EF Core 마이그레이션 정책과 충돌 가능, EF1002 경고 +- **해결**: EF Core 마이그레이션으로 통합, 또는 Raw SQL 제거 + +### 17. ExperionOpcServerService Dispose 불완전 + +- **위치**: `src/Infrastructure/OpcUa/ExperionOpcServerService.cs:299~315` +- **문제**: + - `IHostedService` + `IDisposable` 동시 구현 + - DisposeAsync()와 IDisposable.Dispose() 중복 가능 + - deprecated Stop() API 사용 (#pragma warning disable CS0618) +- **해결**: IAsyncDisposable 구현, deprecated API 제거 + +### 18. McpServerHostedService 프로세스 관리 미흡 + +- **위치**: `src/Infrastructure/Mcp/McpServerHostedService.cs` +- **문제**: + - 프로세스 crash 후 자동 재시작 없음 + - Ping 실패 시 warning만 로그, 복구 시도 안 함 + - Health check 1초 × 30회 = 30초 후放棄 +- **해결**: Watchdog 패턴 적용, 재시작 로직 추가 + +--- + +## 🟢 낮음 (Low) — 리팩토링 시 고려 + +### 19. CORS AllowAnyOrigin + +- **위치**: `Program.cs:121` +- **문제**: 개발용으로는 OK, 프로덕션에서는 제한 필요 +- **해결**: AllowedOrigins 명시적 설정 + +### 20. appsettings.json에 Password 평문 + +- **위치**: `appsettings.json:20` +- **문제**: 연결 문자열에 평문 비밀번호 +- **해결**: 환경변수 또는 .NET Secret Manager 사용 + +### 21. JsonSerializerOptions 불일치 + +- **문제**: + - RealtimeService: 기본 JsonSerializer 옵션 + - McpClient: `PropertyNameCaseInsensitive = true` + - McpService: `[JsonPropertyName]` 미적용 +- **해결**: 공통 JsonSerializerOptions 정의, 또는 PascalCase 통일 + +### 22. v_tag_summary VIEW 재생성 매번 + +- **위치**: `ExperionDbContext.cs:304` — 앱 시작마다 DROP+CREATE +- **해결**: Materialized View + REFRESH USING 또는 조건부 CREATE + +### 23. ExperionHistoryService 재연결 로직 없음 + +- **문제**: RealtimeService 재연결하지만 HistoryService는 없음 +- **해결**: 연결 lost 감지 후 재연결 로직 추가 + +### 24. TagMetadata base_tag 길이 제한 없음 + +- **문제**: 매우 긴 태그명 삽입 시 DB 오버플로우 가능 +- **해결**: entity 정의에 .HasMaxLength() 추가 + +### 25. Logging 수준 불균형 + +- **문제**: HistoryService는 Warning, 나머지는 Information +- **해결**: appsettings.json 통일 + +--- + +## 📊 요약 + +| Severity | Count | 주요 카테고리 | +|---|---|---| +| 🔴 Critical | 3 | SQL 인젝션, 컨트롤러 중복, DbContext 이중 관리 | +| 🟠 High | 7 | 파일 크기, 디버그 코드 잔존, 리소스 관리, 유실된 설정 | +| 🟡 Medium | 8 | 결합도, threading, transaction, disposal | +| 🟢 Low | 6 | CORS, JSON, logging, naming | + +## 🔥 즉시 수정 우선순위 + +1. **#2 (SQL 인젝션)** — 보안 취약점, 즉시 패치 +2. **#1 (컨트롤러 충돌)** — API 동작 불확실성 +3. **#6 (Console.WriteLine)** — 프로덕션 로그 오염 +4. **#10 (history_table hypertable)** — 성능/스토리지 최적화 미적용 + +## 관련 파일 인덱스 + +``` +src/Web/ +├── Controllers/ +│ ├── ExperionControllers.cs ← 1158줄, 다중 컨트롤러 (#4) +│ ├── PidController.cs ← 중복 컨트롤러 (#1) +│ ├── TextToSqlController.cs ← 375줄 +│ └── PidGraphController.cs ← 191줄 +├── Program.cs ← CORS (#19), DI 注册 +└── appsettings.json ← 평문 비밀번호 (#20) +src/Infrastructure/ +├── Database/ +│ └── ExperionDbContext.cs ← 1493줄, SQL 인젝션 (#2), Console (#6) +├── OpcUa/ +│ ├── ExperionRealtimeService.cs ← threading (#9, #12) +│ ├── ExperionOpcServerService.cs ← dispose (#17), threading (#12) +│ └── ExperionFastService.cs ← scope leak (#7) +├── Mcp/ +│ ├── McpClient.cs ← timeout (#8) +│ └── McpServerHostedService.cs ← watchdog 없음 (#18) +└── Csv/ + └── AssetLoader.cs ← Npgsql 직접 (#3) +src/Core/Application/Services/ +├── TextToSqlService.cs ← 파싱 불안정 (#14) +├── PidExtractorService.cs ← MCP 강결합 (#13) +└── PidGraphService.cs ← MCP 강결합 (#13) +``` \ No newline at end of file