Files
ExperionCrawler/CLAUDE.md
2026-04-14 09:56:37 +00:00

410 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ExperionCrawler — 작업 이력
## 작업 규칙
- 복잡한 작업은 항상 todo 목록 먼저 생성
- 각 단계 시작 전 todo 목록 확인
- 단계 완료 후 즉시 completed 표시
## 완료된 작업
### 노드맵 대시보드 구현 (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 재구성 허용
---
## 구현 계획 (참고용)
### 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` | 이력 조회 전용 스타일 추가 |