- 발행일
JSTree 플러그인 활용과 사용자 정의 컨텍스트 메뉴
JSTree 플러그인 활용과 사용자 정의 컨텍스트 메뉴
JSTree 기본 초기화에서 한 단계 더 나아가, 플러그인을 조합하고 컨텍스트 메뉴를 직접 정의하는 방법을 정리합니다.
플러그인 종류와 역할
$('#tree').jstree({
plugins: [
'checkbox', // 노드에 체크박스 추가
'contextmenu', // 우클릭 메뉴
'dnd', // 드래그 앤 드롭
'massload', // 대용량 데이터 지연 로딩
'search', // 노드 검색
'sort', // 자동 정렬
'state', // 열림/닫힘 상태 저장
'types', // 노드 타입별 아이콘/규칙 설정
'unique', // 같은 부모 아래 중복 이름 방지
'wholerow', // 행 전체 선택 영역
'changed', // 변경된 노드 추적
'conditionalselect' // 조건부 선택 허용/차단
]
});
| 플러그인 | 주요 용도 |
|---|---|
checkbox | 다중 선택 체크박스 |
contextmenu | 우클릭 메뉴 커스터마이징 |
dnd | 노드 드래그로 위치 변경 |
search | 노드 텍스트 검색 |
state | 페이지 새로고침 후에도 트리 상태 유지 |
types | 노드 타입별 아이콘, 허용 자식 타입 등 규칙 |
unique | 같은 레벨에서 이름 중복 방지 |
wholerow | 클릭 영역을 행 전체로 확장 |
conditionalselect | 특정 노드 선택 비활성화 |
contextmenu 플러그인으로 우클릭 메뉴 구성
기본 contextmenu는 이름 변경/생성/삭제만 제공합니다. 실무에서는 추가/편집/삭제/복사/붙여넣기 등을 직접 정의해야 합니다.
$('#tree').jstree({
plugins: ['contextmenu'],
contextmenu: {
items: function (node) {
return {
create: {
label: '추가',
icon: 'fa fa-plus',
action: function (data) {
const inst = $.jstree.reference(data.reference);
inst.create_node(node, {}, 'last', function (new_node) {
inst.edit(new_node);
});
}
},
rename: {
label: '편집',
icon: 'fa fa-edit',
action: function (data) {
const inst = $.jstree.reference(data.reference);
inst.edit(node);
}
},
copy: {
label: '복사',
icon: 'fa fa-copy',
action: function (data) {
const inst = $.jstree.reference(data.reference);
inst.copy(node);
}
},
paste: {
label: '붙여넣기',
icon: 'fa fa-paste',
action: function (data) {
const inst = $.jstree.reference(data.reference);
inst.paste(node);
},
// 클립보드에 항목이 있을 때만 활성화
_disabled: function () {
return !$.jstree.reference('#tree').can_paste();
}
},
remove: {
label: '삭제',
icon: 'fa fa-trash',
action: function (data) {
const inst = $.jstree.reference(data.reference);
inst.delete_node(node);
},
separator_before: true
}
};
}
}
});
컨텍스트 메뉴에서 모달 띄우기
단순 인라인 편집 대신 모달로 상세 입력을 받아야 할 때:
contextmenu: {
items: function (node) {
return {
edit: {
label: '편집',
action: function () {
// 선택된 노드 데이터를 모달에 채움
const nodeData = node.original || {};
$('#modal-name').val(node.text);
$('#modal-id').val(node.id);
$('#editModal').modal('show');
}
},
create: {
label: '하위 노드 추가',
action: function () {
$('#parent-id').val(node.id);
$('#createModal').modal('show');
}
}
};
}
}
모달 저장 버튼에서 AJAX로 서버에 전송 후 트리를 갱신:
$('#saveEdit').on('click', function () {
const id = $('#modal-id').val();
const name = $('#modal-name').val();
$.ajax({
url: `/api/nodes/${id}/`,
method: 'PATCH',
data: JSON.stringify({ name }),
contentType: 'application/json',
success: function () {
$('#tree').jstree('rename_node', id, name);
$('#editModal').modal('hide');
}
});
});
dnd 플러그인 — 드래그로 노드 이동
$('#tree').jstree({
plugins: ['dnd'],
// dnd 이벤트로 이동 후 서버에 반영
});
$('#tree').on('move_node.jstree', function (e, data) {
const nodeId = data.node.id;
const newParentId = data.parent;
const position = data.position;
$.ajax({
url: `/api/nodes/${nodeId}/move/`,
method: 'POST',
data: JSON.stringify({ parent: newParentId, position }),
contentType: 'application/json'
});
});
types 플러그인 — 노드 타입별 규칙
루트, 폴더, 파일처럼 타입마다 다른 아이콘과 허용 동작을 지정할 수 있습니다:
$('#tree').jstree({
plugins: ['types'],
types: {
root: {
icon: 'fa fa-home',
valid_children: ['folder']
},
folder: {
icon: 'fa fa-folder',
valid_children: ['folder', 'file']
},
file: {
icon: 'fa fa-file',
valid_children: [] // 자식 추가 불가
}
}
});
이벤트 목록 (주요)
// 노드 선택
$('#tree').on('select_node.jstree', function (e, data) {
console.log(data.node.id, data.node.text);
});
// 노드 생성 후
$('#tree').on('create_node.jstree', function (e, data) {
// 서버에 새 노드 등록
});
// 노드 삭제 전
$('#tree').on('delete_node.jstree', function (e, data) {
// 서버에서 삭제 요청
});
// 이름 변경 후
$('#tree').on('rename_node.jstree', function (e, data) {
// 서버에 이름 수정 요청
});
실무에 붙이면서 알게 된 것
위 예제들은 문법 정리에 가깝습니다. 실제 표준데이터 트리에 붙여 쓰면서 예제 그대로 두면 안 되는 지점이 몇 개 나왔어요.
① 트리는 이미 바뀌었고, 서버는 아직 모른다
move_node.jstree 예제를 다시 봅니다.
$('#tree').on('move_node.jstree', function (e, data) {
$.ajax({ url: `/api/nodes/${data.node.id}/move/`, method: 'POST', ... });
});
이벤트가 뜬 시점에 트리는 이미 옮겨진 상태입니다. 여기서 보내는 요청이 실패해도 화면은 그대로예요. error 콜백도 없고, 되돌리는 코드도 없습니다.
그래서 서버가 500을 내거나 네트워크가 끊기면 사용자는 옮겨진 트리를 보고 있는데 DB는 옛 위치가 됩니다. 새로고침하면 원래대로 돌아가 있고요. 삭제·이름 변경 예제도 전부 같은 구조입니다.
이건 이 프로젝트에서 반복해서 만난 문제와 같은 계열이에요 — 정본과 파생 캐시가 어긋난 것2026-07-10고쳤는데 목록은 옛날 값 — 정본과 파생 캐시를 시그널로 다시 붙인 회고인스턴스를 수정해도 목록·다운로드·RDF는 옛 값을 보여줬습니다. 링크 시점에 한 번 복사하고 끝이었거든요. 캐시 제거·조회 시 재계산·쓰기 시 시그널 세 안을 놓고 마지막을 고른 이유와, 감사 로그가 폭발하지 않게 bulk_update로 일부러 시그널을 우회한 트레이드오프를 정리합니다.도, 비동기 피커에서 값이 조용히 사라진 것2026-07-11라벨을 기다리다 값이 사라진다 — 비동기 관계 피커의 prefill 소실 수리편집 화면에서 아무것도 건드리지 않고 저장만 눌렀는데, 이미 선택돼 있던 값이 빈 값으로 저장되는 버그가 있었습니다. 세 가지 해결책을 놓고 고민한 끝에 "값은 동기, 라벨만 비동기"로 나누는 씨앗 패턴을 골랐어요. 에러 로그가 남지 않는 조용한 데이터 소실을 어떻게 재현하고 잡았는지 정리합니다.도 결국 화면이 보여주는 상태와 저장된 상태가 다른 문제였습니다.
최소한 이렇게는 가야 합니다.
$('#tree').on('move_node.jstree', function (e, data) {
$.ajax({ ... })
.fail(function () {
// 실패 시 원위치로 되돌리고 사용자에게 알린다
$('#tree').jstree(true).move_node(data.node, data.old_parent, data.old_position);
showError('이동에 실패했습니다.');
});
});
낙관적 갱신을 쓰려면 실패했을 때 되돌리는 코드가 짝으로 있어야 합니다. 되돌릴 수 없다면 서버 응답을 기다렸다 반영하는 쪽이 맞고요.
② _disabled 안에서만 셀렉터가 하드코딩돼 있다
붙여넣기 항목만 트리 참조를 다르게 얻습니다.
action: function (data) {
const inst = $.jstree.reference(data.reference); // 이벤트가 준 참조
inst.paste(node);
},
_disabled: function () {
return !$.jstree.reference('#tree').can_paste(); // 셀렉터 하드코딩
}
한 화면에 트리가 둘 이상이면 엉뚱한 트리의 클립보드 상태를 보고 활성/비활성을 정합니다. 나머지 항목이 전부 data.reference를 쓰고 있어서 더 눈에 안 띄어요. _disabled에도 인자로 넘어오는 참조를 쓰거나, 최소한 컨테이너를 변수로 잡아두고 써야 합니다.
③ 생성 순서가 뒤집혀 있다
create 액션은 노드를 만든 다음 이름 편집을 띄웁니다.
inst.create_node(node, {}, 'last', function (new_node) {
inst.edit(new_node); // 이름은 이 다음에 입력받는다
});
그런데 서버 등록을 create_node.jstree 이벤트에서 하면, 이름이 비어 있는 상태로 먼저 등록됩니다. 사용자가 이름을 입력하면 그건 rename_node.jstree로 따로 날아가고요.
한 번의 사용자 행동이 두 번의 요청이 되고, 중간에 이탈하면 이름 없는 노드가 남습니다. 이름까지 받은 뒤 한 번에 보내거나(모달 방식), 서버에서 임시 상태를 다루도록 설계해야 해요.
④ 그래서 결국 모달을 걷어냈다
이 글은 "상세 입력이 필요하면 모달을 띄우라" 고 정리하는데, 실제 화면에서는 나중에 모달을 걷어내고 인라인 폼으로 옮겼습니다. 트리에서 노드를 옮겨 다니며 연속으로 편집하는 작업에는 모달이 맞지 않았거든요. 그 과정은 모달에서 인라인 폼으로 전환한 회고2026-05-19메타클래스 속성 편집을 모달에서 인라인 폼으로 옮긴 UX 회고메타클래스 속성을 편집할 때마다 컨텍스트를 가리던 모달을 들어내고, 트리/리스트 옆 인라인 폼으로 옮긴 결정과 그 결과를 정리합니다.에 따로 적었습니다.
모달이 틀린 건 아니고, "이 작업이 얼마나 자주 반복되는가" 에 따라 갈립니다. 단발 편집이면 모달, 연속 편집이면 인라인 쪽이 맞았어요.
정리
JSTree의 핵심은 플러그인 조합과 이벤트 처리입니다.
- 기본 CRUD는
contextmenu플러그인의items를 오버라이드 - 상세 입력이 필요하면 모달을 띄우고 AJAX로 서버 연동 — 단, 연속 편집이 잦으면 인라인 폼을 먼저 검토
- 드래그 이동은
move_node.jstree이벤트에서 처리 — 실패 시 되돌리는 코드를 반드시 짝으로 - 노드 타입별 규칙은
types플러그인으로 제어
문법 자체는 어렵지 않은데, 트리 조작이 곧 데이터 변경이라는 점 때문에 실패 처리와 요청 순서가 실제 난이도를 결정합니다.