- 발행일
편집 중 트리를 누르면 입력이 사라졌다 — 되돌린 선택이 다시 화면을 지우는 문제
편집 중 트리를 누르면 입력이 사라졌다
인스턴스 관리 화면은 왼쪽에 클래스 트리, 오른쪽에 상세/편집 폼이 있는 구조입니다. 트리에서 노드를 고르면 오른쪽이 그 노드 내용으로 갈아 끼워져요.
$tree.on("select_node.jstree", function (e, data) {
const id = data.node.id;
if (instItems[id]) renderInstance(instItems[id]);
else if (classItems[id]) renderClass(classItems[id], id);
else renderEmpty("이 네임스페이스에서 클래스를 선택하세요.", "fa-cube");
...
});
문제는 편집 중에도 똑같이 갈아 끼운다는 것이었습니다. 슬롯 열 개를 채우다가 트리를 잘못 누르면 아무 경고 없이 전부 사라집니다. 용어·클래스 화면에는 이미 이탈 가드가 있었는데 인스턴스 화면만 빠져 있었어요.
되묻는 것까지는 5줄, 되돌리는 게 문제였다
confirm()으로 되묻는 것 자체는 간단합니다. 어려운 건 사용자가 "취소"를 눌렀을 때 였어요.
취소를 누르면 트리 선택을 원래 노드로 되돌려야 합니다. 그런데 select_node()를 부르면 그게 또 select_node.jstree 이벤트를 쏩니다. 그 핸들러가 다시 renderInstance()를 불러서, 지키려던 입력을 결국 지워버립니다. 되돌리는 동작이 되돌리려던 것을 파괴하는 구조였습니다.
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
① renderXxx() 호출부에 "편집 중이면 건너뛰기" 조건 | 이벤트는 그대로 흐름 | 렌더 함수가 셋인데다 앞으로 늘어남. 하나 빠뜨리면 그 경로로만 입력이 날아가는, 재현 어려운 버그가 됨 | 기각 |
② 편집 중엔 트리를 통째로 잠금(disable_node) | 애초에 못 누름 | 다른 노드 보기도 막힘. 편집 중 참고하러 옆 노드를 보는 건 정상 사용임 | 기각 |
③ deselect_node 후 select_node, 그 구간만 플래그로 이벤트 무시 | 되돌림이 화면을 안 건드림. 무시 구간이 두 줄로 눈에 보임 | 전역 플래그 하나가 늘어남. 플래그 해제를 빠뜨리면 트리가 통째로 먹통 | 채택 |
④ beforeunload식으로 jstree의 changed.jstree 전 단계에서 취소 | 되돌림 자체가 필요 없음 | jstree는 선택을 취소할 수 있는 전(before) 훅이 없음. select_node.jstree는 이미 선택된 뒤에 온다 | 불가 |
③으로 갔습니다. 핸들러 맨 앞에서 통째로 빠져나가는 형태입니다.
// 트리에서 지금 보고 있는 노드 — 편집 이탈을 되돌릴 때 여기로 선택을 복구한다.
let selectedNodeId = null;
// 되돌리기로 다시 고르는 선택은 통째로 무시한다 — 다시 렌더하면 지키려던 입력이 날아간다.
let suppressSelectEvent = false;
const LEAVE_FORM_MESSAGE = "입력 중인 내용은 저장되지 않습니다. 이동할까요?";
function isEditingForm() {
return mode === "edit" || mode === "add";
}
$tree.on("select_node.jstree", function (e, data) {
if (suppressSelectEvent) return;
const id = data.node.id;
// 편집·추가 중 다른 노드를 고르면 상세가 갈아 끼워져 입력이 사라진다 — 용어·클래스 화면과
// 같은 가드다. 되물으면 트리 선택을 보던 노드로 되돌린다.
// data.event 는 사용자가 노드를 직접 눌렀을 때만 실린다. 저장 직후 refresh_node 가 옛 선택을
// 복원하거나 안내 링크가 옮겨 주는 프로그램 선택에는 없어, 그런 이동까지 되묻지 않는다.
if (data.event && isEditingForm() && id !== selectedNodeId) {
if (!confirm(LEAVE_FORM_MESSAGE)) {
const tree = $tree.jstree(true);
suppressSelectEvent = true;
tree.deselect_node(id);
if (selectedNodeId) tree.select_node(selectedNodeId);
suppressSelectEvent = false;
return;
}
}
selectedNodeId = id;
...
});
data.event가 있느냐 없느냐
이 가드를 붙이자마자 두 번째 문제가 나왔습니다. 저장 직후에도 되묻기 시작한 겁니다.
저장하면 refresh_node()로 트리를 갱신하는데, jstree가 갱신 후 옛 선택을 복원하면서 select_node.jstree를 쏩니다. 그러면 아직 mode가 edit인 시점에 이벤트가 와서, 방금 저장한 사용자에게 "저장 안 됩니다, 이동할까요?"라고 묻습니다. 반대 의미의 경고예요.
"0건 안내" 링크로 다른 클래스로 보내주는 경로도 마찬가지였습니다. 사용자는 이미 링크를 눌러 동의했는데 한 번 더 묻습니다.
구분점은 data.event였습니다. jstree는 사용자가 노드를 직접 눌렀을 때만 원본 DOM 이벤트를 data.event로 실어 줍니다. 코드가 select_node()를 부른 프로그램 선택에는 없어요.
if (data.event && isEditingForm() && id !== selectedNodeId) {
mode만 봐서는 "사용자가 떠나려는 것"과 "코드가 화면을 정리하는 것"을 구별할 수 없었습니다. 라이브러리가 이미 구별해 주고 있었고, 저는 그 필드를 안 보고 있었어요.
같은 뿌리 — refresh()가 비동기다
며칠 뒤 같은 화면의 e2e 스펙 2건이 간헐적으로 빨개졌습니다. 4번 돌리면 1~2번 실패하는 식이라 처음엔 타이밍 이슈로 보고 waitForTimeout을 넣을 뻔했습니다.
async function openAllNamespaces(page) {
await page.goto(INSTANCE_URL);
await page.locator("#nsScopeToggle").click(); // ← 여기서 refresh() 가 돈다
}
// ... 그리고 곧바로
await page.evaluate((id) => window.jQuery("#instanceTree").jstree(true).open_node(id), nsNode);
네임스페이스 스코프 토글의 change 핸들러가 jstree refresh()를 부릅니다. 이게 비동기입니다. 트리가 다시 그려지기 전에 open_node()를 부르면, 예외도 로그도 없이 조용히 무시됩니다. 그 다음 클래스 앵커를 찾는 단계에서야 "없다"고 죽으니, 실패 지점과 원인 지점이 떨어져 있었어요.
앵커가 다시 그려지길 기다린 뒤 펼치도록 분리했습니다.
// 클래스 노드는 네임스페이스를 펼쳐야 붙는다(lazy). 스코프 토글이 트리를 refresh 하므로
// 네임스페이스 앵커가 다시 그려진 뒤에 펼친다 — 그 전에 부르면 조용히 무시된다.
async function openAddForm(page, namespaceNode, classNode) {
await page.locator(`#instanceTree a[id="${namespaceNode}_anchor"]`).waitFor();
await page.evaluate(
(id) => window.jQuery("#instanceTree").jstree(true).open_node(id),
namespaceNode,
);
const classAnchor = page.locator(`#instanceTree a[id="${classNode}_anchor"]`);
await classAnchor.waitFor();
await classAnchor.click();
await page.locator("#drAddBtn").click();
}
waitForTimeout(500)을 안 쓴 이유는 단순합니다. 그건 "얼마나 걸리는지 모르겠다"는 말이라 CI가 느린 날 다시 빨개져요. waitFor()는 무엇을 기다리는지를 적습니다.
이미 다른 스펙(instance-empty-object-slot)에 같은 관용구가 있었는데, 그때는 "이 스펙만의 사정"으로 보고 헬퍼로 안 뺐습니다. 두 번째로 같은 걸 만나고 나서야 뺐어요.
버그 수정에서도 e2e 수정에서도 같은 문장이 나옵니다. 트리 위젯에서 "지금 상태"를 읽고 곧바로 무언가 하는 코드는 거의 다 틀립니다. 렌더가 끝난 시점은 이벤트나 대기로만 알 수 있어요.
검증
- 이탈 가드 회귀 스펙 신설(
instance-edit-leave-guard.spec.js, 107줄) — 편집 중 다른 노드 클릭 → 확인 문구 노출, 취소 시 입력값 보존 + 트리 선택 원위치, 확인 시 이동. - 저장 직후
refresh_node경로에서 확인창이 뜨지 않는 것을 별도 케이스로 못박음. 이게 없으면 다음 사람이data.event조건을 "불필요한 방어"로 보고 지웁니다. - 레이스 스펙 2건 4회 연속 통과(고치기 전에는 4회 중 절반 실패), ESLint 신규 0건.
- 문구는 용어·클래스 화면의 기존 이탈 가드와 동일하게 맞춤 — 같은 성격의 경고가 화면마다 다른 문장이면 사용자가 다른 일로 읽습니다.
남은 것 · 한계
confirm()을 씁니다. 네이티브 모달이라 스타일이 화면과 따로 놀고, 문구에 강조도 못 넣어요. 이 화면군 셋이 다 같은 방식이라 일단 맞췄지만, 공용 확인 모달로 바꾸는 게 맞습니다. 그때는 세 화면을 같이 옮겨야 합니다.suppressSelectEvent는 전역 플래그입니다. 지금은 세우고 지우는 구간이 세 줄 안에 있어 안전하지만, 되돌림 도중에 비동기가 끼어들면 플래그가 켜진 채 남아 트리가 통째로 먹통이 됩니다.try/finally로 감싸는 게 최소한의 방어인데 안 해뒀어요. 다음 수정 때 넣을 자리입니다.- 입력이 실제로 바뀌었는지는 안 봅니다. 편집 모드로 들어갔다가 아무것도 안 고치고 나가도 되묻습니다. dirty 판정을 붙이면 확인창이 눈에 띄게 줄 텐데, 슬롯 값이 리터럴·객체·데이터유형 세 종류라 비교 대상을 정하는 게 간단치 않아 미뤘습니다.
관련 글: Playwright로 메뉴 136종 QA 스윕 · 표준데이터를 고정 컬럼에서 인스턴스 기반 모델로