# 웹 UI 구조 개선 플랜 — HTML/JS 모놀리식 분리 (by Opus)

> 작성: 2026-05-24 · 상태: **제안 (구현 보류)** · 대상: `src/Web/wwwroot/`

---

## 0. TL;DR

- **진짜 문제는 "모놀리식 2개"**: `index.html` 1,761줄 + `app.js` 5,148줄. HTML 분리만으론 절반만 해결.
- **추천: A안(HTML 파셜 fetch include) + app.js를 `docs.js`처럼 탭별로 분리**. 빌드도구 0 추가, 현 아키텍처 유지, **pane 단위 점진 이관** 가능.
- 1순위 실행 순서: ① 파셜 로더 추가 → ② 안전하게 **eager-주입**으로 파일만 분리 → ③ 탭별 JS 분리 → ④ 지연로딩 전환.
- `.NET` 정석을 원하면 **B안(Razor 파셜)**도 강력(서버 1응답, fetch 지연 0). **D안(Vite/프레임워크)은 현 규모엔 과함 → 비채택.**

---

## 1. 현황 진단 (실측 2026-05-24)

| 파일 | 라인 | 비고 |
|---|---|---|
| `wwwroot/index.html` | **1,761** | `<section class="pane">` **15개**가 한 파일에 인라인 |
| `wwwroot/js/app.js` | **5,148** | 전 탭 로직 한 파일 |
| `wwwroot/js/docs.js` | 571 | ✅ **이미 분리된 모범 사례** (문서 탐색기) |
| `wwwroot/css/style.css` | 2,230 | 전 탭 스타일 한 파일 (docs.css만 분리됨) |

**빌드 스텝 없음** — 순수 정적 SPA. `<script src>` 직접 로드 + 라이브러리 로컬 번들(`wwwroot/lib/`). 서빙은 ASP.NET:
```
Program.cs:
  app.UseDefaultFiles();        // index.html
  app.UseStaticFiles();         // wwwroot/
  app.MapFallbackToFile("index.html");
```

### 현재 pane 인벤토리 (index.html 내 라인 시작 위치)

| data-tab | id | 시작줄 | data-tab | id | 시작줄 |
|---|---|---|---|---|---|
| cert | pane-cert | 109 | fast | pane-fast | 1033 |
| conn | pane-conn | 156 | pid | pane-pid | 1083 |
| crawl | pane-crawl | 213 | evt | pane-evt | 1201 |
| db | pane-db | 301 | llmchat | pane-llmchat | 1285 |
| nm-dash | pane-nm-dash | 357 | kbadmin | pane-kbadmin | 1384 |
| pb | pane-pb | 436 | write | pane-write | 1548 |
| hist | pane-hist | 753 | docs | pane-docs | 1634 |
| opcsvr | pane-opcsvr | 907 | | | |
| t2s | pane-t2s | 938 | | | |

---

## 2. 문제 정의

1. **편집 마찰** — 한 탭만 손봐도 1,700줄짜리 파일을 열어야 함. 충돌/오편집 위험.
2. **인지 부하** — 탭 경계가 파일 경계와 불일치. 어디가 어느 기능인지 스크롤로 탐색.
3. **초기 파싱 낭비** — 안 쓰는 14개 탭의 DOM까지 매번 파싱.
4. **app.js가 더 큼** — HTML만 쪼개면 5,148줄 JS 모놀리식은 그대로 남음.

---

## 3. 선택지 분석 (현 "무빌드" 제약 기준)

| 방식 | 작동 원리 | 빌드도구 | 노력 | 적합도 |
|---|---|---|---|---|
| **A. HTML 파셜 (fetch include)** | pane별 `panes/*.html` → 탭 진입 시 `fetch`로 `<main>`에 주입 | 불필요 | 낮음 | ★★★ 현 구조에 가장 자연 |
| **B. Razor 파셜 뷰** | `Index.cshtml` + `_Pane*.cshtml`, 서버가 한 응답으로 합침 | 불필요(.NET 내장) | 중 | ★★★ .NET 프로젝트 정석 |
| C. Web Components / `<template>` | pane = 커스텀 엘리먼트(Shadow DOM) | 불필요 | 중상 | ★ 현 inline-onclick·전역함수 스타일과 충돌 |
| D. Vite + 프레임워크 (Vue/Svelte/React) | 진짜 컴포넌트, HMR, 번들/트리셰이킹 | **npm/node 필요** | 높음 | △ 프론트를 크게 키울 때만 |

