[실무 기록] jQuery jsTree를 모듈로 감싸서 쓰면서 배운 것들

2025. 9. 8. 11:33·FrontEnd/JavaScript

회사 프로젝트에서 카테고리 트리 같은 걸 보여줘야 하는 요구가 생겼다.
"검색 필터에 트리 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로 내려왔다.
그래서 트리 렌더링은 항상 이런 흐름을 탔다.

  1. fetch/axios로 트리 데이터 요청
  2. convertTreeData로 jsTree 형식으로 변환
  3. 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
'FrontEnd/JavaScript' 카테고리의 다른 글
  • [실무 기록] 폼 실시간 공유 3편 – Yjs Awareness로 상대 커서,선택 상태 보여주기
  • [실무 기록] 폼 실시간 공유 2편 - WebSocket 서버 구조랑 네임스페이스 적응기
  • [실무 기록] 폼 실시간 공유 1편 CRDT, Yjs, WebSocket을 파보기까지
  • JavaScript async/await와 Promise 체이닝, 어떻게 골라 쓸까
프론트엔드 개발자 jbeat
프론트엔드 개발자 jbeat
프론트엔드 개발자 블로그인데 일상도 쪼그으믐
  • 프론트엔드 개발자 jbeat
    jbeat 님의 블로그
    프론트엔드 개발자 jbeat
  • 전체
    오늘
    어제
    • 분류 전체보기 (44)
      • FrontEnd (43)
        • TypeScript (6)
        • JavaScript (18)
        • Next.js (3)
        • React (1)
        • Testing (2)
        • Third Party (1)
        • web (10)
        • Tooling (1)
        • coding test (0)
        • A.I (1)
      • 일상 (1)
        • wedding (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 인기 글

  • 태그

    Android
    playwright
    CRDT
    Next.js
    코테
    주니어
    컬렉션
    고차함수
    javascript
    TypeScript
    CrossOrigin
    Utility
    WebSocket
    이터러블
    preconnect
    배열
    yjs
    타입스크립트
    pick
    omit
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
프론트엔드 개발자 jbeat
[실무 기록] jQuery jsTree를 모듈로 감싸서 쓰면서 배운 것들
상단으로

티스토리툴바