회사 프로젝트에서 카테고리 트리 같은 걸 보여줘야 하는 요구가 생겼다.
"검색 필터에 트리 UI 하나 붙이면 되겠지" 정도로 생각했는데, 막상 손대 보니 생각보다 고려할 게 많았다.
- 트리 데이터를 백엔드에서 받아와서 jsTree 형식으로 변환해야 하고
- 화면에 트리가 여러 개 붙을 수 있고
- 탭 전환/필터 리셋 시 기존 트리를 깔끔하게 destroy 해줘야 하고
- 같은 ID로 여러 번 초기화하면 메모리 누수도 신경 써야 했다
그래서 그냥 여기저기서 $().jstree(...)를 호출하는 대신,
jsTree 관리 모듈을 하나 만들고 그 위에서만 트리를 제어하는 구조로 정리했다.
이 글은 그때 썼던 패턴을 나중에 내가 다시 봐도 이해할 수 있게,
조금 일기처럼 정리해놓은 기록이다.
1. 객체 리터럴 vs class, 뭐로 묶을지 먼저 결정
처음 고민은 단순했다.
"이걸 class로 찍어낼까, 아니면 객체 리터럴 하나로 끝낼까"
내 상황은 이랬다.
- 페이지 전체에서 jsTree 설정(테마, 타입, 플러그인) 은 한 세트만 있으면 됨
- 대신 트리 인스턴스는 여러 개 생길 수 있음 (#jstreeXXX1, #jstreeXXX2 등)
그래서 구조를 이렇게 나눴다.
- jsTree를 직접 생성/파괴하는 관리 로직
→ JsTreeManager라는 객체 리터럴 하나에 모으기 (싱글톤 느낌) - 실제 jsTree 인스턴스들
→ Map 안에 targetId → instance 형태로 저장
결국 "매번 새로운 매니저 인스턴스를 만들 필요가 없다"는 결론이라,
이번 케이스에서는 class보다 객체 리터럴 + Map 조합이 더 자연스러웠다.
2. DOM 요소 먼저 한 군데로 모아두기
jsTree 붙이기 전에 제일 먼저 한 작업은
DOM 요소들을 한 번에 모아두는 객체를 만든 거였다.
// DOM 요소를 그룹화한 객체 (Singleton 스타일)
const elements = {
pageTitle: document.getElementById("mnTabPaneTitle"),
tabMenus: document.querySelectorAll(".mnTabMenu"),
filterBox: document.getElementById("mnFilterBox"),
jsTreeContainers: {
xxx1: document.getElementById("jstreeXXX1"),
xxx2: document.getElementById("jstreeXXX2"),
},
};
이렇게 해두니 편한 점이 많았다.
- HTML 구조가 바뀌었을 때,
셀렉터를 찾으려고 파일 전체를 뒤질 필요가 없음 - jsTree를 어느 DOM에 붙이는지 한 눈에 보임
- 디버깅할 때도 "이 페이지가 어떤 DOM에 의존하는지"가 한 객체에 모여 있음
실제로 트리를 초기화할 때는 이렇게 썼다.
JsTreeManager.create("jstreeXXX1", xxx1TreeData);
JsTreeManager.create("jstreeXXX2", xxx2TreeData);
DOM을 직접 건드려야 할 때는 elements.jsTreeContainers.xxx1 같은 식으로 접근했다.
나중에 보면 "이 페이지의 UI 스펙 문서" 같은 역할을 elements가 대신해준다.
3. 백엔드 데이터 → jsTree 데이터, 재귀로 변환
백엔드에서 내려오는 데이터 구조는 대략 이런 느낌이었다고 치자.
[
{
unit: {
title: "상위 노드",
child: [
{ title: "하위 1", child: [] },
{ title: "하위 2", child: [{ title: "손자 1", child: [] }] },
],
},
},
// ...
]
하지만 jsTree는 이런 구조를 원한다.
[
{
text: "상위 노드",
type: "default",
children: [
{ text: "하위 1", type: "leaf", children: [] },
{ text: "하위 2", type: "default", children: [/* ... */] },
],
},
// ...
]
그래서 재귀 함수 하나를 두고,
unit / child 구조를 jsTree 구조로 바꿔주는 변환 단계를 만들었다.
// unit/child 형태의 데이터를 jsTree용 구조로 변환
const convertTreeData = (data) => {
const processNode = (node) => ({
text: node.title, // 표시 텍스트
type: Utility.isEmpty(node.child) ? "leaf" : "default", // 자식 유무에 따라 type 지정
children: node.child?.map(processNode) || [], // 자식 노드 재귀 변환
});
// 최상위 배열을 순회하며 unit을 변환
return data.map((item) => processNode(item.unit));
};
재귀 흐름을 말로 풀면 이런 느낌이다.
- convertTreeData는 최상위 배열만 돈다
→ 각 요소의 unit을 processNode에 넘긴다 - processNode는
- title → text로 옮기고
- child가 없으면 type: "leaf"
- child가 있으면 type: "default"
- children에는 child.map(processNode) 결과를 넣는다
- 이걸 재귀로 반복하면 트리 전체가 한 번에 jsTree용 구조로 변환된다
실제 사용은 이런 식이다.
const treeData = convertTreeData(apiResponse);
JsTreeManager.create("jstreeXXX1", treeData);
트리 구조가 어떻게 생겼는지는 convertTreeData 안에서만 신경 쓰고,
나머지 코드는 "트리 데이터 하나 받았다"는 전제로 쓸 수 있어서 훨씬 깔끔해졌다.
4. JsTreeManager – 객체 리터럴 + Map으로 인스턴스 관리
핵심은 jsTree 인스턴스를 관리하는 매니저 모듈이다.
// jsTree 관리 모듈
const JsTreeManager = {
instances: new Map(),
create: function (targetId, data) {
// 같은 ID의 트리가 있으면 제거
this.destroy(targetId);
// jQuery 선택자
const $tree = $(`#${targetId}`);
// jsTree 인스턴스 생성
const instance = $tree.jstree({
core: {
themes: { responsive: true },
data: data,
},
types: {
default: { icon: "fa fa-folder text-primary" },
leaf: { icon: "fa fa-leaf text-success" },
},
plugins: ["types"],
});
// 로드 완료 후 전체 펼치기
instance.on("ready.jstree", () => {
instance.jstree("open_all");
});
// 인스턴스 저장
this.instances.set(targetId, instance);
return instance;
},
destroy: function (targetId) {
if (this.instances.has(targetId)) {
this.instances.get(targetId).jstree("destroy");
this.instances.delete(targetId); // 메모리 누수 방지
}
},
};
여기서 신경 썼던 포인트 몇 가지를 따로 정리해 둔다.
4-1. instances를 Object 대신 Map으로 둔 이유
처음에는 단순히 {}로도 충분하지 않을까 했는데,
이번 케이스에서는 Map이 확실히 더 편했다.
- has, get, set, delete, size 같은 컬렉션 메서드가 이미 있다
- 나중에 필요하면 Object.fromEntries(instances)로 객체로도 변환 가능
- jsTree 인스턴스를 "컬렉션"으로 다루는 게 코드 레벨에서 더 잘 드러난다
키가 문자열이라 Object로도 가능하지만,
"여러 인스턴스를 자주 추가/삭제하는 구조"라면 Map이 훨씬 자연스럽다고 느꼈다.
4-2. create 안에서 destroy를 먼저 부르는 패턴
트리는 생각보다 자주 다시 그려진다.
- 필터 변경
- 탭 전환
- 검색 조건에 따라 다른 트리를 보여줄 때
이때 매번 jstree(...)만 다시 호출하면
이전 인스턴스가 DOM/Evnet에 매달린 채로 남아서 쌓인다.
그래서 "같은 ID로 다시 만들기 전에 destroy부터" 라는 룰을 아예 코드로 박아뒀다.
create: function (targetId, data) {
this.destroy(targetId);
// ...
}
이 한 줄 덕분에 이런 문제를 같이 막을 수 있다.
- 메모리 누수
- 이벤트 핸들러 중복 등록
- 동일 DOM에 여러 jsTree가 겹쳐 올라가는 현상
실제로 트리를 재생성하는 입장에서는
"create를 한 번 더 부르면 알아서 기존 게 치워진다"는 보장이 생겨서 쓰기도 편하다.
4-3. ready.jstree에서 open_all을 거는 이유
사용자 입장에서는 트리가 펼쳐져 있는 편이 보통 더 편했다.
그래서 "로드 끝나면 일단 전부 열어두자" 쪽으로 결정했다.
instance.on("ready.jstree", () => {
instance.jstree("open_all");
});
jsTree 내부에서 데이터 로딩이 끝난 시점에만 open_all이 실행되기 때문에,
초기 렌더에서 트리가 반쯤만 열려 있다가 다시 깜빡이는 문제도 줄었다.
나중에 "최대 2단계까지만 자동 오픈" 같은 요구가 생기면
이 콜백 안 로직만 바꿔주면 된다.
5. 비동기 데이터 로딩은 async/await로 정리
트리 데이터는 대부분 API로 내려왔다.
그래서 트리 렌더링은 항상 이런 흐름을 탔다.
- fetch/axios로 트리 데이터 요청
- convertTreeData로 jsTree 형식으로 변환
- JsTreeManager.create로 실제 트리 생성
실제 코드는 대략 이런 형태였다.
async function loadTreeAndRender(targetId, url) {
try {
const res = await fetch(url);
const json = await res.json();
const treeData = convertTreeData(json);
JsTreeManager.create(targetId, treeData);
} catch (error) {
console.error("트리 로딩 중 에러 발생:", error);
}
}
Promise 체이닝으로 쓰면
fetch(url)
.then(res => res.json())
.then(json => convertTreeData(json))
.then(treeData => JsTreeManager.create(targetId, treeData))
.catch(...)
이렇게 길게 내려가서 jsTree 코드까지 섞이며 읽기 힘들어지는 느낌이라,
이번에는 그냥 async/await 쪽이 더 보기 좋았다.
트리가 여러 개일 때는 Promise.all을 같이 썼다.
async function loadAllTrees() {
const [xxx1Res, xxx2Res] = await Promise.all([
fetch("/api/tree/xxx1"),
fetch("/api/tree/xxx2"),
]);
const xxx1Data = convertTreeData(await xxx1Res.json());
const xxx2Data = convertTreeData(await xxx2Res.json());
JsTreeManager.create("jstreeXXX1", xxx1Data);
JsTreeManager.create("jstreeXXX2", xxx2Data);
}
둘 사이에 의존 관계가 없어서 굳이 순차로 돌릴 이유도 없었고,
병렬 호출 + 한 번에 렌더링이 제일 깔끔했다.
6. Map vs Object, 이번 프로젝트에서의 기준
jsTree 쪽에서 컬렉션이 필요했던 부분은 크게 두 군데였다.
- JsTreeManager.instances 같은 인스턴스 관리
- 나중에 붙일지도 모를 캐시/설정 관리
이번 프로젝트에서 기준은 이렇게 잡았다.
- 키가 문자열이고, JSON 직렬화 중심으로 쓸 단순한 구조
→ 그냥 Object - 자주 추가/삭제하고, 컬렉션 전용 연산이 필요한 경우
→ Map
JsTreeManager.instances는 다음 조건에 딱 맞았다.
- 키: targetId
- 값: jsTree 인스턴스
- 필요한 연산: has, get, set, delete, size
그래서 그냥 망설임 없이 Map을 썼다.
const instances = new Map();
instances.set("jstreeXXX1", xxx1Instance);
instances.set("jstreeXXX2", xxx2Instance);
if (instances.has("jstreeXXX1")) {
instances.get("jstreeXXX1").jstree("destroy");
}'FrontEnd > JavaScript' 카테고리의 다른 글
| JavaScript Function.call / apply / bind - this (0) | 2025.09.09 |
|---|---|
| [실무 기록] 폼 실시간 공유 3편 – Yjs Awareness로 상대 커서,선택 상태 보여주기 (0) | 2025.09.08 |
| [실무 기록] 폼 실시간 공유 2편 - WebSocket 서버 구조랑 네임스페이스 적응기 (0) | 2025.09.08 |
| [실무 기록] 폼 실시간 공유 1편 CRDT, Yjs, WebSocket을 파보기까지 (0) | 2025.09.08 |
| JavaScript async/await와 Promise 체이닝, 어떻게 골라 쓸까 (0) | 2024.11.28 |