**핵심 트레이드오프**
- A: fetch 첫 진입 시 미세 지연(캐시 후 0). `<script>`는 innerHTML로 실행 안 됨 → **pane엔 마크업/inline-onclick만** 둘 것.
- B: fetch 지연 0(서버 합성), 레이아웃·파셜 재사용 깔끔. 대신 정적 `index.html` → MVC 뷰로 이전 필요(`AddControllersWithViews`, 뷰 반환 액션, `MapFallback` 조정).
- C: 기존 코드가 `getElementById` + 전역 `onclick`에 크게 의존 → Shadow DOM 캡슐화와 정면충돌. 비추천.
- D: 큰 마이그레이션(npm 툴체인 + `deploy.sh` 변경 + 바닐라→컴포넌트 재작성). 사내 도구 규모엔 과투자.

---

## 4. 추천안: **A(HTML 파셜) + app.js 탭별 분리**

**선정 이유**: 빌드도구 0 추가(배포 그대로 `dotnet publish`), 기존 아키텍처(정적 wwwroot·전역 함수·곳곳 fetch)와 동일, **pane 하나씩 점진 이관** 가능, 지연로딩 시 초기 파싱 경감. `docs.js`가 "한 기능 = 한 JS 파일" 패턴을 이미 입증.

### 4.1 목표 디렉토리 구조

```
wwwroot/
  index.html              ← 사이드바 + 빈 <main> 셸만 (수백 줄로 축소)
  panes/
    cert.html  conn.html  crawl.html  db.html  nm-dash.html
    pb.html    hist.html  opcsvr.html t2s.html fast.html
    pid.html   evt.html   llmchat.html kbadmin.html write.html
    docs.html
  js/
    core.js               ← 공용: esc()/fmt/api 래퍼/탭 라우터/kb 토큰
    cert.js conn.js ... docs.js   ← 탭별 (docs.js가 템플릿)
  css/
    style.css             ← 공용 토큰/레이아웃
    cert.css ... docs.css ← (선택) 탭별 스타일
```

### 4.2 파셜 로더 설계

`index.html`의 `<main>`에는 **빈 셸**만 남긴다:
```html
<section class="pane" id="pane-docs" data-src="/panes/docs.html"></section>
```
`core.js`:
```js
// 탭 진입 시 1회 주입 후 init 호출
const paneInit = {
  docs:   () => docsInit(),
  opcsvr: () => srvLoad(),
  t2s:    () => t2sInitMode(),
  fast:   () => fastSessionsLoad(),
  // …필요한 탭만 등록…
};

async function activateTab(tab){
  const el = document.getElementById(`pane-${tab}`);
  if (el.dataset.loaded !== '1' && el.dataset.src){
    el.innerHTML = await (await fetch(el.dataset.src)).text();
    el.dataset.loaded = '1';
  }
  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');
  el.classList.add('active');
  paneInit[tab]?.();
}

document.querySelectorAll('.nav-item').forEach(item =>
  item.addEventListener('click', () => activateTab(item.dataset.tab)));
```
- inject된 HTML 안의 `onclick="foo()"`는 `foo`가 전역이면 그대로 동작(HTML 파서가 바인딩).
- `<script>`는 innerHTML로 **실행되지 않음** → pane 파일엔 절대 `<script>` 넣지 말 것(로직은 항상 `js/<tab>.js`).

### 4.3 탭 → init 매핑 (현 app.js 기준)

