# 웹UI 개선 — 코딩 실행 계획 (by Qwen27B)
> 작성: 2026-05-24 · 상태: **감리 정정 반영 — 실행 전 §0.5 필독** · 대상: `src/Web/wwwroot/`
> 기반: `웹UI-개선플랜-byOPUS.md` + `diagnosis-checklist.md` 규칙 준수
> 감리: 2026-05-24 실코드 대조 — 치명 결함 3건 발견·정정(§0.5)
---
## 0. 진단 요약 (Checklist STEP 1~4 적용)
### STEP 1 — 맥락 파악
| 항목 | 내용 |
|------|------|
| 파일 역할 | ASP.NET Static Files로 서빙되는 바닐라 JS SPA 프론트엔드 |
| 아키텍처 레이어 | 진입점 (UI). 백엔드 API(`/api/*`)에 fetch로 호출 |
| 관련 문서 | AGENTS.md(빌드/런/배포), CODING_CONVENTIONS.md(JSON camelCase 규칙) |
| 제약 | 빌드 스텝 0, 정적 파일만, `dotnet publish`로 배포 |
### STEP 2 — 구조 탐색
```
wwwroot/
├── index.html (1,761줄, pane 16개 + 모달 2개) ← 초안 15개는 오기(docs=Tab16 누락)
├── js/
│ ├── app.js (5,148줄, 전 탭 로직)
│ ├── docs.js (712줄, ✅ 분리된 모범 사례) ← 계획 초안 571줄은 오기(D6)
│ ├── pid-viewer.js (P&ID 뷰어)
│ └── xlsx.full.min.js
├── css/
│ ├── style.css (2,231줄)
│ ├── docs.css
│ └── pid_graph.css
└── lib/ (uPlot 등 외부 라이브러리)
```
### STEP 3 — 코드 읽기 (핵심 발견)
1. **탭 라우터**: `app.js:5-20` — `document.querySelectorAll('.nav-item')` click listener. 진입 훅 4개: `opcsvr`, `t2s`, `fast`, `docs`
2. **docs.js 패턴**: `docsInit()` 진입 함수 + `docsState.inited` 플래그로 1회 초기화. 전역 `esc()`/`kbToken` 재사용
3. **모달 2개**: fastRecord 신규 세션 모달(`index.html:1679-1724`), 날짜 선택 팝업(`index.html:1727-1754`) — pane 외부에 위치
4. **datepicker**: `app.js:1660-1779` — 여러 탭에서 공유하는 공용 컴포넌트
### STEP 4 — 호출 계층 지도
```
사용자 클릭 (nav-item)
→ app.js:5 click listener
→ tab 클래스 토글
→ if (tab === 'opcsvr') srvLoad() ← API 호출
→ if (tab === 't2s') t2sInitMode() ← UI 모드 설정
→ if (tab === 'fast') fastSessionsLoad() ← API 호출
→ if (tab === 'docs') docsInit() ← API 호출 (docs.js)
```
---
## 0.5 감리 정정 (실코드 대조 — 2026-05-24) ⚠️ 실행 전 필독
> 본 계획을 `src/Web/wwwroot/` 실제 코드와 1:1 대조한 결과, **실행을 막는 치명 결함 3건** + 정합성 결함 3건 발견. 아래 정정을 반영한 후 실행할 것. 헤더/Phase 본문의 라인 번호는 대부분 정확하나, 분리 전제와 로더 코드가 틀렸다.
### 결함 요약
| # | 심각도 | 위치 | 문제 | 정정 |
|---|--------|------|------|------|
| D1 | 🔴 치명 | §4.2, §4.3 | app.js는 함수 선언만 있지 않음 — **모듈레벨 상태변수 ~25개**와 **로드 시 실행되는 최상위 문**(`certStatus()` 2593, `btn-fast-*`/`btn-pid-*` addEventListener 3001~3640)이 산재. "함수 단위 이동 + `_t2sLastResult` 1개만 이전"으로는 누락/오작동 | 상태변수·최상위 문 전수 이전(아래 인벤토리). DOM 배선은 init 훅 내부로 |
| D2 | 🔴 치명 | §2.2, §3.3, §5.1 | 로더에 **기동 코드 부재** — `loadPane`/`activateTab`이 click에만 묶여, 페이지 로드 시 기본 활성 pane(`pane-cert`)이 빈 채로 남음. §3.3의 "eager 주입"도 §2.2 코드엔 없음 | DOMContentLoaded에서 기본 pane 주입+init(아래 정정 로더) |
| D3 | 🔴 치명 | §2.2, §5.2 | pane이 async fetch 파셜이 되면, fast/pid 탭의 **최상위 addEventListener 배선이 pane DOM 주입보다 먼저 실행**돼 무력화(`?.`로 silent no-op) → 버튼 사망. eager(Phase1)에서도 fetch 비동기라 동일 발생 | 해당 배선을 fast/pid의 **1회 setup 훅**으로 이동 |
| D4 | 🟡 정합성 | §2.3 ↔ §4.2#3 | 로드 순서 모순 — §4.2#3 "datepicker가 hist/evt보다 먼저" vs §2.3 datepicker 맨 뒤. 실제로는 모두 `function` 선언(호이스팅)이라 **순서 무관** | §4.2#3 폐기, §2.3 순서 유지 |
| D5 | 🟡 정합성 | §4.1 | "원본 라인" 범위가 **상호 겹침**(분할표가 아님): hist `1120-1658` ⊃ evt `1349-1503`; t2s `1868-2643` ⊃ core helper `2599-2643`; conn의 `llm*` 2개(160,177)가 llmchat `llm*`와 충돌 | 범위는 참고용. prefix grep 기계 분리 금지, 함수명 단위 판단 |
| D6 | ⚪ 경미 | §0 STEP2, §6.1 | 수치 오기 — `docs.js` 571줄 → **712줄**; pane **15개 → 16개**(docs=Tab16); "15개 fetch" → 16개 | 정정(완료) |
### 실제 함수 배치 — 탭별 연속 블록이 아님 (D5 근거)
app.js 함수는 탭별로 깔끔히 연속되지 **않으며** 교차(interleave)된다:
- hist/ht: `1120-1332` + **(evt `1349-1503` 끼어듦)** + `1505-1658`
- core helper `fmtTs`/`fmtVal`/`parseEnumPv`: `2599-2643` (t2s 영역 한가운데)
- `llmLoadConfig`/`llmSaveModelConfig`: `160`/`177` (conn 영역 — **llmchat 아님**)
- `certStatus();` 호출: `2593` (t2s 영역 — **cert 아님**)
→ **`grep '^function llm'` 같은 기계 분리 금지.** 함수명 단위로 소속 탭을 직접 판단할 것.
### 모듈레벨 상태변수 전수 인벤토리 (각 소속 파일 상단으로 이전 — D1)
| 변수 | 라인 | 소속 파일 |
|------|------|-----------|
| `_t2sLastResult` | 2 | t2s.js |
| `_nmOffset` / `_nmTotal` / `_nmLimit` | 492-494 | nm-dash.js |
| `PB_GROUPS` / `pbPreviewData` / `SUBAREA_OPTIONS` | 613-614, 1022 | pb.js |
| `HIST_TAG_IDS` | 1117 | hist.js |
| `_dtp` / `_DTP_DAYS` | 1654, 1658 | datepicker.js |
| `_srvPollTimer` | 1777 | opcsvr.js |
| `t2sMode` | 1863 | t2s.js |
| `fastCurrentSessionId` / `fastChart` / `fastLivePollTimer` / `fastChartTagNames` / `fastDbConnected` | 2640-2644 | fast.js |
| `pidCurrentPage` / `pidPageSize` / `pidLastResult` / `pidExtracting` / `pidElapsedInterval` / `pidPrefixPanelVisible` / `CATEGORY_META` / `CATEGORY_ORDER` | 3100-3460 | pid.js |
| `llmSessions` / `llmActiveSessionId` / `llmAbortController` / `llmIsStreaming` / `llmType` / `llmUseTools` / `llmAgentMode` / `llmMcpTools` / `LLM_STARTER_CHIPS` / `llmKbDocMap` / `_llmSparkSeq` / `_llmSparkCache` / `LLM_MAX_HISTORY` / `LLM_SUMMARY_KEEP` | 3670-4262 | llmchat.js |
| `kbToken` / `kbExpiresAt` / `kbCollections` / `kbPollTimer` | 4673-4676 | kbadmin.js |
### 로드 시 실행되는 최상위 문 (init 훅으로 이전 — D1/D3)
| 문 | 라인 | 조치 |
|----|------|------|
| `.nav-item` 탭 라우터 | 5 | core.js (계획대로) |
| `certStatus();` | 2593 | **cert setup 훅 신설**(1회) |
| `btn-fast-*` addEventListener ×7 | 3001-3079 | fast setup 훅(1회, 중복배선 가드) |
| `[href="#pane-fast"]` | 3093 | fast setup 훅 |
| `btn-pid-export-*` ×2 | 3613-3626 | pid setup 훅(1회) |
| `[data-tab="pid"/"llmchat"/"kbadmin"]` | 3640, 3700, 4694 | **nav 요소 대상이라 shell에 잔존** → core.js에 통합 가능(pane DOM 비의존) |
### 정정 로더 (§2.2 · §5.1 대체 — D2/D3 반영)
핵심: ① 진입 시 **DOM 주입 완료 후 init**, ② **1회 setup(배선)** 과 **매 진입 enter(데이터 로드)** 분리(원동작 보존), ③ **기동 코드로 기본 pane 초기화**.
```javascript
// core.js — 정정판 로더
const paneCache = new Map(); // src → html
const paneReady = new Set(); // pane DOM 주입 완료
const paneSetup = new Set(); // 1회 배선 완료(중복 addEventListener 방지)
// 1회만: pane DOM에 의존하는 최상위 배선 (D1/D3에서 이전)
const paneWire = {
cert: () => certStatus(), // 기존 최상위 호출(2593)
fast: () => fastWire(), // btn-fast-* 배선(3001-3093)을 담은 함수
pid: () => pidWire(), // btn-pid-export-* 배선(3613-3626)
};
// 매 진입: 기존 탭 라우터의 init (데이터 갱신 등)
const paneEnter = {
opcsvr: () => srvLoad(),
t2s: () => t2sInitMode(),
fast: () => fastSessionsLoad(),
docs: () => docsInit(), // docsState.inited 내부 가드 있음
};
async function loadPane(tab) {
const el = document.getElementById(`pane-${tab}`);
if (!el || !el.dataset.src || paneReady.has(tab)) return;
if (!paneCache.has(tab)) {
try { paneCache.set(tab, await (await fetch(el.dataset.src)).text()); }
catch { el.innerHTML = `
패널 로딩 실패: ${esc(el.dataset.src)}
`; return; }
}
el.innerHTML = paneCache.get(tab);
paneReady.add(tab);
}
async function activateTab(tab) {
await loadPane(tab); // ① DOM 주입 완료까지 대기
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
document.getElementById(`pane-${tab}`)?.classList.add('active');
if (!paneSetup.has(tab)) { paneWire[tab]?.(); paneSetup.add(tab); } // ② 1회 배선
paneEnter[tab]?.(); // 매 진입
}
document.querySelectorAll('.nav-item').forEach(item =>
item.addEventListener('click', () => activateTab(item.dataset.tab)));
// ③ 기동(D2): 기본 활성 pane 초기화 — 없으면 첫 화면이 빈 채로 뜸
document.addEventListener('DOMContentLoaded', () => {
const def = document.querySelector('.nav-item.active')?.dataset.tab || 'cert';
activateTab(def);
// Phase 1(eager): 나머지도 미리 주입하려면 ↓ 주석 해제
// document.querySelectorAll('.pane[data-src]').forEach(p => loadPane(p.id.slice(5)));
});
```
> 주의: `fastWire`/`pidWire`는 기존 최상위 `addEventListener` 블록을 그대로 감싼 함수다. `?.` 옵셔널 체이닝은 유지하되, **반드시 pane DOM 주입 후**(=`paneWire`에서) 호출돼야 배선이 성립한다.
---
## 1. 실행 전략
**A안(HTML 파셜 fetch) + app.js 탭별 분리** 채택.
| 단계 | 내용 | 파일 변경 |
|------|------|-----------|
| Phase 0 | `core.js` 생성 + 파셜 로더 | `js/core.js`(신규), `index.html` |
| Phase 1 | pane HTML을 `panes/*.html`으로 eager 분리 | `panes/*.html`(15개 신규), `index.html` 수정 |
| Phase 2 | app.js를 탭별 JS로 분리 | `js/*.js`(15개 신규), `app.js` 삭제 |
| Phase 3 | 지연로딩 전환 | `core.js` 수정 |
---
## 2. Phase 0 — `core.js` + 파셜 로더
### 2.1 `js/core.js` 생성
`app.js`의 공용 함수를 추출하여 `core.js`에 배치:
| 함수 | 현재 위치 | 역할 |
|------|-----------|------|
| `esc(s)` | app.js:23 | HTML 이스케이프 |
| `setGlobal(state, text)` | app.js:28 | 전역 상태 표시 |
| `log(id, lines)` | app.js:33 | 로그박스 출력 |
| `api(method, path, body)` | app.js:41 | fetch 래퍼 |
| `fmtTs(v)` | app.js:2599 | 타임스탬프 포맷 |
| `fmtVal(v)` | app.js:2616 | 값 포맷 |
| `parseEnumPv(v)` | app.js:2630 | Enum PV 파싱 |
### 2.2 파셜 로더 구현
> ⚠️ **아래 초안 로더는 결함(D2 기동 부재 · D3 배선 타이밍)** 있음. 실제 구현은 §0.5 "정정 로더"를 사용할 것. 초안은 설계 의도 참고용으로만 남김.
```javascript
// core.js — 파셜 로더 (초안 · §0.5 정정판으로 대체)
const paneCache = new Map();
const paneInit = {
opcsvr: () => srvLoad(),
t2s: () => t2sInitMode(),
fast: () => fastSessionsLoad(),
docs: () => docsInit(),
};
async function loadPane(tab) {
const el = document.getElementById(`pane-${tab}`);
if (!el || !el.dataset.src) return;
if (!paneCache.has(tab)) {
try {
paneCache.set(tab, await (await fetch(el.dataset.src)).text());
} catch (e) {
el.innerHTML = `패널 로딩 실패: ${esc(el.dataset.src)}
`;
return;
}
}
el.innerHTML = paneCache.get(tab);
}
async function activateTab(tab) {
await loadPane(tab);
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.pane').forEach(p => p.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
const el = document.getElementById(`pane-${tab}`);
if (el) el.classList.add('active');
paneInit[tab]?.();
}
document.querySelectorAll('.nav-item').forEach(item =>
item.addEventListener('click', () => activateTab(item.dataset.tab)));
```
### 2.3 `index.html` 스크립트 로드 순서
```html
```
---
## 3. Phase 1 — pane HTML eager 분리
### 3.1 `index.html` 셸화
1,761줄 → ~120줄로 축소. `` 내 pane을 `data-src` 셸로 대체:
```html
```
모달 2개(fastRecord, datepicker)는 `index.html`에 유지(pane 외부 공유 컴포넌트).
### 3.2 `panes/*.html` 파일 생성
각 pane의 `` 내용을 그대로 추출. `` 태그는 제거(로더가 innerHTML로 주입).
| 파일 | 원본 라인 | 내용 |
|------|-----------|------|
| `panes/cert.html` | 111-151 | 인증서 관리 |
| `panes/conn.html` | 156-208 | 서버 접속 테스트 |
| `panes/crawl.html` | 213-296 | 데이터 크롤링 + 노드맵 수집 |
| `panes/db.html` | 301-352 | DB 저장 |
| `panes/nm-dash.html` | 357-431 | 노드맵 대시보드 |
| `panes/pb.html` | 436-748 | 포인트빌더 |
| `panes/hist.html` | 753-902 | 이력 조회 |
| `panes/opcsvr.html` | 907-933 | OPC UA 서버 |
| `panes/t2s.html` | 938-1028 | Text-to-SQL |
| `panes/fast.html` | 1033-1078 | fastRecord |
| `panes/pid.html` | 1083-1196 | P&ID 추출 |
| `panes/evt.html` | 1201-1280 | 이벤트 히스토리 |
| `panes/llmchat.html` | 1285-1379 | 로컬 LLM 채팅 |
| `panes/kbadmin.html` | 1384-1543 | RAG 관리 |
| `panes/write.html` | 1548-1629 | OPC UA Write |
| `panes/docs.html` | 1634-1672 | 문서 탐색기 |
### 3.3 Phase 1 완료 시 동작
- `core.js`의 기동 코드가 페이지 로드 시 **16개 pane을 모두 eager 주입**(§0.5 정정 로더의 DOMContentLoaded에서 `loadPane` 일괄 호출 — 초안 §2.2엔 이 루프가 없음, D2)
- 단, fetch는 **비동기**이므로 "DOM 즉시 존재"는 성립하지 않음 → pane DOM에 의존하는 배선/init은 주입 완료 후(`paneWire`/`paneEnter`)에만 실행해야 함(D3)
- 동작은 기존과 동일하게 보이되, 파일만 분리됨
---
## 4. Phase 2 — app.js 탭별 분리
### 4.1 분리 매핑표
> ⚠️ **"원본 라인"은 분할표가 아니라 참고용** — 함수가 교차 배치돼 범위가 서로 겹친다(hist⊃evt, t2s⊃core helper). 라인 기준 일괄 추출 금지, 함수명 단위로 이동(§0.5 D5).
| 파일 | 함수 접두어 | 함수 수 | 원본 라인 |
|------|-------------|---------|-----------|
| `js/core.js` | `esc`, `setGlobal`, `log`, `api`, `fmtTs`, `fmtVal`, `parseEnumPv` | 7 | 23-60, 2599-2643 |
| `js/cert.js` | `cert*` | 2 | 52-104 |
| `js/conn.js` | `conn*`, `getServerCfg`, `llmLoadConfig`, `llmSaveModelConfig` | 5 | 106-261 |
| `js/crawl.js` | `crawl*`, `nodeMapCrawl` | 2 | 263-387 |
| `js/db.js` | `db*`, `selectFile` | 4 | 389-495 |
| `js/nm-dash.js` | `nm*` | 5 | 497-614 |
| `js/pb.js` | `pb*`, `rt*`, `meta*`, `subArea*` | 31 | 616-1118 |
| `js/hist.js` | `hist*`, `ht*`, `renderHistoryTable` | 10 | 1120-1658 |
| `js/opcsvr.js` | `srv*`, `_srv*` | 9 | 1779-1866 |
| `js/t2s.js` | `t2s*`, `toggleMcpMode`, `loadMcpTools`, `renderToolsChips`, `callTool`, `apiChat*`, `apiRenderResponse` | 28 | 1868-2643 |
| `js/fast.js` | `fast*` | 25 | 2646-3102 |
| `js/pid.js` | `pid*` | 23 | 3104-3688 |
| `js/llmchat.js` | `llm*` | 46 | 3690-4676 |
| `js/kbadmin.js` | `kb*` | 32 | 4678-5070 |
| `js/write.js` | `wr*` | 4 | 5072-5148 |
| `js/evt.js` | `evt*`, `_evt*` | 8 | 1349-1503 |
| `js/datepicker.js` | `dt*`, `_dtp` | 16 | 1660-1779 |
### 4.2 분리 규칙
1. **함수 단위 이동**: 함수 정의 전체를 복사. import 없음 (전역 스코프). ⚠️ prefix grep 기계 분리 금지 — `llmLoadConfig`/`llmSaveModelConfig`(160/177)는 `llm*`지만 conn 소속, `certStatus()`/core helper는 다른 영역에 끼어있음(§0.5 참조)
2. **전역 변수**: `_t2sLastResult` 하나가 아님 — **모듈레벨 상태변수 ~25개 전부** 이전 필수. §0.5 "모듈레벨 상태변수 전수 인벤토리" 표 기준
3. **로드 순서**: `core.js` 먼저, 그다음 탭별 JS. ~~datepicker가 hist/evt보다 먼저~~ → **불필요**(전부 `function` 선언 호이스팅, 순서 무관). 단 로드 시 실행되는 최상위 문은 §0.5대로 init 훅으로 이전해야 함(D4)
4. **docs.js**: 이미 분리됨. 변경 없음
### 4.3 `app.js` 삭제
모든 함수가 분리된 후 `app.js` 삭제. `app.js.backup`는 유지(롤백용).
---
## 5. Phase 3 — 지연로딩 전환
### 5.1 eager → lazy 변경
`core.js`의 `loadPane()`을 수정하여 **탭 진입 시에만 fetch**. ⚠️ §0.5 정정 로더는 이미 lazy(진입 시 `loadPane`) + 기동 코드(기본 pane) 포함 — 별도 전환 불필요. 아래 초안은 기동·배선 누락(D2/D3) 상태이므로 §0.5판을 사용:
```javascript
const paneLoaded = new Set();
async function loadPane(tab) {
if (paneLoaded.has(tab)) return;
const el = document.getElementById(`pane-${tab}`);
if (!el || !el.dataset.src) return;
try {
el.innerHTML = await (await fetch(el.dataset.src)).text();
paneLoaded.add(tab);
} catch (e) {
el.innerHTML = ``;
}
}
```
### 5.2 init 타이밍 검증
지연로딩 전환 시 반드시 검증할 항목:
| 탭 | init 함수 | 검증 사항 |
|----|-----------|-----------|
| opcsvr | `srvLoad()` | `srvLoad()`가 pane 내부 DOM을 참조하는지 확인 |
| t2s | `t2sInitMode()` |同上 |
| fast | `fastSessionsLoad()` |同上 |
| docs | `docsInit()` | `docsState.inited` 플래그로 안전 |
**리스크**: `index.html`에 인라인되어 있던 `onclick="..."`은 innerHTML 파싱 시 정상 바인딩됨. `