발행일

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 플러그인으로 제어

문법 자체는 어렵지 않은데, 트리 조작이 곧 데이터 변경이라는 점 때문에 실패 처리와 요청 순서가 실제 난이도를 결정합니다.