현재 탭 전환부에 이미 있는 진입 훅:
```js
if (tab === 'opcsvr') srvLoad();
if (tab === 't2s')    t2sInitMode();
if (tab === 'fast')   fastSessionsLoad();
if (tab === 'docs')   docsInit();
// 그 외 탭: 진입 시 자동 호출 없음 (버튼으로만 동작) — nm-dash/pb/hist 등
```
→ `paneInit` 맵으로 그대로 옮기면 됨. **진입 자동호출이 없는 탭**(cert/conn/crawl/db/nm-dash/pb/hist/pid/evt/llmchat/kbadmin/write)은 주입만 하면 되고 init 불필요.

### 4.4 JS 분리 단위 (app.js 함수 접두어별 실측)

| 접두어 | 개수 | → 분리 파일 | 탭 |
|---|---|---|---|
| `llm*` | 46 | `js/llmchat.js` | 로컬 LLM 채팅 |
| `kb*` | 32 | `js/kbadmin.js` | RAG 관리 |
| `pb*` | 19 | `js/pb.js` | 포인트빌더 |
| `pid*` | 18 | `js/pid.js` | P&ID 추출 |
| `fast*` | 16 | `js/fast.js` | fastRecord |
| `t2s*` | 12 | `js/t2s.js` | Text-to-SQL |
| `dt*` | 12 | `js/datepicker.js` (**공용 컴포넌트**) | (여러 탭 공유) |
| `srv*` | 5 | `js/opcsvr.js` | OPC UA 서버 |
| `nm*` | 5 | `js/nm-dash.js` | 노드맵 대시보드 |
| `hist*`/`ht*` | 5+4 | `js/hist.js` | 이력 조회 |
| `wr*` | 4 | `js/write.js` | OPC UA Write |
| `evt*` | 4 | `js/evt.js` | 이벤트 히스토리 |
| `db*` | 3 | `js/db.js` | DB 저장 |
| `conn*` | 3 | `js/conn.js` | 서버 접속 테스트 |
| `cert*` | 2 | `js/cert.js` | 인증서 관리 |
| `rt*`/`sub*` | 3+4 | 해당 탭에 귀속 | (realtime/subarea) |
| `esc`/`api*`/`fmt*`/`render*`/`set*`/`call*` | 공용 | `js/core.js` | 전역 유틸 |

> 전역 스코프 공유(classic script)라 파일만 나눠도 함수 상호참조는 그대로 동작 — `docs.js`가 `esc`/`kbToken`을 재사용하는 방식과 동일. **로드 순서: `core.js` 먼저, 그다음 탭 JS들.**

### 4.5 CSS 분리 (선택, 우선순위 낮음)

`style.css`(2,230줄)는 공용 토큰/레이아웃만 남기고 탭별 스타일은 `css/<tab>.css`로(이미 `docs.css` 분리됨). HTML/JS 분리 완료 후 진행 권장.

---

## 5. 점진 이관 단계 (빅뱅 금지)

| Phase | 내용 | 리스크 | 롤백 |
|---|---|---|---|
| **0. 로더 추가** | `core.js`에 `activateTab`/파셜 로더 추가. **`data-src` 있는 pane만 fetch**, 나머지 인라인 pane과 공존 | 없음 | 로더 제거 |
| **1. 안전 분리 (eager)** | pane 하나씩 `panes/*.html`로 들어내되, 시작 시 **전체 파셜을 한 번에 eager-주입** → 동작 100% 동일, 파일만 분리 | 매우 낮음 | 파셜 내용을 index.html에 되붙임 |
| **2. JS 분리** | app.js의 탭 함수군을 `js/<tab>.js`로 이동(4.4 표), 공용은 `core.js`로. `index.html`에 `<script>` 추가 | 낮음(전역 공유) | 함수 되돌림 |
| **3. 지연로딩 전환** | eager-주입 → 탭 진입 시 fetch로 전환. **init 타이밍 점검 필수**(§6) | 중 | eager로 복귀 |
| **4. CSS 분리** | 탭별 css 추출 | 낮음 | — |

**권장 착수 순서**: docs(이미 깔끔) → write/cert/conn(작은 탭으로 패턴 검증) → llmchat/kbadmin(큰 탭).

---

## 6. 리스크 & 주의사항

