558 lines
26 KiB
Markdown
558 lines
26 KiB
Markdown
# ExperionCrawler — 작업 이력
|
||
|
||
## 작업 규칙
|
||
- 복잡한 작업은 항상 todo 목록 먼저 생성
|
||
- 각 단계 시작 전 todo 목록 확인
|
||
- 단계 완료 후 즉시 completed 표시
|
||
|
||
## 완료된 작업
|
||
|
||
### 로그 정리 — 스냅샷 로그 2줄 → 1줄 (2026-04-15)
|
||
|
||
#### 증상
|
||
히스토리 스냅샷 1회 저장마다 터미널에 로그 2줄 출력:
|
||
```
|
||
[ExperionDb] history 스냅샷: 1752건 @ 01:14:18
|
||
[HistoryService] 스냅샷 저장: 1752건
|
||
```
|
||
|
||
#### 원인
|
||
DB 저장 완료 후 `ExperionDbService`에서 `LogInformation`, 호출자 `ExperionHistoryService`에서도 `LogInformation`. 저장은 1회이나 로그가 2줄.
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `src/Infrastructure/Database/ExperionDbContext.cs` | `SnapshotToHistoryAsync()` 내부 로그를 `LogInformation` → `LogDebug`로 변경 |
|
||
|
||
#### 결과
|
||
운영 로그(`Information` 레벨)에서 `[HistoryService] 스냅샷 저장: N건` 1줄만 출력.
|
||
|
||
---
|
||
|
||
### 버그 수정 — Ctrl+C 종료 시 자동재시작 플래그 삭제 오류 (2026-04-15)
|
||
|
||
#### 증상
|
||
Ctrl+C로 앱 종료 시 `realtime_autostart.json` 플래그 파일이 삭제되어, 재기동 후 자동 구독 시작이 동작하지 않음.
|
||
|
||
#### 원인
|
||
`IHostedService.StopAsync(CancellationToken)` (앱 종료 훅)이 UI 수동 중지 메서드인 `StopAsync()`를 그대로 호출. `StopAsync()`는 플래그 파일을 삭제하므로 앱 종료와 수동 중지를 구분하지 못했음.
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `src/Infrastructure/OpcUa/ExperionRealtimeService.cs` | `IHostedService.StopAsync(CancellationToken)` 분리 — `_cts.Cancel()` + 태스크 대기만 수행, 플래그 파일 삭제 없음 |
|
||
|
||
#### 동작 구분
|
||
|
||
| 종료 방식 | 플래그 파일 |
|
||
|----------|------------|
|
||
| Ctrl+C (앱 종료) | **유지** → 재기동 시 자동 구독 시작 |
|
||
| UI 중지 버튼 | **삭제** → 재기동 후 자동 시작 없음 |
|
||
|
||
---
|
||
|
||
### 버그 수정 — 이력 조회 중복 키 예외 (2026-04-15)
|
||
|
||
#### 증상
|
||
이력 조회 시 서버 500 에러:
|
||
```
|
||
System.ArgumentException: An item with the same key has already been added.
|
||
Key: p-6102.hzset.fieldvalue
|
||
at ExperionDbService.QueryHistoryAsync ... line 342
|
||
```
|
||
|
||
#### 원인
|
||
`history_table`에 동일 `recorded_at` + 동일 `tagname` 조합이 중복 저장된 행 존재. `.ToDictionary(r => r.TagName, r => r.Value)` 호출 시 중복 키로 예외 발생.
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `src/Infrastructure/Database/ExperionDbContext.cs` | `TagName` 기준 `GroupBy` 추가 → 중복 시 `.Last().Value` 사용 |
|
||
|
||
---
|
||
|
||
### 기능 추가 — 이력 조회 날짜/시간 팝업 피커 (2026-04-15)
|
||
|
||
#### 배경
|
||
- `datetime-local` 입력이 Windows 브라우저 로케일에 따라 AM/PM 12시간제로 표시됨
|
||
- 서버(Ubuntu UTC) / 브라우저(Windows KST) 시간대 차이로 인한 표시 혼란
|
||
|
||
#### 설계
|
||
- `datetime-local` 입력 제거 → 클릭 시 커스텀 달력+시간 팝업 오픈
|
||
- 달력: 월 이동 가능, 오늘 날짜 amber 강조, 선택일 반전 표시
|
||
- 시간: 24시간제, `−`/`+` 버튼 또는 직접 입력 (0–23시, 0–59분)
|
||
- 확인 시 `YYYY-MM-DD HH:MM` 형식으로 필드 표시
|
||
- hidden input에 로컬 시간 문자열 저장 → `new Date(...).toISOString()`으로 KST→UTC 변환 후 서버 전송 (기존 로직 유지)
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `src/Web/wwwroot/index.html` | `datetime-local` 2개 → `.dt-display` + `hidden input` 교체; 팝업 HTML(`#dt-popup`, `#dt-overlay`) 추가 |
|
||
| `src/Web/wwwroot/css/style.css` | `.dt-popup`, `.dt-cal-grid`, `.dt-day`, `.dt-time-row` 등 피커 전용 다크 테마 스타일 추가; 기존 `datetime-local` AM/PM 숨김 CSS 제거 |
|
||
| `src/Web/wwwroot/js/app.js` | `dtOpen()`, `dtRenderCal()`, `dtSelectDay()`, `dtPrevMonth()`, `dtNextMonth()`, `dtAdjTime()`, `dtClampTime()`, `dtConfirm()`, `dtClear()`, `dtClose()` 구현; `histReset()`에서 `dtClearField()` 호출로 표시 텍스트 초기화 |
|
||
|
||
#### 빌드 결과
|
||
- 경고 8건 (기존 동일), **에러 0건** — 빌드 성공
|
||
|
||
---
|
||
|
||
### 버그 수정 — 단일 태그 읽기 성공/실패 판정 오류 (2026-04-15)
|
||
|
||
#### 증상
|
||
서버접속테스트 페이지에서 단일 태그 읽기 시, OPC UA 서버가 `BadNodeIdUnknown(0x80340000)` 등 에러 상태 코드를 반환해도 "✅ 읽기 성공"으로 표시되는 버그.
|
||
|
||
#### 원인
|
||
`ExperionOpcClient.cs`의 `ReadTagsAsync` 내부에서 `StatusCode` 값과 무관하게 `Success = true`를 하드코딩해서 `ExperionReadResult`를 생성했음.
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `src/Infrastructure/OpcUa/ExperionOpcClient.cs` | `StatusCode.IsGood()` 결과를 `Success` 플래그로 사용. Bad이면 `Success=false`, `Value=null`, `Error`에 상태 코드 메시지 설정 |
|
||
|
||
#### 결과
|
||
`BadNodeIdUnknown` 등 Bad 상태 코드 수신 시 → ❌ 읽기 실패로 정상 표시
|
||
|
||
#### 빌드 결과 (경고 상세)
|
||
경고 8건, **에러 0건** — 빌드 성공
|
||
|
||
| # | 파일 | 내용 |
|
||
|---|------|------|
|
||
| 1 | `ExperionOpcClient.cs:108` | `Session.Create()` → `ISessionFactory.CreateAsync` 사용 권장 |
|
||
| 2 | `ExperionRealtimeService.cs:161` | `Subscription.ApplyChanges()` → `ApplyChangesAsync()` 사용 권장 |
|
||
| 3 | `ExperionRealtimeService.cs:168` | 동일 |
|
||
| 4 | `ExperionRealtimeService.cs:277` | `Subscription.Create()` → `CreateAsync()` 사용 권장 |
|
||
| 5 | `ExperionRealtimeService.cs:346` | `Subscription.Delete()` → `DeleteAsync()` 사용 권장 |
|
||
| 6 | `ExperionRealtimeService.cs:424` | `Session.Create()` → `ISessionFactory.CreateAsync` 사용 권장 |
|
||
| 7–8 | (위 항목 중 중복 카운트) | — |
|
||
|
||
전부 OPC UA SDK가 동기 메서드를 `[Obsolete]`로 표시하고 비동기 버전을 권장하는 경고. 기능상 문제 없음.
|
||
|
||
---
|
||
|
||
### 노드맵 대시보드 구현 (2026-04-14)
|
||
|
||
node_map_master 테이블을 조회·탐색할 수 있는 웹 대시보드를 풀스택으로 구현했다.
|
||
|
||
#### 수정된 파일
|
||
|
||
| 파일 | 내용 |
|
||
|------|------|
|
||
| `src/Core/Application/Interfaces/IExperionServices.cs` | `IExperionDbService`에 `GetMasterStatsAsync()` / `QueryMasterAsync()` 추가, `NodeMapStats` / `NodeMapQueryResult` record 추가 |
|
||
| `src/Infrastructure/Database/ExperionDbContext.cs` | `ExperionDbService`에 두 메서드 구현 (통계·필터 조회, 페이지네이션) |
|
||
| `src/Web/Controllers/ExperionControllers.cs` | `ExperionNodeMapController` 추가 (`GET /api/nodemap/stats`, `GET /api/nodemap/query`) |
|
||
| `src/Web/wwwroot/index.html` | 사이드바 05번 탭 추가, `#pane-nm-dash` 섹션 추가 (통계 카드·필터폼·페이지네이션·테이블) |
|
||
| `src/Web/wwwroot/js/app.js` | `nmLoad()` / `nmQuery()` / `nmPrev()` / `nmNext()` / `nmReset()` 구현, 탭 클릭 핸들러에 `nmLoad()` 호출 추가 |
|
||
| `src/Web/wwwroot/css/style.css` | `.nm-stat-row`, `.nm-cls`, `.nm-dtype`, `.pg`, `.btn-sm` 등 대시보드 전용 스타일 추가 |
|
||
|
||
#### 빌드 결과
|
||
- 경고 3건 (기존 경고 동일), **에러 0건** — 빌드 성공
|
||
|
||
#### 주의 사항
|
||
- 인증서 관련 코드(`ExperionCertificateService.cs`, 인증서 컨트롤러)는 일절 수정하지 않음
|
||
|
||
---
|
||
|
||
### 이름 필터 드롭다운 OR 조건 검색 (2026-04-14)
|
||
|
||
|
||
노드맵 대시보드의 이름 검색을 텍스트 입력에서 `name` 컬럼 고유값 풀다운 메뉴 4개로 교체, OR 조건 최대 4개 동시 선택 가능하도록 확장했다.
|
||
|
||
#### 수정된 파일
|
||
|
||
| 파일 | 내용 |
|
||
|------|------|
|
||
| `src/Core/Application/Interfaces/IExperionServices.cs` | `GetNameListAsync()` 추가; `QueryMasterAsync` 파라미터 `string? name` → `IEnumerable<string>? names` |
|
||
| `src/Infrastructure/Database/ExperionDbContext.cs` | `GetNameListAsync()` 구현 (distinct + 오름차순 정렬); `QueryMasterAsync`에서 `nameList.Contains(x.Name)` → EF가 `WHERE name IN (...)` SQL 생성 |
|
||
| `src/Web/Controllers/ExperionControllers.cs` | `GET /api/nodemap/names` 엔드포인트 추가; `Query` 액션 파라미터 `string? name` → `List<string>? names` (ASP.NET Core가 `?names=A&names=B` 자동 바인딩) |
|
||
| `src/Web/wwwroot/index.html` | "이름 검색" 텍스트 입력 제거 → `nf-name-1` ~ `nf-name-4` 4개 `<select>` 드롭다운 추가 |
|
||
| `src/Web/wwwroot/js/app.js` | `nmLoad()`에서 `/api/nodemap/names` 병렬 호출 후 4개 드롭다운 채우기; `nmQuery()`에서 선택 이름들을 `params.append('names', nm)`로 OR 전송; `nmReset()`에서 4개 드롭다운 초기화 |
|
||
| `src/Web/wwwroot/css/style.css` | `.nm-name-selects` (4열 그리드, 900px 이하 2열) 추가 |
|
||
|
||
#### 빌드 결과
|
||
- 경고 3건 (기존 경고 동일), **에러 0건** — 빌드 성공
|
||
|
||
---
|
||
|
||
## 구현 완료 (2026-04-14, todo.md 전항목)
|
||
|
||
### 빌드 결과
|
||
- 경고 6건 (기존 3건 + 신규 3건 OPC SDK deprecated API 경고), **에러 0건** — 빌드 성공
|
||
|
||
---
|
||
|
||
## 버그 수정 이력 (2026-04-14)
|
||
|
||
### 버그 1 — OPC UA 연결 시 OS TCP 타임아웃(최대 127초) 문제
|
||
|
||
#### 증상
|
||
- 접속 테스트 버튼을 눌렀을 때 수분간 응답 없는 것처럼 보임
|
||
- `ExperionRealtimeService`: "연결 오류, 30초 후 재시도" 로그가 매우 늦게 출력됨
|
||
- 오류: `System.Net.Sockets.SocketException (110): Connection timed out`
|
||
|
||
#### 원인
|
||
Linux에서 OPC UA 서버 IP가 응답 없음(firewall/unreachable)이면 OS TCP SYN 재전송 타임아웃이 최대 127초까지 걸림. `TransportQuotas.OperationTimeout`은 OPC UA 프로토콜 레벨 타임아웃이라 TCP connect 단계에는 적용되지 않음.
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `ExperionOpcClient.cs` | `SelectEndpointAsync`에 `CancellationTokenSource(10초)` 추가 — DiscoveryClient 생성 시 10초 타임아웃 적용 |
|
||
| `ExperionRealtimeService.cs` | 동일하게 `SelectEndpointAsync` 10초 타임아웃 적용 |
|
||
|
||
#### 결과
|
||
서버 미응답 시 127초 대기 → **10초 이내 실패** 처리
|
||
|
||
---
|
||
|
||
### 버그 2 — PostgreSQL `sorry, too many clients already` (SQLSTATE 53300)
|
||
|
||
#### 증상
|
||
구독 시작 후 실시간 값 수신 시 터미널에 다량의 에러:
|
||
```
|
||
Npgsql.PostgresException (0x80004005): 53300: sorry, too many clients already
|
||
at ExperionDbService.UpdateLiveValueAsync(...)
|
||
at ExperionRealtimeService.<<OnNotification>b__0>d.MoveNext()
|
||
```
|
||
|
||
#### 원인
|
||
`OnNotification` 콜백이 포인트마다 `Task.Run` → 새 DI 스코프 → 새 `DbContext` → 새 DB 커넥션을 열었음. 2000여개 포인트가 동시에 값 변경 콜백을 받으면 순식간에 PostgreSQL `max_connections`(기본 100) 초과.
|
||
|
||
```
|
||
값 변경 콜백 × 2000개 → Task.Run × 2000개 → DB 커넥션 × 2000개 → 💥
|
||
```
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `IExperionServices.cs` | `BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate>)` 인터페이스 추가, `LiveValueUpdate` record 추가 |
|
||
| `ExperionDbContext.cs` | `BatchUpdateLiveValuesAsync` 구현 — 단일 DbContext에서 순차 ExecuteUpdateAsync |
|
||
| `ExperionRealtimeService.cs` | `OnNotification`에서 `Task.Run` 제거 → `ConcurrentDictionary`에 최신값만 기록. 별도 `FlushLoopAsync` 태스크가 500ms마다 단일 DbContext로 배치 업데이트 |
|
||
|
||
#### 수정 후 구조
|
||
```
|
||
값 변경 콜백 × N개 → ConcurrentDictionary[nodeId] = 최신값
|
||
↓ 500ms마다
|
||
단일 DbContext → BatchUpdateLiveValuesAsync → DB 커넥션 1개
|
||
```
|
||
|
||
#### 결과
|
||
- DB 커넥션 동시 사용 수: 2000개 → **최대 1개**
|
||
- 500ms 내 중복 변경은 최신값 1건만 DB에 반영 (deduplication)
|
||
- 빌드: 경고 6건(기존 동일), **에러 0건**
|
||
|
||
---
|
||
|
||
### 버그 3 — 대시보드 탭 진입 시 자동 API 호출로 인한 CPU/브라우저 버벅임
|
||
|
||
#### 증상
|
||
- **노드맵 대시보드** 탭 진입 시 CPU 과부하, 페이지 버벅임
|
||
- **포인트빌더** 탭 진입 시 동일 증상
|
||
- **이력 조회** 탭 진입 시 한참 동안 열리지 않음
|
||
|
||
#### 원인 (항목별)
|
||
|
||
| 탭 | 자동 호출 API | 무거운 이유 |
|
||
|----|--------------|------------|
|
||
| 노드맵 대시보드 | `/api/nodemap/stats` + `/api/nodemap/names` + `/api/nodemap/query` | stats: 5가지 집계 쿼리(COUNT×4, MAX, DISTINCT). 결과로 전체 조회까지 자동 실행 |
|
||
| 포인트빌더 | `/api/nodemap/names` + `/api/nodemap/stats` | stats 집계 쿼리 (포인트빌더 dataType 드롭다운 채우기 용도) |
|
||
| 이력 조회 | `/api/history/tagnames` → 드롭다운 8개에 2000개 옵션 삽입 | 8 × 2000 = 16,000개 DOM `<option>` 생성으로 브라우저 freeze |
|
||
|
||
#### 수정 내용
|
||
|
||
**공통 원칙**: 탭 진입 시 API 호출 0건. 사용자가 명시적으로 버튼을 눌렀을 때만 실행.
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `app.js` | 탭 클릭 핸들러에서 `nmLoad()`, `pbLoad()`, `histLoad()` 자동 호출 제거 |
|
||
| `app.js` | `nmReset()` 에서 `nmQuery(0)` 자동 호출 제거 |
|
||
| `app.js` | `nmLoad()` → `nmLoadNames()`로 분리 (이름 드롭다운만, 버튼 클릭 시 호출) |
|
||
| `app.js` | `nmLoad()` 내부의 통계 카드 렌더링 + `nmQuery(0)` 자동 호출 제거 |
|
||
| `app.js` | `pbLoad()` 에서 `/api/nodemap/stats` 호출 제거 |
|
||
| `app.js` | `histLoad()` 는 유지하되 탭 자동 호출 제거, "▼ 옵션 불러오기" 버튼 클릭 시에만 실행 |
|
||
| `index.html` | 노드맵 대시보드: 통계 카드(`nm-stat-row`) 제거, 데이터타입 select → text input |
|
||
| `index.html` | 노드맵 대시보드: 이름 드롭다운에 "▼ 옵션 불러오기" 버튼 추가 |
|
||
| `index.html` | 포인트빌더: 데이터타입 select 2개 → text input 2개 (`Double`, `Int32` 등 직접 입력) |
|
||
| `index.html` | 이력 조회: 태그 선택 드롭다운에 "▼ 옵션 불러오기" 버튼 추가 |
|
||
|
||
#### 결과 (탭별 진입 시 API 호출 수)
|
||
|
||
| 탭 | 이전 | 이후 |
|
||
|----|------|------|
|
||
| 노드맵 대시보드 | stats + names + query = **3건** | **0건** |
|
||
| 포인트빌더 | names + stats = **2건** | names = **1건** |
|
||
| 이력 조회 | tagnames = **1건** + DOM 16,000개 생성 | **0건** |
|
||
|
||
#### 주의 사항
|
||
- `/api/nodemap/stats` 엔드포인트는 서버에 남아있으나 프론트엔드에서 호출하지 않음
|
||
- 이름/태그 드롭다운은 "▼ 옵션 불러오기" 버튼으로 수동 로드
|
||
- 데이터타입 필터는 text input 직접 입력 방식으로 변경 (API 불필요)
|
||
|
||
---
|
||
|
||
### 버그 4 — 포인트빌더 탭 진입 시 여전히 버벅임 (2026-04-14)
|
||
|
||
#### 증상
|
||
버그 3 수정 이후에도 포인트빌더 탭 진입 시 버벅임 지속.
|
||
|
||
#### 원인
|
||
버그 3 수정 시 탭 핸들러에서 `pbLoad()` 제거를 누락. `app.js`에 `if (tab === 'pb') pbLoad()` 가 그대로 남아 있었음. `pbLoad()`는 `/api/nodemap/names` 호출 → 8개 드롭다운에 전체 name 목록 삽입 → DOM 부하.
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `app.js` | 탭 핸들러에서 `if (tab === 'pb') pbLoad()` 제거 |
|
||
| `index.html` | 포인트빌더 이름 선택 레이블 옆에 "▼ 옵션 불러오기" 버튼 추가 (`onclick="pbLoad()"`) |
|
||
|
||
#### 결과
|
||
포인트빌더 탭 진입 시 API 호출 **0건**
|
||
|
||
---
|
||
|
||
### 기능 추가 — 실시간 구독 자동 재시작 플래그 (2026-04-14)
|
||
|
||
#### 배경
|
||
앱 재기동 시 구독이 자동으로 재시작되지 않아 매번 수동으로 구독 시작 버튼을 눌러야 했음.
|
||
히스토리 스냅샷이 구독 여부와 무관하게 무조건 실행되어 `livevalue = NULL` 행이 저장되는 문제도 존재.
|
||
|
||
#### 설계
|
||
- 구독 시작 시 서버 설정을 `realtime_autostart.json` 파일로 저장 (앱 실행 디렉토리)
|
||
- 앱 기동 시 (`IHostedService.StartAsync`) 파일 존재 여부 확인 → 있으면 자동 구독 시작
|
||
- 구독 중지 시 파일 삭제 → 재기동 후 자동 시작 없음
|
||
- `ExperionHistoryService`가 `IExperionRealtimeService.GetStatus().Running` 확인 → OFF이면 스냅샷 건너뜀
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `ExperionRealtimeService.cs` | `StartAsync(cfg)` 시 `realtime_autostart.json` 저장; `StopAsync()` 시 파일 삭제; `StartAsync(CancellationToken)` (IHostedService)에서 파일 읽어 자동 재시작 |
|
||
| `ExperionHistoryService.cs` | `IExperionRealtimeService` 생성자 주입; 스냅샷 전 `GetStatus().Running` 체크 → false이면 `continue` |
|
||
|
||
#### 동작 흐름
|
||
```
|
||
구독 시작 버튼 → realtime_autostart.json 저장 → OPC UA 구독 시작
|
||
앱 재기동 → 파일 감지 → 자동 구독 시작
|
||
구독 중지 버튼 → 파일 삭제 → 재기동 후 자동 시작 안 함
|
||
히스토리 서비스 → Running=false이면 스냅샷 건너뜀
|
||
```
|
||
|
||
---
|
||
|
||
### 기능 추가 — 수동 포인트 추가 시 OPC UA 핫 추가 및 유효성 검증 (2026-04-14)
|
||
|
||
#### 배경
|
||
수동으로 포인트를 추가해도 기존 구독에는 반영되지 않아 구독 재시작이 필요했음.
|
||
잘못된 node_id 입력 시 DB에만 저장되고 `livevalue`가 영원히 NULL인 문제도 존재.
|
||
|
||
#### 설계
|
||
- 수동 추가 시 DB 저장 후 구독 중이면 `MonitoredItem` 핫 추가 (`ApplyChanges()`)
|
||
- OPC UA 서버 응답 상태 확인 → bad 상태코드이면 subscription 제거 + DB 롤백 + 에러 반환
|
||
- 구독 중이 아닌 경우 DB에만 저장 → 다음 구독 시작 시 자동 포함
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 수정 내용 |
|
||
|------|----------|
|
||
| `IExperionServices.cs` | `IExperionRealtimeService`에 `AddMonitoredItemAsync(string nodeId)` 추가 (반환: `(bool Success, string Message)`) |
|
||
| `ExperionRealtimeService.cs` | `AddMonitoredItemAsync` 구현 — MonitoredItem 생성, `ApplyChanges()`, 상태 확인, bad이면 롤백 |
|
||
| `ExperionControllers.cs` | `ExperionPointBuilderController`에 `IExperionRealtimeService` 주입; `Add` 엔드포인트에서 DB 저장 후 `AddMonitoredItemAsync` 호출 → 실패 시 `DeleteRealtimePointAsync`로 DB 롤백 |
|
||
|
||
#### 동작 흐름
|
||
```
|
||
수동 추가 요청
|
||
├── DB 저장
|
||
├── 구독 중 아님 → 성공 ("다음 구독 시작 시 자동 포함")
|
||
└── 구독 중
|
||
├── OPC UA ApplyChanges() → Good → 즉시 구독 포함, 성공
|
||
└── OPC UA → Bad → subscription 제거 + DB 롤백 + 에러 반환
|
||
```
|
||
|
||
#### 빌드 결과
|
||
- 경고 8건 (기존 6건 + OPC SDK deprecated 2건), **에러 0건** — 빌드 성공
|
||
|
||
---
|
||
|
||
### 성능 분석 — 1,699포인트 기준 CPU 부하 추정 (2026-04-14)
|
||
|
||
#### 전제 조건
|
||
- 실시간 포인트: 1,699개
|
||
- 히스토리 스냅샷 주기: 60초
|
||
- 실시간 배치 flush 주기: 500ms
|
||
|
||
#### 히스토리 스냅샷 (60초마다)
|
||
|
||
- 작업: `realtime_table` 1,699행 SELECT → `history_table` INSERT 1,699행
|
||
- 특성: 1분에 1번 순간 burst, 수십 ms 수준
|
||
- 앱 CPU: EF Core 객체 생성 1,699개 → 거의 무시 가능
|
||
- **결론: 평균 CPU 기여 < 1%**
|
||
|
||
#### 실시간 livevalue 갱신 (500ms마다 배치)
|
||
|
||
- 작업: `ExecuteUpdateAsync` × (변경된 포인트 수)건 / 500ms
|
||
- OPC UA는 값이 바뀔 때만 콜백 → 전 포인트가 동시에 변경되는 경우는 드묾
|
||
- 실제 변경 수: 수십~수백건/500ms가 일반적
|
||
- **결론: 변경 포인트 수에 비례, 대부분의 경우 낮음**
|
||
|
||
#### 종합
|
||
|
||
| 작업 | 주기 | 예상 CPU |
|
||
|------|------|----------|
|
||
| 히스토리 스냅샷 | 60초/회 | 무시 가능 (< 1%) |
|
||
| 실시간 배치 업데이트 | 500ms/회 | 변경 포인트 수에 비례 |
|
||
| **합계** | - | **단일 코어 기준 5~15% 이내** |
|
||
|
||
실제 병목은 CPU보다 **PostgreSQL I/O와 커넥션 처리**쪽이 먼저 나타남. 현재 구조(단일 DbContext, 배치 flush)는 이미 최적화된 상태.
|
||
|
||
---
|
||
|
||
### 성능 분석 — 멀티모니터 4대 실시간 폴링 부하 (2026-04-14)
|
||
|
||
#### 시나리오
|
||
- 웹페이지에서 `realtime_table` 조회, 페이지당 200개, 2초 간격 갱신
|
||
- 멀티모니터 4대에서 4개의 브라우저 탭/창이 동시 동작
|
||
|
||
#### 부하 추정
|
||
|
||
| 항목 | 계산 | 평가 |
|
||
|------|------|------|
|
||
| 서버 요청 수 | 4탭 × 1회/2초 = **2 req/s** | 무시 가능 |
|
||
| DB 쿼리 | SELECT 200행 × 2회/s | 경량 |
|
||
| 응답 크기 | 200행 × ~150 bytes ≈ **30KB/응답** | 소량 |
|
||
| 네트워크 | 4 × 30KB / 2s = **60KB/s** | 거의 없음 |
|
||
| 브라우저 RAM | 탭당 60~100MB × 4 = **240~400MB** | 보통 수준 |
|
||
|
||
**결론: 서버 부하 크지 않음. 일반 개발용 PC(i5급, 8GB RAM)에서 충분히 감당 가능.**
|
||
|
||
#### 실질적 병목 — 브라우저 DOM 재렌더링
|
||
|
||
현재 `pbRender()`는 `tbl.innerHTML`로 테이블 전체를 교체하는 방식 (full re-render).
|
||
- 200행 × 4탭 × 2초마다 전체 재생성 → 체감 가능한 CPU 사용
|
||
|
||
#### 결정 사항
|
||
**실시간 모니터링 페이지 구현 시 반드시 incremental DOM update 방식 사용**
|
||
- 이미 그려진 `<td>` 셀의 `.textContent`만 갱신 (값이 바뀐 셀만)
|
||
- `innerHTML` 전체 교체 금지
|
||
- 구조 변경(행 추가/삭제) 시에만 DOM 재구성 허용
|
||
|
||
---
|
||
|
||
### TimescaleDB 관련 결정 사항 (2026-04-14)
|
||
|
||
PostgreSQL에 TimescaleDB 확장이 설치되어 있음.
|
||
|
||
#### 결론: 앱 코드 수정 불필요
|
||
|
||
TimescaleDB는 PostgreSQL **확장(extension)** 이므로:
|
||
- 연결 문자열: 기존 PostgreSQL 그대로 사용
|
||
- EF Core / Npgsql 드라이버: 그대로 사용
|
||
- `history_table` hypertable 전환은 DB에서 DDL 한 줄만 실행
|
||
|
||
```sql
|
||
SELECT create_hypertable('history_table', 'recorded_at');
|
||
```
|
||
|
||
이 명령을 DB에서 한 번 실행하면 이후 INSERT/SELECT는 코드 변경 없이 TimescaleDB가 자동으로 시계열 최적화를 적용함.
|
||
|
||
**DbContext, 엔티티, 컨트롤러 등 앱 코드는 전혀 수정 불필요.**
|
||
|
||
---
|
||
|
||
## 구현 계획 (참고용)
|
||
|
||
### Task 1 — RealtimeTable + 포인트빌더 대시보드
|
||
|
||
#### 개요
|
||
- `realtime_table` PostgreSQL 테이블 생성: `id, tagname, node_id, livevalue, timestamp`
|
||
- `tagname`: `node_id.Substring(node_id.LastIndexOf(':') + 1)` (마지막 ':' 오른쪽 문자열, 없으면 전체)
|
||
- 소스: `node_map_master WHERE name IN (...) AND data_type = 'Double'`
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 내용 |
|
||
|------|------|
|
||
| `ExperionEntities.cs` | `RealtimePoint` 엔티티 추가 (`realtime_table` 매핑) |
|
||
| `IExperionServices.cs` | `IExperionDbService`에 `BuildRealtimeTableAsync`, `GetRealtimePointsAsync`, `AddRealtimePointAsync`, `DeleteRealtimePointAsync` 추가 |
|
||
| `ExperionDbContext.cs` | `DbSet<RealtimePoint>`, 테이블 DDL, 4개 서비스 메서드 구현 |
|
||
| `ExperionControllers.cs` | `ExperionPointBuilderController` 추가 (POST /api/pointbuilder/build, GET /api/pointbuilder/points, POST /api/pointbuilder/add, DELETE /api/pointbuilder/{id}) |
|
||
| `index.html` | 06번 탭 '포인트빌더' 추가 — name 드롭다운 8개, dataType 드롭다운, 빌드 버튼, 수동 node_id 입력, 포인트 테이블 |
|
||
| `app.js` | `pbLoad()`, `pbBuild()`, `pbAddManual()`, `pbDelete(id)`, `pbRender()` 구현 |
|
||
| `style.css` | 포인트빌더 전용 스타일 추가 |
|
||
|
||
#### 설계 결정
|
||
- `BuildRealtimeTableAsync`는 기존 레코드를 모두 지우고 재생성 (TRUNCATE + INSERT)
|
||
- 수동 추가(`AddRealtimePointAsync`)는 `tagname`을 자동 추출해서 삽입
|
||
- 약 2000건 → 페이지네이션 불필요, 전체 목록을 클라이언트 측 테이블로 렌더링
|
||
|
||
---
|
||
|
||
### Task 2 — OPC UA 실시간 구독 (livevalue 업데이트)
|
||
|
||
#### 개요
|
||
- OPC UA Subscription + MonitoredItem API 사용 (값 변경 시에만 콜백)
|
||
- `IExperionRealtimeService` 인터페이스 + `ExperionRealtimeService` BackgroundService 신규 파일
|
||
- 서버 접속 설정은 `appsettings.json`에서 읽음 (기존 `ExperionServerConfig` 구조 재사용)
|
||
- 값 변경 콜백 → `realtime_table.livevalue` 업데이트 + `timestamp` 갱신
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 내용 |
|
||
|------|------|
|
||
| `IExperionServices.cs` | `IExperionRealtimeService` 인터페이스, `IExperionDbService`에 `UpdateLiveValueAsync` 추가 |
|
||
| `ExperionDbContext.cs` | `UpdateLiveValueAsync` 구현 |
|
||
| `ExperionRealtimeService.cs` (신규) | `BackgroundService` 구현 — Subscription 생성, MonitoredItem 등록, 콜백 처리 |
|
||
| `ExperionControllers.cs` | `ExperionRealtimeController` 추가 (POST /api/realtime/start, POST /api/realtime/stop, GET /api/realtime/status) |
|
||
| `Program.cs` | `AddHostedService<ExperionRealtimeService>()` 등록 |
|
||
| `index.html` + `app.js` | 포인트빌더 탭에 실시간 시작/정지 버튼, 상태 표시, livevalue 폴링(3초) 추가 |
|
||
|
||
#### 설계 결정
|
||
- OPC UA Subscription: `PublishingInterval = 1000ms`
|
||
- MonitoredItem: `SamplingInterval = 500ms`, `DeadBandType = None`
|
||
- 값 변경 없으면 콜백 없음 → DB 업데이트 없음 (OPC UA 규약 준수)
|
||
- 서비스 재시작 시 자동 재연결 로직 포함 (30초 재시도)
|
||
|
||
---
|
||
|
||
### Task 3 — HistoryTable (시계열 스냅샷)
|
||
|
||
#### 개요
|
||
- `history_table`: `id, tagname, node_id, value, recorded_at`
|
||
- `ExperionHistoryService` BackgroundService → 설정된 주기(기본 60초)마다 `realtime_table` 전체를 스냅샷
|
||
- 주기는 `appsettings.json: "HistoryIntervalSeconds": 60` 에서 읽음
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 내용 |
|
||
|------|------|
|
||
| `ExperionEntities.cs` | `HistoryRecord` 엔티티 추가 (`history_table` 매핑) |
|
||
| `IExperionServices.cs` | `IExperionDbService`에 `SnapshotToHistoryAsync` 추가 |
|
||
| `ExperionDbContext.cs` | `DbSet<HistoryRecord>`, 테이블 DDL, `SnapshotToHistoryAsync` 구현 |
|
||
| `ExperionHistoryService.cs` (신규) | `BackgroundService` — 주기적 `SnapshotToHistoryAsync` 호출 |
|
||
| `Program.cs` | `AddHostedService<ExperionHistoryService>()` 등록 |
|
||
|
||
---
|
||
|
||
### Task 4 — HistoryTable 웹페이지
|
||
|
||
#### 개요
|
||
- 07번 탭 '이력 조회' 추가
|
||
- tagname 드롭다운 최대 8개 선택 (다중 선택으로 열 구성)
|
||
- 시작 시간 / 종료 시간 범위 필터
|
||
- 결과 테이블: tagname이 열 헤더, recorded_at이 행
|
||
|
||
#### 수정 파일
|
||
|
||
| 파일 | 내용 |
|
||
|------|------|
|
||
| `IExperionServices.cs` | `IExperionDbService`에 `GetTagNamesAsync`, `QueryHistoryAsync` 추가; `HistoryQueryResult` record 추가 |
|
||
| `ExperionDbContext.cs` | `GetTagNamesAsync`, `QueryHistoryAsync` 구현 |
|
||
| `ExperionControllers.cs` | `ExperionHistoryController` 추가 (GET /api/history/tagnames, GET /api/history/query) |
|
||
| `index.html` | 07번 탭 '이력 조회' + `#pane-hist` 섹션 추가 |
|
||
| `app.js` | `histLoad()`, `histQuery()`, `histRender()` 구현 |
|
||
| `style.css` | 이력 조회 전용 스타일 추가 |
|