진단 보고서(plans/...phase5-사용자체크리스트.md) 기반 7건 코드 이슈
수정 + Phase 6 잔여 항목 중 최우선인 run_sql 가드 구현.
핫픽스:
- nl2sql_worker.py: _list_drawings 파싱 버그(문자열 분리) HIGH
- nl2sql_worker.py: 5개 async 함수 blocking DB 연결 → to_thread MED
- ExperionDbContext.cs: KB DDL의 {} 문자가 String.Format placeholder로
오인되어 부팅 실패 → 별도 NpgsqlCommand 사용 HIGH
- KbIngestWorker: 단일 청크 임베딩 실패 시 전체 abort → 부분 인덱싱 LOW
- KbAuthService: 초기 비번 로그 평문 → 마스킹 + 콘솔 분리 출력 LOW
- KbQdrantClient: new HttpClient → IHttpClientFactory LOW
- OllamaController: plant_context.md 매 요청 파일 읽기 → mtime 캐시 LOW
Phase 6 — run_sql 가드:
- _validate_sql 강화: \b 단어 경계로 updated_at 오탐 제거, WITH 허용,
TRUNCATE/COPY 추가, 다중 세미콜론 차단
- _apply_sql_guards: LIMIT 미지정 시 SELECT * FROM (...) _capped LIMIT 1000
- _execute_sql_internal: 매 호출 SET statement_timeout = 30000
- SQL_MAX_ROWS / SQL_STATEMENT_TIMEOUT_MS 환경변수화
- 응답 JSON에 row_limit 필드 추가
- nl2sql_worker.py의 _run_sql / _query_with_nl에도 동일 적용
기타:
- .gitignore: storage/ 추가 (KB 업로드 원본 디렉토리)
- opencode.json: 모델 항목을 실제 서빙 모델(Qwen3.6-27B-FP8 / 256K)로 동기화
검증:
- dotnet build: 경고 0건, 에러 0건
- python3 -m py_compile: OK
- _apply_sql_guards / _validate_sql 스모크 테스트 통과
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
45 KiB
ExperionCrawler — 작업 이력
작업 규칙
- 복잡한 작업은 항상 todo 목록 먼저 생성
- 각 단계 시작 전 todo 목록 확인
- 단계 완료 후 즉시 completed 표시
완료된 작업
기능 추가 — 로컬 LLM 채팅 + 지식 베이스(RAG) Phase 0~5 (2026-05-13)
배경
운전원이 채팅 UI에서 자연어로 공장 상황·계기 상태·정비 이력 등을 질문하면, (a) PostgreSQL 시계열/이벤트 데이터와 (b) 관리자가 업로드한 KB 문서(Qdrant 벡터 검색)를 합성해 답하도록 통합. 별도 14번 탭 "RAG 관리"에서 관리자 비번 인증 후 문서 업로드/인덱싱/관리.
설계서: plans/LLM채팅+지식증강플랜.md
아키텍처
[채팅 #13] ── /api/ollama/vllm/chat/stream ──► [OllamaController]
│ SSE: message, tool_start, tool_result
│ tool_calls 루프(최대 10라운드)
▼
[McpClient → Python MCP]
│
┌───────────────────────────────┴───────────┐
▼ ▼
PostgreSQL Qdrant 컬렉션 7개
(history/realtime/event/ ├── ws-65f457145aee80b2 (코드)
tag_metadata/kb_*) ├── experion-opc-docs (R530)
└── kb_* 5개 (사용자 KB)
[RAG 관리 #14] ── /api/kb/{auth,upload,documents,jobs,download,...} ──►
[KbAuthController + KbController + KbIngestWorker(BackgroundService)]
│ Argon2id 비번 + 60분 세션 토큰
│ 업로드 → storage/kb/{yyyy-MM}/{uuid}.ext (SHA256)
│ 큐 폴링 2초 → parse(MCP) → embed(Ollama 768-dim) → upsert(Qdrant)
│ attempts ≥3 = failed
KB 데이터 모델 (PostgreSQL, 자동 마이그레이션)
kb_collections(5종 시드: system_instrument / plant_operation / procedure / report / vendor_doc)kb_documents(UUID PK + collection_key FK + status: pending/parsing/embedding/indexed/failed/disabled)kb_ingest_jobs(stage: parse, attempts, finished_at)kb_admin_credential(단일 행, Argon2id 해시)kb_admin_sessions(60분 만료)
Phase별 구현
Phase 0 — 사전 정비
| 파일 | 수정 내용 |
|---|---|
mcp-server/worker/nl2sql_worker.py |
time_bucket('1 min', ts) → history_table.recorded_at 직접 SELECT, _get_tag_metadata도 server.py 형식과 일치 |
mcp-server/llm-model.json |
실제 서빙 모델(Qwen3.6-27B-FP8)과 이미 동기화 — 변경 없음 |
prompts/plant_context.md |
신규 (빈 골격) — 단위/계기 약어/태그 규칙/예시 자유 작성 영역 |
src/Web/Controllers/OllamaController.cs |
ComposeSystemPrompt(userPrompt, toolsEnabled) 추가 — BaseSystemPromptKo + plant_context.md + ToolGuideKo + 사용자 입력 순서로 합성 |
src/Web/wwwroot/js/app.js |
영문 하드코딩된 tool description 제거 (서버에서 합성) |
src/Web/appsettings.json |
PromptsDirectory: "../../prompts" 추가 |
Phase 1 — 데이터 모델 & 인증
| 파일 | 수정 내용 |
|---|---|
src/Core/Domain/Entities/ExperionEntities.cs |
KbCollection / KbDocument / KbIngestJob / KbAdminCredential / KbAdminSession 5개 엔티티 추가 |
src/Infrastructure/Database/ExperionDbContext.cs |
DbSet 5개 + OnModelCreating 인덱스 + InitializeAsync에 DDL/시드 (pgcrypto 활성화 포함) |
src/Infrastructure/Kb/KbQdrantClient.cs |
신규 — EnsureCollectionAsync, DeleteByDocAsync, UpsertAsync |
src/Infrastructure/Kb/KbStartupService.cs |
신규 IHostedService — 부팅 시 활성 컬렉션 5개 Qdrant ensure |
src/Infrastructure/Kb/PasswordHasher.cs |
신규 — Argon2id (4 thread, 64MB, 3 iter) + NewSessionToken |
src/Infrastructure/Kb/KbAuthService.cs |
신규 — EnsureCredentialAsync (env or 자동생성), Login/Validate/Logout/ChangePassword |
src/Web/Controllers/KbAuthController.cs |
신규 — /api/kb/auth/{login|logout|status|change-password}, X-Kb-Token 헤더 |
src/Web/ExperionCrawler.csproj |
Konscious.Security.Cryptography.Argon2 v1.3.1 추가 |
src/Web/Program.cs |
KB 서비스 등록 + 부팅 시 EnsureCredentialAsync 호출 |
Phase 2 — 업로드 & 비동기 워커
| 파일 | 수정 내용 |
|---|---|
src/Infrastructure/Kb/KbStorageService.cs |
신규 — storage/kb/{yyyy-MM}/{uuid}.{ext}, SHA256 스트림 계산 |
src/Infrastructure/Kb/KbEmbeddingClient.cs |
신규 — Ollama nomic-embed-text(/api/embeddings) 768-dim |
src/Infrastructure/Kb/KbIngestWorker.cs |
신규 BackgroundService — 2초 폴링, parse→embed→index 단일 패스, attempts ≥3=failed |
src/Web/Controllers/KbController.cs |
신규 — upload(multipart, RequestSizeLimit 500MB), documents 페이지네이션, jobs 조회, download(Content-Disposition), delete(Qdrant+storage 동시정리), reindex, disable, bulk-disable, purge-disabled |
mcp-server/parsers/ |
신규 디렉터리 — xlsx_parser(시트+행), pdf_parser(페이지+표), docx_parser(헤딩 path), text_parser(md/txt) |
mcp-server/server.py |
@mcp.tool() parse_document(doc_id, title, file_path, mime_type, collection_key, chunking_policy) 추가 |
mcp-server/pyproject.toml |
openpyxl / python-docx / pdfplumber 의존성 추가 |
Phase 3 — 관리 탭 #14
| 파일 | 수정 내용 |
|---|---|
src/Web/wwwroot/index.html |
사이드바 14번 탭 + pane-kbadmin 섹션 (로그인 카드, 필터, 문서 테이블, 업로드/비번변경 모달) |
src/Web/wwwroot/js/app.js |
kbLogin / kbLogout / kbLoadCollections / kbRefresh / kbRenderDocs / kbUpload* / kbDelete / kbReindex / kbDisable / kbBulkDisable / kbPurgeDisabled / kbChangePw* + 1.5초 진행률 폴링 + sessionStorage 토큰 |
src/Web/wwwroot/css/style.css |
.kb-login-card / .kb-main / .kb-doc-tbl / .kb-status (pending/parsing/embedding/indexed/failed/disabled 색상) / .kb-modal |
Phase 4 — 다운로드 & 검색
| 파일 | 수정 내용 |
|---|---|
mcp-server/server.py |
KB_COLLECTIONS 상수, _search_kb_collection (Qdrant 단일 + tags filter), _recency_factor (7d+10% / 30d+5% / 90d+2%), _search_kb_raw (다중 컬렉션 검색→가중치→since 후필터→dedup→top_k), @mcp.tool() search_kb, rag_query 확장 (search_kb, kb_collections 인자) |
Phase 5 — 채팅 통합
| 파일 | 수정 내용 |
|---|---|
src/Web/Controllers/OllamaController.cs |
EmitToolStart(id, name, argsJson) / EmitToolResult(id, name, ok, payload) 헬퍼. VllmChatStreamWithTools의 공식 tool_calls 경로 + JSON-텍스트 폴백 경로 모두 SSE 이벤트 발행 |
src/Web/wwwroot/js/app.js |
SSE 파서 버그 수정 (event: 라인 추적), llmAppendToolCard / llmUpdateToolCard / llmRenderToolPayload / llmRenderTable / llmRenderKbHits 추가, llmKbDocMap + llmLinkKbCitations (제목→다운로드 링크 치환), LLM_STARTER_CHIPS 7종 + llmUseChip |
src/Web/wwwroot/css/style.css |
.llm-tool-cards / .llm-tool-card (spin 애니, ok/err 색상) / .llm-tool-tbl (sticky header) / .llm-kb-hits / .kb-cite-link / .llm-chip |
주요 설계 결정
| 항목 | 결정 |
|---|---|
| 관리자 권한 | 비번 인증 (Argon2id), 세션 토큰 60분, X-Kb-Token 헤더 |
| 초기 비번 | 환경변수 KB_ADMIN_INITIAL_PASSWORD 우선, 없으면 부팅 시 콘솔에 16자 랜덤 출력 |
| 컬렉션 구조 | doc_type별 5개 분리 컬렉션 (마스터 시드) + 자유 태그 |
| 임베딩 모델 | 기존 nomic-embed-text (768-dim) — Phase 0~5는 그대로, BGE-M3 마이그레이션은 보류 |
| 청킹 정책 | xlsx 시트+행 둘 다, pdf 페이지+표 별도, docx 헤딩 path, md/txt 단순 |
| 재인덱스/삭제 | Qdrant(doc_id filter)와 storage 파일 동시 정리 |
| Worker 큐 처리 | parse→embed→index 단일 패스(한 잡으로 끝까지), attempts ≥3 = failed, 2초 폴링 |
| 시스템 프롬프트 | 서버에서 합성 (한글 base + plant_context.md + tool guide + 사용자 입력) |
| SSE 이벤트 | message / tool_start / tool_result / done / error — 클라이언트 파서가 event-type 추적 |
| KB 인용 | search_kb 결과 title↔doc_id 매핑 누적, 본문에 등장 시 다운로드 링크로 자동 치환 |
| 자동 표 렌더 | {success, columns, data} 또는 data:[{...}] 형태 감지 시 최대 50행 HTML 테이블 |
API 엔드포인트 (신규)
POST /api/kb/auth/login/logout/change-password,GET /api/kb/auth/statusGET /api/kb/collections— 활성 컬렉션 + 문서/청크 카운트POST /api/kb/upload(multipart, admin) — 즉시 doc_id 반환, 큐 적재GET /api/kb/documents?collection=&status=&q=&page=&pageSize=GET /api/kb/documents/{id}/DELETE /api/kb/documents/{id}(admin)POST /api/kb/documents/{id}/reindex//disable(admin)POST /api/kb/documents/bulk-disable//purge-disabled(admin)GET /api/kb/jobs?docId=&stage=&pendingOnly=GET /api/kb/download/{docId}— Content-Disposition 원본 스트림 (인증 X)
MCP 도구 (신규)
parse_document(doc_id, title, file_path, mime_type, collection_key, chunking_policy)— KbIngestWorker 전용search_kb(query, collection_keys?, top_k=8, tags?, since?, boost_recent=True)— 채팅 노출rag_query확장 —search_kb=False,kb_collections=None옵션
빌드 결과
dotnet build— 경고 0건, 에러 0건mcp-serverPython 6개 파일 (server.py, nl2sql_worker.py, parsers/*) syntax OK
런타임 셋업 (코드 외)
cd mcp-server && uv pip install -e .— Phase 2에서 추가된openpyxl/python-docx/pdfplumber설치mcp-server재시작 —parse_document,search_kb새 도구 인식- 앱 첫 기동 후 콘솔의
[Kb] 관리자 초기 비밀번호 자동 생성: XXXX로그 → 14번 탭에서 즉시 변경 - Qdrant 5개 컬렉션 생성 확인 —
curl http://localhost:6333/collections
잔여 작업
- Phase 6 (보강 도구):
query_events,summarize_events,active_alarms,find_tags,generate_status_report(→ 완료, 아래 참조)run_sqlLIMIT/timeout - Phase 7 (옵션): NL2SQL 의도 라우터, 대화 요약, 에이전트 모드, KB 청크 미리보기 UI
- Phase 5 후순위: 시계열 미니 스파클라인, 툴 카드 메시지 영구 보존
- 결정 보류: 현장 재고 데이터 출처, 임베딩 모델 BGE-M3 마이그레이션
Phase 5 후속 — 진단 보고서 핫픽스 + Phase 6 첫 항목 (2026-05-14)
배경
plans/LLM채팅+지식증강-phase5-사용자체크리스트.md 진단 보고서에서 도출된 6건의 코드 이슈와 Phase 6 잔여 항목 중 우선순위가 가장 높은 run_sql 안전 가드를 함께 처리.
수정 파일
| 파일 | 수정 내용 |
|---|---|
mcp-server/worker/nl2sql_worker.py |
(HIGH) _list_drawings dict(zip(columns, row[0])) → [row[0] for row in rows] 버그 수정 — 문자열이 문자 단위로 분리되어 잘못된 dict 생성되던 문제 |
mcp-server/worker/nl2sql_worker.py |
(MED) _run_sql/_query_pv_history/_get_tag_metadata/_list_drawings/_query_with_nl 5개 함수가 async 안에서 psycopg.connect() blocking 호출 → _aget_db_connection() 헬퍼(asyncio.to_thread)로 격리 |
mcp-server/worker/nl2sql_worker.py |
(Phase 6) _validate_sql + _apply_sql_guards 추가, _run_sql·_query_with_nl에 auto-LIMIT/statement_timeout 적용 |
mcp-server/server.py |
(Phase 6) _validate_sql 강화 (단어 경계 매칭으로 updated_at 오탐 제거, WITH 허용, 세미콜론 다중문장 차단, TRUNCATE/COPY 추가). SQL_MAX_ROWS=1000, SQL_STATEMENT_TIMEOUT_MS=30000 환경변수화. _apply_sql_guards로 LIMIT 미지정 시 서브쿼리 wrap. _execute_sql_internal에서 매 호출 SET statement_timeout 적용. run_sql 도구 docstring 갱신 |
src/Infrastructure/Kb/KbIngestWorker.cs |
(LOW) 단일 청크 임베딩 실패 시 전체 abort → skip 후 부분 인덱싱, error_message에 "부분 인덱싱: N/M 청크" 기록. 전 청크 실패 시에만 throw |
src/Infrastructure/Kb/KbAuthService.cs |
(LOW) 초기 비번 로그 평문 → 마스킹(앞 4자만)으로 변경, 평문은 Console.Out으로 1회 분리 출력 (파일 로거 미포함) |
src/Infrastructure/Kb/KbQdrantClient.cs |
(LOW) new HttpClient 직접 생성 → IHttpClientFactory.CreateClient("KbQdrant") |
src/Web/Program.cs |
(LOW) AddHttpClient("KbQdrant") 팩토리 등록 (BaseAddress + 30s Timeout) |
src/Web/Controllers/OllamaController.cs |
(LOW) LoadPlantContext() 매 요청 File.ReadAllText → mtime 기반 캐시 (lock 보호) |
src/Infrastructure/Database/ExperionDbContext.cs |
(HIGH) KB DDL의 ExecuteSqlRawAsync 사용 → JSONB '{}' / TEXT[] '{}' / 시드 JSON의 중괄호가 String.Format placeholder로 오인되어 부팅 실패. 별도 NpgsqlConnection + NpgsqlCommand.ExecuteNonQueryAsync로 전환 |
.gitignore |
KB 업로드 원본 디렉토리 storage/ 추가 (런타임 데이터는 git 추적 제외) |
Phase 6 — run_sql 가드 동작
| 가드 | 동작 |
|---|---|
| 키워드 차단 | \b{kw}\b 단어 경계 매칭 — INSERT/UPDATE/DELETE/DROP/ALTER/CREATE/GRANT/REVOKE/TRUNCATE/COPY/EXEC |
| 시작 키워드 | SELECT 또는 WITH만 허용 (선행 ( 허용) |
| 다중 문장 | 끝의 ; 제외하고 ; 포함 시 차단 |
| 자동 LIMIT | 꼬리 LIMIT N [OFFSET M] 미지정 시 SELECT * FROM ({sql}) _capped LIMIT 1000 wrap |
| 타임아웃 | SET statement_timeout = 30000 (커넥션별) |
| 환경변수 | SQL_MAX_ROWS, SQL_STATEMENT_TIMEOUT_MS로 조정 가능 |
응답 JSON에 row_limit 필드 추가 — 클라이언트가 잘림 여부 판단 가능.
빌드/검증 결과
dotnet build src/Web/ExperionCrawler.csproj— 경고 0건, 에러 0건python3 -m py_compile mcp-server/server.py mcp-server/worker/nl2sql_worker.py— OK_apply_sql_guards/_validate_sql스모크 테스트 통과SELECT *→ wrap 적용,LIMIT 10/LIMIT 10 OFFSET 5→ 그대로SELECT 1; DROP TABLE x→ 차단,WITH t AS ...→ 허용SELECT updated_at→ 허용 (단어 경계 매칭으로UPDATE키워드 오탐 제거)
잔여 (이 커밋 이후)
- Phase 6 나머지:
query_events,summarize_events,active_alarms,find_tags,generate_status_report - Phase 7 옵션, Phase 5 후순위는 변동 없음
appsettings.json의AdminInitialPassword: "admin"은 사용자 지시로 유지 (운영 전 제거 권장)
기능 추가 — OPC UA 서버 기능 (2026-04-15)
배경
ExperionCrawler가 OPC UA 클라이언트 역할만 했으나, 외부 OPC UA 클라이언트(SCADA, MES 등)가 ExperionCrawler에 접속해 실시간 값을 읽을 수 있도록 OPC UA 서버 기능 추가.
아키텍처
[Experion HS R530] ──(OPC UA Client)──► ExperionCrawler ◄──(OPC UA Client)── [외부 시스템]
│
(OPC UA Server)
│
[PostgreSQL DB]
주소 공간 구조
Root/Objects/ExperionCrawler
├── ServerInfo/Status, PointCount, LastUpdateTime
└── Realtime/<tagname_1>, <tagname_2>, … (ns=2;s=tag_{tagname})
수정/추가 파일
| 파일 | 수정 내용 |
|---|---|
src/Web/ExperionCrawler.csproj |
OPCFoundation.NetStandard.Opc.Ua.Server v1.5.378.134 패키지 추가 |
src/Web/appsettings.json |
OpcUaServer 섹션 추가 (Port:4841, EnableSecurity:false, AllowAnonymous:true) |
src/Core/Application/Interfaces/IExperionServices.cs |
IExperionOpcServerService 인터페이스, OpcServerStatus record, GetRealtimeNodeDataTypesAsync() 추가 |
src/Infrastructure/OpcUa/ExperionOpcServerNodeManager.cs |
신규 — CustomNodeManager2 상속, 주소 공간 관리 (CreateAddressSpace, RebuildAddressSpace, UpdateNodeValue) |
src/Infrastructure/OpcUa/ExperionOpcServerService.cs |
신규 — ExperionStandardServer + ExperionOpcServerService (IHostedService + IExperionOpcServerService) |
src/Infrastructure/OpcUa/ExperionRealtimeService.cs |
_pointCache (nodeId→RealtimePoint) 추가; FlushPendingAsync에서 OPC 서버 노드 값 lazy 갱신 |
src/Infrastructure/Database/ExperionDbContext.cs |
GetRealtimeNodeDataTypesAsync() — realtime_table × node_map_master 조인 |
src/Web/Controllers/ExperionControllers.cs |
ExperionOpcServerController 추가 (start/stop/status/rebuild) |
src/Web/Program.cs |
ExperionOpcServerService Singleton+HostedService 등록 |
src/Web/wwwroot/index.html |
08 OPC UA 서버 탭 + pane-opcsvr 섹션 추가 |
src/Web/wwwroot/js/app.js |
srvLoad/Start/Stop/Rebuild/_srvRender/_srvStartPoll/_srvStopPoll 구현 |
src/Web/wwwroot/css/style.css |
.srv-status-card, .srv-meta, .dot.grn 스타일 추가 |
주요 설계 결정
| 항목 | 결정 |
|---|---|
| 인증서 | 기존 pki/own/certs/{hostname}.pfx 재사용 (ApplicationType.ClientAndServer) |
| 포트 | 기본 4841 (4840은 Experion HS R530이 사용 가능) |
| 보안 | 기본 None (appsettings.json에서 변경 가능) |
| 자동 재시작 | opcserver_autostart.json 플래그 파일 패턴 (RealtimeService와 동일) |
| 순환 참조 | IServiceProvider lazy resolve — _opcServer ??= _sp.GetService<IExperionOpcServerService>() |
| FlushLoop 연동 | 500ms 배치 DB 업데이트 후 → OPC 서버 노드 값도 동시 갱신 (DB 폴링 없음) |
API 엔드포인트
GET /api/opcserver/status— 상태 조회 (running, clientCount, nodeCount, endpointUrl, startedAt)POST /api/opcserver/start— 서버 시작POST /api/opcserver/stop— 서버 중지POST /api/opcserver/rebuild— 주소 공간 재구성
빌드 결과
- 경고 11건 (기존 8건 + OPC SDK Server Start/Stop deprecated 3건), 에러 0건 — 빌드 성공
OPC UA 서버가 노출하는 데이터
데이터 출처: realtime_table에 등록된 포인트 전체 (포인트빌더에서 빌드/수동 추가한 포인트)
주소 공간 구조
Root/Objects/ExperionCrawler
├── ServerInfo/
│ ├── Status (String) — "Running" / "Stopped"
│ ├── PointCount (Int32) — 구독 중인 포인트 수
│ └── LastUpdateTime (DateTime) — 마지막 값 갱신 시각
└── Realtime/
├── <tagname_1> ns=2;s=tag_FIC101_PV
├── <tagname_2>
└── …
NodeId 명명 규칙: ns=2;s=tag_{tagname}
DataType 결정: realtime_table × node_map_master 조인
- Double/Float/Int32/Int64/Boolean/DateTime → 해당 OPC UA 타입
- 기타/NULL → String (fallback)
접근 제한: 읽기 전용 (AccessLevel = CurrentRead), Historizing = false
갱신 주기: Experion HS R530 → FlushLoop 500ms 배치 → DB + OPC 서버 노드 동시 갱신
로그 정리 — 스냅샷 로그 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_table1,699행 SELECT →history_tableINSERT 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 |
보통 수준 |
결론: 서버 부하 크지 않음. 일반 개발용 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_tablehypertable 전환은 DB에서 DDL 한 줄만 실행
SELECT create_hypertable('history_table', 'recorded_at');
이 명령을 DB에서 한 번 실행하면 이후 INSERT/SELECT는 코드 변경 없이 TimescaleDB가 자동으로 시계열 최적화를 적용함.
DbContext, 엔티티, 컨트롤러 등 앱 코드는 전혀 수정 불필요.
구현 계획 (참고용)
Task 1 — RealtimeTable + 포인트빌더 대시보드
개요
realtime_tablePostgreSQL 테이블 생성:id, tagname, node_id, livevalue, timestamptagname: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인터페이스 +ExperionRealtimeServiceBackgroundService 신규 파일- 서버 접속 설정은
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_atExperionHistoryServiceBackgroundService → 설정된 주기(기본 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 |
이력 조회 전용 스타일 추가 |