1. **지연로딩 시 init 타이밍** — "페이지 로드 시점에 pane 내부 DOM을 만지는 코드"는 깨진다(해당 DOM이 아직 없음). 현재 대부분 탭 진입 시 init이라 안전하나, 로드 시점 `getElementById('pane 내부')` 호출이 있으면 **주입 이후로 이동** 필요. → **Phase 1은 eager-주입으로 시작**해 이 리스크를 0으로.
2. **`<script>` 미실행** — innerHTML 주입된 `<script>`는 안 돈다. pane 파일엔 마크업만, 로직은 `js/<tag>.js`에.
3. **inline `onclick` 의존** — 현 코드 다수가 `onclick="foo()"`. foo가 전역이면 동작하므로 유지 가능(단 전역 오염은 그대로 — 큰 리팩터는 별도 과제).
4. **fetch 실패 처리** — 파셜 404/네트워크 오류 시 빈 화면 방지용 에러 표시.
5. **캐시** — 파셜은 정적이라 브라우저 캐시 대상. 배포 후 `Ctrl+F5` 또는 쿼리 버전(`?v=`) 고려.
6. **ASP.NET fallback** — `MapFallbackToFile("index.html")` 유지. `panes/*.html`은 정적이라 `UseStaticFiles`로 그대로 서빙됨(추가 설정 불필요).

---

## 7. 대안 B: Razor 파셜 (서버 사이드) — .NET 정석

```
Views/
  _ViewStart.cshtml   _Layout.cshtml(사이드바)
  Home/Index.cshtml   ← @await Html.PartialAsync("Panes/_Docs") …
  Shared/Panes/_Docs.cshtml  _Llmchat.cshtml ...
```
- **장점**: 서버가 한 응답으로 합성 → **fetch 지연 0**, 레이아웃/파셜 재사용·조건부 렌더 깔끔, 부분 서버데이터 주입 가능.
- **필요 작업**: `builder.Services.AddControllersWithViews()`(또는 RazorPages), 뷰 반환 액션, 정적 `index.html` 제거 + `MapFallback` 라우팅 조정, `onclick` 전역함수는 그대로 사용 가능.
- **선택 기준**: "프론트를 SPA fetch로 더 키울 생각이 없고 서버 렌더 조합이 낫다"면 B가 더 깨끗. JS 분리(§4.4)는 A/B 공통으로 필요.

---

## 8. 비채택: D안(Vite/프레임워크) 이유

- npm/node 툴체인 신규 도입 + `deploy.sh`에 빌드 스텝 추가 + 바닐라(전역함수·inline-onclick) 코드를 컴포넌트로 재작성 = **대규모 마이그레이션**.
- 현재는 사내 산업용 도구로 프론트 변경 빈도가 폭발적이지 않음 → 투자 대비 효익 낮음.
- **재검토 조건**: 탭이 25개+로 늘거나, 실시간 상태/복잡한 양방향 바인딩이 핵심이 되거나, 프론트 전담 인력이 붙을 때.

---

## 9. 의사결정 체크리스트

- [ ] 방향 선택: **A(파셜 fetch)** vs **B(Razor)** — JS 분리(§4.4)는 공통
- [ ] 착수 범위: 전체 일괄 vs 큰 탭(llmchat/kbadmin)부터 vs 작은 탭부터
- [ ] 지연로딩 도입 여부(A안): eager 유지 vs lazy 전환
- [ ] CSS 분리 포함 여부(Phase 4)
- [ ] 캐시 무효화 전략(`?v=` 등)

---

## 부록: 적용 시 참조 포인트

- 탭 라우터 현 위치: `app.js:5` (`document.querySelectorAll('.nav-item')…`)
- 진입 훅 현 위치: `app.js` 탭 전환부 `if (tab === 'opcsvr') srvLoad();` 등
- 모범 분리 사례: `wwwroot/js/docs.js`(571줄) — 전역 `esc`/`kbToken` 재사용, 자체 `docsInit()` 진입
- pane 셸 패턴: `<section class="pane" id="pane-X" data-src="/panes/X.html"></section>`
