Studio 상태 흐름 — 입력에서 렌더까지, 그리고 상태가 실제로 어디 있는가
이 문서가 답하는 질문
- 사용자가 Enter를 누르면 무슨 일이 순서대로 벌어지나?
- 상태는 어디에 보관되나 — 모듈 변수? DOM? localStorage? 클로저?
- 그래프/테이블/JSON/SQL 탭 전환은 무엇에 의존하나?
- 히스토리(↑/↓)와 저장 질의는 어떤 규칙으로 동작하나?
- 오류가 나면 화면은 어떤 상태가 되고, 어떻게 복구되나?
1. 사실 — 상태가 있는 곳은 다섯 군데다
Studio에는 상태 관리 라이브러리가 없다. 상태는 다섯 개의 서로 다른 저장소에 흩어져 있고, 각각 수명과 동기화 규칙이 다르다.
| # | 저장소 | 실체 | 수명 | 근거 |
|---|---|---|---|---|
| 1 | 모듈 변수 state |
graph schema stats saved history histIdx |
페이지 수명 | app.js:13-20 |
| 2 | localStorage | og.saved · og.history · og.graph |
브라우저 영속 | app.js:17-18, 62, 192, 670, 855, 874 |
| 3 | DOM 자체 | 프레임 목록, 활성 탭, 패널 표시 여부, inspector 열림 | DOM 수명 | app.js:187, 209-212, 632, 845-847 |
| 4 | 시뮬레이션 클로저 | view running alpha dragging panning hover selected nodes edges index |
그래프 뷰마다 1벌 | app.js:365-376 |
| 5 | 모듈 전역 보조 | frameSeq (프레임 ID 카운터) · colorMemo (라벨→색 메모) |
페이지 수명 | app.js:171, 27 |
결정적 사실: state 객체에는 "현재 질의", "실행 중 여부", "결과"가 없다. 그것들은 전부 DOM(프레임)에 산다. 그래서 프레임을 el.remove()하면(app.js:188) 그 질의에 관한 모든 것이 사라진다 — 복원 경로가 없다.
1.1 state 객체 전문
// portal/web/app.js:13-20
const state = {
graph: 'default',
schema: null,
stats: null,
saved: JSON.parse(localStorage.getItem('og.saved') || '[]'),
history: JSON.parse(localStorage.getItem('og.history') || '[]'),
histIdx: -1,
};
saved/history는 모듈 로드 시점에 한 번만 localStorage에서 읽는다. 이후 두 탭을 동시에 열면 서로 덮어쓴다 (동기화 없음).JSON.parse가 try/catch 없이 최상위에 있다. localStorage 값이 손상되면 스크립트 전체가 이 줄에서 던지고, Studio가 백지로 뜬다. (→FE-14)
1.2 localStorage 키 3종
| 키 | 쓰는 곳 | 상한 | 읽는 곳 |
|---|---|---|---|
og.saved |
app.js:192 (프레임 ★ 버튼), app.js:874 (remove) |
slice(0, 50) — 저장할 때만 적용 |
app.js:17 |
og.history |
app.js:670 (run() 진입 시 무조건) |
slice(0, 100) (app.js:668) |
app.js:18 |
og.graph |
app.js:855 (#graph-select change) |
— | app.js:62 |
og.saved의 상한 처리에는 비대칭이 있다: state.saved.unshift(title)로 메모리에는 전부 쌓이고(app.js:191), localStorage에는 앞 50개만 나간다(app.js:192). 반면 unsave(app.js:873-874)는 state.saved 전체를 그대로 저장한다. 따라서 50개를 넘긴 뒤 하나를 지우면 잘려 있던 항목들이 되살아나 저장된다.
2. 질의 실행 흐름
2.1 전체 흐름
flowchart TD
A["사용자 입력<br/>#editor (textarea)"] --> B{"트리거"}
B -->|"Ctrl/Cmd + Enter<br/>app.js:820-823"| C
B -->|"#run-btn 클릭<br/>app.js:818"| C
B -->|"[data-run] 요소 클릭<br/>app.js:862-869"| C
C["run()<br/>app.js:663"] --> D{"query 비었나?"}
D -->|"예"| Z1["조용히 return<br/>app.js:665"]
D -->|"아니오"| E["history.unshift + slice(0,100)<br/>histIdx = -1<br/>localStorage 'og.history'<br/>app.js:667-670"]
E --> F{"query === ':clear'?"}
F -->|"예"| Z2["#stream.innerHTML = ''<br/>editor 비우기<br/>app.js:672-676"]
F -->|"아니오"| G["addFrame(query)<br/>#stream.prepend<br/>body = 'running…'<br/>app.js:678, 173-197"]
G --> H["editor 비우고 autosize()<br/>app.js:679-680"]
H --> I{"콜론 명령 분기<br/>app.js:682-685"}
I -->|":sql "| J1["runSql → POST /api/sql"]
I -->|":explain "| J2["runExplain → POST /api/explain"]
I -->|":why "| J3["runWhy → POST /api/diagnose"]
I -->|":schema"| J4["runSchemaCmd → GET /api/schema"]
I -->|"그 외"| K["POST /api/cypher<br/>{graph, query, params:{}}<br/>app.js:688"]
K --> L{"res.ok"}
L -->|"false"| M["errorView(data, query)<br/>탭 1개 'Error'<br/>loadAudit()<br/>app.js:691-699"]
L -->|"true"| N["뷰 조립<br/>app.js:701-710"]
N --> N1{"data.graph.nodes.length > 0"}
N1 -->|"예"| N2["Graph 탭 추가<br/>graphView(data.graph)"]
N1 -->|"아니오"| N3
N2 --> N3["Table 탭"] --> N4["JSON 탭"]
N4 --> N5{"data.compiled_sql 존재"}
N5 -->|"예"| N6["SQL 탭"]
N5 -->|"아니오"| O
N6 --> O["setViews(frame, views, status)<br/>app.js:712-718"]
O --> P["loadSchema() + loadAudit()<br/>app.js:719-720"]
2.2 각 단계의 부작용
| 단계 | 부작용 | 근거 |
|---|---|---|
run() 진입 |
질의를 실행 전에 히스토리에 넣는다. 실패한 질의도 남는다 | app.js:667 |
addFrame() |
프레임을 #stream에 prepend — 최신이 위 |
app.js:187 |
addFrame() |
.close / .save 핸들러를 이 시점에 붙인다 |
app.js:188-195 |
setViews() |
tabs.innerHTML = '' + body.innerHTML = ''로 기존 뷰를 통째로 버린다 |
app.js:202-203 |
| 성공 후 | loadSchema()를 매 질의마다 다시 호출 (인스턴스 수 갱신 목적) |
app.js:719 |
| 성공/실패 후 | loadAudit() 호출 |
app.js:697, 720 |
주의: runSql / runExplain / runWhy / runSchemaCmd는 loadAudit()을 부르지 않는다. :sql로 실행한 SQL은 og_audit에도 남지 않는다(엔진의 감사 기록은 og_cypher 안에만 있다, engine/src/cypher/mod.rs:96, 107).
2.3 콜론 명령 5종
| 명령 | 접두 길이 | 라우트 | 만드는 탭 | 근거 |
|---|---|---|---|---|
:sql <stmt> |
slice(5) |
POST /api/sql |
Table, JSON | app.js:682, 723-737 |
:explain <cypher> |
slice(9) |
POST /api/explain |
SQL, Plan | app.js:683, 739-753 |
:why <cypher> |
slice(5) |
POST /api/diagnose |
Diagnosis | app.js:684, 755-787 |
:schema |
완전 일치 | GET /api/schema |
Schema, Storage | app.js:685, 789-800 |
:clear |
완전 일치 | — | (스트림 비움) | app.js:672-676 |
:clear만 addFrame() 이전에 처리된다. 나머지 넷은 프레임이 이미 만들어진 뒤 분기한다.
3. 뷰 전환 — 탭은 인덱스로 묶여 있다
3.1 구조
flowchart LR
subgraph frame [".frame (DOM)"]
direction TB
H["header"] --> T[".tabs"]
H --> S[".status"]
B[".body"]
end
T -->|"tabs.children[i]"| BTN["button.tab<br/>(.active 클래스)"]
B -->|"body.children[i]"| PANE["div.view<br/>(.active 클래스)"]
BTN -. "같은 i" .-> PANE
// portal/web/app.js:208-214
tab.onclick = () => {
$$('.tab', tabs).forEach((t) => t.classList.remove('active'));
$$('.view', body).forEach((x) => x.classList.remove('active'));
tab.classList.add('active');
body.children[i].classList.add('active');
if (v.onShow) v.onShow(body.children[i]);
};
전환 상태는 CSS 클래스 하나다. .view { display:none } / .view.active { display:block } (style.css:147-148).
결정적 결합: 탭 버튼 i와 패널 body.children[i]가 배열 인덱스로만 묶여 있다. 탭과 패널 사이에 id도 aria-controls도 없다. views 배열의 순서를 바꾸거나 중간에 조건부 탭을 삽입하면 조용히 어긋난다.
3.2 뷰 순서 규칙 (/api/cypher 경로)
[Graph]? → Table → JSON → [SQL]?
Graph는data.graph.nodes.length > 0일 때만 (app.js:702).SQL은data.compiled_sql이 truthy할 때만 (app.js:708). 쓰기 질의는 서버가compiled_sql: null을 준다 (index.js:196-202).
따라서 첫 번째 탭이 무엇인지가 결과에 따라 달라진다. setViews는 항상 views[0]을 활성화한다 (app.js:218, 223).
3.3 onShow 훅
graphView만 onShow를 돌려준다 (app.js:346). 캔버스는 display:none 상태에서 크기가 0이므로, 탭이 보이는 시점에 sim.resize()를 다시 불러야 한다.
// portal/web/app.js:346
return { node: wrap, onShow: () => sim.resize() };
setViews는 첫 뷰에 대해 즉시 한 번 호출한다 (app.js:223).
4. 히스토리 — histIdx의 의미
stateDiagram-v2
[*] --> Idle: histIdx = -1
Idle --> Browsing: ArrowUp — 한 줄일 때만
Browsing --> Browsing: ArrowUp → histIdx++
Browsing --> Browsing: ArrowDown → histIdx--
Browsing --> Idle: ArrowDown at histIdx 0 — editor 비움
Browsing --> Idle: run() → histIdx = -1
Idle --> Idle: run() → history.unshift(query)
// portal/web/app.js:824-836
} else if (e.key === 'ArrowUp' && !$('#editor').value.includes('\n')) {
if (state.histIdx + 1 < state.history.length) {
state.histIdx++;
$('#editor').value = state.history[state.histIdx];
...
} else if (e.key === 'ArrowDown' && state.histIdx >= 0) {
state.histIdx--;
$('#editor').value = state.histIdx >= 0 ? state.history[state.histIdx] : '';
규칙:
history[0]이 가장 최근 질의 (unshift,app.js:667).- ArrowUp은 에디터에 개행이 없을 때만 히스토리로 동작한다. 여러 줄 질의를 편집 중이면 커서 이동이 정상 동작한다.
- ArrowDown이
histIdx를 -1로 되돌리면 에디터를 비운다 — 편집 중이던 내용은 이미 덮어써진 뒤이므로 복구 불가. - 상한 100 (
app.js:668). 중복 제거는 없다 — 같은 질의를 세 번 돌리면 세 번 들어간다.
5. 사이드바 · 패널 상태
flowchart TD
RB["rail-btn[data-panel] 클릭<br/>app.js:841-851"] --> R1["모든 .rail-btn에서 .active 제거"]
R1 --> R2["클릭한 버튼에 .active"]
R2 --> R3["모든 .panel에 .hidden 토글<br/>p.dataset.panel !== btn.dataset.panel"]
R3 --> R4{"어느 패널?"}
R4 -->|"audit"| A1["loadAudit()"]
R4 -->|"saved"| A2["renderSaved()"]
R4 -->|"schema / help"| A3["(아무것도 안 함)"]
- 활성 패널은 DOM 클래스가 유일한 소스다.
state에 기록되지 않는다. 새로고침하면 항상schema패널로 돌아간다 (index.html:14가 하드코딩). [data-panel]선택자를 쓰는 이유는 레일의/benchmark.html링크(index.html:26)를 건너뛰기 위함이다 (app.js:839-841의 주석이 명시).#audit-refresh버튼(index.html:63)에는 핸들러가 없다.grep -n "audit-refresh" portal/web/*.js결과 0건. 죽은 UI다. (→FE-13)
5.1 스키마 로드 체인
sequenceDiagram
participant P as 페이지 로드
participant H as loadHealth()
participant S as loadSchema()
participant SRV as 서버
P->>H: app.js:879
H->>SRV: GET /api/health
alt 실패 또는 data.ok === false
SRV-->>H: 503 {ok:false, error, ...}
H->>H: #conn-dot .ok 제거
H->>H: #db-info = "not connected" + error
Note over H: return — loadSchema 호출 안 함
else 성공
SRV-->>H: 200 {ok:true, version, database, server, graphs[]}
H->>H: #graph-select 채움
H->>H: state.graph = localStorage['og.graph'] 또는 graphs[0].name
Note over H: 저장된 그래프가 목록에 없으면 graphs[0]로 되돌림 (app.js:63)
H->>S: await loadSchema() (app.js:70)
S->>SRV: GET /api/schema?graph=...
SRV-->>S: {schema, stats}
S->>S: state.schema / state.stats 저장
S->>S: #entity-chips / #rel-chips / #prop-chips innerHTML
S->>S: renderHierarchy(ents, rels)
end
loadSchema()는 실패하면 조용히 return한다 (app.js:75). 사이드바가 텅 빈 채로 남고 사용자에게는 아무 표시도 없다. (→ FE-16)
6. 오류 상태
6.1 오류가 화면에 도달하는 경로
flowchart TD
E["PostgreSQL 오류<br/>(pg 드라이버 예외)"] --> P["pgError(e)<br/>index.js:67-75<br/>{error, code, detail, hint, where}"]
P --> H["HTTP 400<br/>(health는 503)"]
H --> A["api() → {ok:false, data}<br/>app.js:39-47"]
A --> B{"호출자"}
B -->|"run()"| C["errorView(data, query)<br/>프레임에 'Error' 탭 1개<br/>app.js:692-696"]
B -->|"runSql/runExplain/runWhy"| D["errorView(...)<br/>app.js:726, 742, 758"]
B -->|"loadSchema"| S1["if (!ok) return — 무시<br/>app.js:75"]
B -->|"loadAudit"| S2["if (!ok) return — 무시<br/>app.js:143"]
B -->|"runSchemaCmd"| S3["if (!ok) return — 프레임이<br/>'running…'에서 영구 정지<br/>app.js:791"]
B -->|"dblclick expand"| S4["if (!ok) return — 무시<br/>app.js:560"]
C --> F["'did you mean:' 정규식 매칭<br/>app.js:287"]
F -->|"매칭"| G["후보 칩 버튼 생성<br/>클릭 시 query.split(wrong).join(cand)<br/>후 run() 재실행<br/>app.js:295-304"]
F -->|"미매칭"| I["메시지만 표시"]
6.2 오류 상태에서 남는 것 / 사라지는 것
| 항목 | 오류 후 상태 |
|---|---|
| 에디터 내용 | 사라진다 — run()이 addFrame 직후 비운다 (app.js:679) |
| 히스토리 | 남는다 — 실행 전에 넣었으므로 ↑로 복구 가능 (app.js:667) |
| 프레임 | 남는다. Error 탭 1개 + status에 <span class="bad">failed</span> |
og_audit |
파싱 오류는 남지 않는다 — audit() INSERT 직후 error!()가 트랜잭션을 중단시켜 롤백된다 (engine/src/cypher/mod.rs:96-97). 컴파일/실행 오류는 audit()을 아예 부르지 않는다 (mod.rs:137-140). 따라서 app.js:149-150의 r.error_code ? 'error' : ... 분기는 og_cypher 경로로는 도달하지 않는다. (코드 구조 근거. 실측은 하지 않음.) |
| 스키마 사이드바 | 갱신되지 않음 — loadSchema()는 성공 경로에서만 호출 (app.js:719) |
6.3 복구 경로
- ↑ 키 — 히스토리에서 실패한 질의를 되불러온다.
- 교정 칩 — 엔진이
did you mean:을 냈을 때만.app.js:287의 정규식/did you mean:\s*(.+?)\.?$/m에 의존한다. 엔진이 이 문구를 바꾸면 조용히 죽는다. :why <query>—POST /api/diagnose로 컴파일 검사 + 패턴 워크 + 추정치.- 프레임 닫기 —
.close버튼. 되돌릴 수 없다.
전역 복구 없음: window.onerror도 unhandledrejection 핸들러도 없다. api()의 fetch가 네트워크 레벨에서 던지면(서버 다운 등) run()이 rejected promise를 남기고 프레임은 running…에서 멈춘다.
7. 그래프 뷰의 내부 상태 (클로저)
createSimulation()이 반환하는 것은 { resize, fit, get running, toggle }뿐이다 (app.js:618-628). 나머지는 전부 클로저 안이다:
| 변수 | 초기값 | 무엇 | 근거 |
|---|---|---|---|
nodes |
원형 배치 + 지터 | {d, id, x, y, vx, vy, r} |
app.js:356-364 |
edges |
양 끝점이 index에 있는 것만 |
{d, s, t} |
app.js:366-368 |
index |
Map<id, node> |
중복 삽입 방지 | app.js:365 |
view |
{x:0, y:0, k:1} |
팬/줌 | app.js:370 |
running |
true |
❙❙ 버튼이 토글 | app.js:371, 624-627 |
alpha |
1 |
매 스텝 *= 0.994, < 0.005면 정지 |
app.js:372, 387, 438 |
dragging / panning / hover / selected |
null |
포인터 상태 | app.js:373-376 |
alpha를 되살리는 지점이 세 곳 있다: 노드 mousedown(0.35, app.js:529), 드래그 중(0.25, app.js:539), 더블클릭 확장(1, app.js:580), toggle()(0.3, app.js:626).
8. 벤치마크 페이지의 상태 (별도 앱)
portal/web/benchmark.js는 app.js와 아무것도 공유하지 않는다. 상태는 두 개뿐이다:
// portal/web/benchmark.js:51-52
let DATA = null;
let active = 0;
flowchart TD
I["init()<br/>benchmark.js:56"] --> F["fetch('/api/benchmark')"]
F -->|"throw"| X1["fail(e.message)<br/>#gate 빨강 + '.empty'"]
F --> C{"DATA.scales 비었나?"}
C -->|"예"| X2["fail('no benchmark results found…')"]
C -->|"아니오"| A["active = scales.length - 1<br/>(가장 큰 그래프)"]
A --> S["buildSwitcher()<br/>aria-pressed로 선택 표시"]
S --> R["render()"]
R --> R1["renderGate(scale)"]
R --> R2["#report.innerHTML = ''<br/>chartCard + latencyCard + costCard"]
R --> M["fillMetrics()<br/>[data-metric] 텍스트 치환"]
SW["switcher 버튼 클릭"] --> A2["active = i<br/>aria-pressed 갱신"] --> R
active의 초기값이 가장 큰 스케일이라는 점이 중요하다 (benchmark.js:65).fillMetrics()는render()와 달리active를 보지 않고 항상 마지막 스케일을 쓴다 (benchmark.js:356). 스위처를 바꿔도 산문 속 숫자는 바뀌지 않는다.
이 페이지의 데이터 계약과 현재 어긋난 부분은 05_benchmark_report.md.
9. 랜딩 사이트의 상태 (별도 앱)
web/index.html:1803:
var state = { q: 0, view: "sql" };
q= 선택된 예제 질의 인덱스 (0..2),view="sql" | "table" | "graph".render()(index.html:1826-1848)가aria-selected속성과#pane-out.innerHTML을 갱신한다.- 데이터는 파일 안의
QUERIES상수(index.html:1673-1756)에 하드코딩되어 있다. 네트워크 요청이 하나도 없다. graph탭은q.graph === true인 질의에서만 보인다 (index.html:1831-1833). 숨겨진 상태에서 선택되어 있었다면sql로 되돌린다.
상세는 06_landing_site.md.
10. 규칙
필수 (Required)
setViews()에 넘기는views배열의 순서를 바꾸려면onShow인덱스 결합을 함께 확인한다 (app.js:212-213).- localStorage 키를 추가하면
og.접두를 유지한다 (기존 3개 모두 이 규약). - 새 비동기 호출을 추가할 때
if (!ok) return;으로 끝내지 않는다. 최소한 프레임 상태를Error뷰로 바꾼다 (app.js:692-696패턴). - 히스토리에 넣는 시점을 실행 전으로 유지한다. 실패한 질의를 ↑로 복구하는 유일한 경로다 (
app.js:667).
금지 (Forbidden)
state객체에 렌더 결과를 담지 않는다. 결과는 DOM에만 산다는 것이 현재 설계다. 섞으면 두 개의 진실이 생긴다.#editor의 값을 상태로 읽어 쓰는 새 코드를 만들지 않는다.run()이 즉시 비운다 (app.js:679) — 이미errorView의 교정 버튼이 클로저로query를 붙잡아 두는 방식으로 우회하고 있다 (app.js:280, 301).- 프레임 DOM에
id이외의 데이터를 심지 않는다.frameSeq는 단순 카운터이고 프레임 조회 API가 없다. localStorage.getItem결과를 try/catch 없이JSON.parse하는 코드를 더 추가하지 않는다 (app.js:17-18이 이미 이 문제를 갖고 있다).
11. 미확인
- 브라우저 탭 두 개에서 동시에 Studio를 열었을 때
og.saved/og.history가 어떻게 어긋나는지 실측하지 않았다. 코드상storage이벤트 리스너가 없으므로 마지막에 쓴 쪽이 이긴다. og_audit에 오류 행이 실제로 남지 않는다는 것은 코드 구조(§6.2)로 추론했다. 라이브 DB에서 확인하지 않았다.