- 발행일
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는 옛 위치가 됩니다. 새로고침하면 원래대로 돌아가 있고요. 삭제·이름 변경 예제도 전부 같은 구조입니다.
이건 이 프로젝트에서 반복해서 만난 문제와 같은 계열이에요 — 정본과 파생 캐시가 어긋난 것도, 비동기 피커에서 값이 조용히 사라진 것도 결국 화면이 보여주는 상태와 저장된 상태가 다른 문제였습니다.
최소한 이렇게는 가야 합니다.
$('#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로 따로 날아가고요.
한 번의 사용자 행동이 두 번의 요청이 되고, 중간에 이탈하면 이름 없는 노드가 남습니다. 이름까지 받은 뒤 한 번에 보내거나(모달 방식), 서버에서 임시 상태를 다루도록 설계해야 해요.
④ 그래서 결국 모달을 걷어냈다
이 글은 "상세 입력이 필요하면 모달을 띄우라" 고 정리하는데, 실제 화면에서는 나중에 모달을 걷어내고 인라인 폼으로 옮겼습니다. 트리에서 노드를 옮겨 다니며 연속으로 편집하는 작업에는 모달이 맞지 않았거든요. 그 과정은 모달에서 인라인 폼으로 전환한 회고에 따로 적었습니다.
모달이 틀린 건 아니고, "이 작업이 얼마나 자주 반복되는가" 에 따라 갈립니다. 단발 편집이면 모달, 연속 편집이면 인라인 쪽이 맞았어요.
정리
JSTree의 핵심은 플러그인 조합과 이벤트 처리입니다.
- 기본 CRUD는
contextmenu플러그인의items를 오버라이드 - 상세 입력이 필요하면 모달을 띄우고 AJAX로 서버 연동 — 단, 연속 편집이 잦으면 인라인 폼을 먼저 검토
- 드래그 이동은
move_node.jstree이벤트에서 처리 — 실패 시 되돌리는 코드를 반드시 짝으로 - 노드 타입별 규칙은
types플러그인으로 제어
문법 자체는 어렵지 않은데, 트리 조작이 곧 데이터 변경이라는 점 때문에 실패 처리와 요청 순서가 실제 난이도를 결정합니다.