UI5

리스트 없이 계층 표시 3가지 — UI5 Tree #shorts #SAP #UI5

▶ YouTube에서 보기

개요 및 이 글에서 다루는 것

SAP UI5 애플리케이션에서 조직도, 자재 명세서(BOM), 파일 시스템 같은 계층 구조 데이터를 표현해야 하는 요구는 매우 빈번합니다. 단순 ListTable로는 부모-자식 관계를 직관적으로 표현하기 어렵기 때문에 sap.m.Tree 컨트롤이 등장합니다. 이 글은 중급 UI5 개발자를 대상으로, 단순 노드 렌더링부터 대용량 트리의 성능 최적화까지 단계별로 짚어봅니다.

  • sap.m.Tree와 StandardTreeItem의 내부 동작 원리 이해
  • JSONModel 기반 계층 데이터 모델링과 arrayNames 바인딩 파라미터
  • select, toggleOpenState 이벤트의 차이와 활용 패턴
  • BOM 시나리오에서 동적 노드 조작 및 컨트롤러 분리
  • 대용량 트리에서의 지연 로딩 및 성능 고려사항

필요한 사전 지식

이 글을 효과적으로 따라가려면 다음 항목에 대한 기본 이해가 권장됩니다. UI5 XML View 구조와 컨트롤러 라이프사이클(onInit, onAfterRendering), JSONModel 양방향 바인딩, 그리고 sap.m.List/ListBase 계열 컨트롤의 aggregation 바인딩 경험이 있으면 충분합니다. Tree는 내부적으로 ListBase를 상속하므로 List 바인딩 지식이 곧장 전이됩니다.

실습 환경 및 준비물

본 예제는 다음 환경을 기준으로 작성되었습니다.

  • SAP UI5: 1.120.x LTS (Long-term Maintenance) 또는 1.124.x 최신 안정 버전
  • 개발 도구: SAP Business Application Studio 또는 VS Code + ui5-tooling (@ui5/cli 3.x)
  • 런타임: Node.js 18 LTS 이상, Chrome/Edge 최신 버전 권장
  • 프레임워크 구성: sap.m, sap.ui.model.json.JSONModel 라이브러리 포함
  • 선택 사항: SAP Fiori 디자인 가이드라인을 따르는 sap_horizon 테마

로컬 개발 시 ui5.yamlsap.msap.ui.core 라이브러리를 명시하고, ui5 serve로 핫 리로드 환경을 띄워두면 노드 구조 변경 결과를 즉시 확인할 수 있어 반복 작업이 빨라집니다.

핵심 개념과 동작 원리

sap.m.Tree는 표면적으로는 들여쓰기된 리스트처럼 보이지만, 내부적으로는 ClientTreeBinding 또는 ODataTreeBinding이 노드의 평탄화(flattening)와 깊이(level) 계산을 처리합니다. 즉, 화면에 보이는 것은 1차원 리스트이지만 각 항목이 자신의 깊이와 펼침 상태를 알고 있어 들여쓰기와 화살표 토글이 가능한 구조입니다.

비유하자면 트리 컨트롤은 마치 접을 수 있는 아코디언 책장과 같습니다. 책장의 각 칸(노드)은 자신의 단(level)을 알고 있고, 부모 칸이 닫히면 그 아래 모든 칸이 시야에서 사라지지만 메모리에는 여전히 존재합니다. 그래서 펼침 상태(expanded)와 데이터(children)는 별도 차원으로 관리됩니다.

JSONModel을 사용할 때 핵심은 arrayNames 바인딩 파라미터입니다. UI5는 기본적으로 객체의 모든 배열 속성을 자식 후보로 간주하는데, 모델에 메타 배열(예: 권한 목록, 태그 등)이 섞여 있으면 의도치 않은 노드가 펼쳐집니다. arrayNames: ["children"] 또는 ["subDepartments", "members"]처럼 명시하면 해당 키만 자식으로 인식합니다.

이벤트 측면에서는 두 가지 핵심 이벤트를 구분해야 합니다. select는 항목 선택 시(클릭 또는 키보드), toggleOpenState는 노드의 펼침 화살표 클릭 시 발생합니다. 전자는 비즈니스 로직(상세 화면 이동 등)에, 후자는 지연 로딩이나 자식 카운트 갱신 같은 UI 보조 동작에 사용합니다.

또한 StandardTreeItem은 아이콘, 텍스트, 카운터 정도의 단순 표현에 적합하고, 더 복잡한 셀(여러 컬럼, 액션 버튼 등)이 필요하면 CustomTreeItem으로 자유롭게 내용을 구성할 수 있습니다.

실전 예제 1단계 — 기본 조직도 표시

가상의 회사 조직도(본부 → 팀 → 구성원)를 트리로 표시하는 가장 단순한 형태부터 시작합니다.

<mvc:View
    controllerName="kr.btpstacks.org.controller.OrgChart"
    xmlns="sap.m"
    xmlns:mvc="sap.ui.core.mvc">
    <Page title="우리 회사 조직도">
        <Tree
            id="orgTree"
            items="{
                path: '/divisions',
                parameters: { arrayNames: ['units'] }
            }">
            <StandardTreeItem
                title="{label}"
                icon="{iconUri}"
                counter="{headcount}" />
        </Tree>
    </Page>
</mvc:View>
sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/ui/model/json/JSONModel"
], function (Controller, JSONModel) {
    "use strict";

    return Controller.extend("kr.btpstacks.org.controller.OrgChart", {
        onInit: function () {
            var oOrgData = {
                divisions: [{
                    label: "기술본부",
                    iconUri: "sap-icon://building",
                    headcount: 42,
                    units: [{
                        label: "플랫폼팀",
                        iconUri: "sap-icon://group",
                        headcount: 12,
                        units: [
                            { label: "김도윤 책임", iconUri: "sap-icon://employee", headcount: 0 },
                            { label: "박세린 선임", iconUri: "sap-icon://employee", headcount: 0 }
                        ]
                    }, {
                        label: "데이터팀",
                        iconUri: "sap-icon://group",
                        headcount: 8,
                        units: []
                    }]
                }]
            };
            this.getView().setModel(new JSONModel(oOrgData));
        }
    });
});

핵심은 parameters: { arrayNames: ['units'] }입니다. 만약 이 파라미터를 생략하면 label, iconUri 같은 비배열 속성은 무시되지만, 모델에 tags: [] 같은 다른 배열이 들어오는 순간 트리 구조가 깨질 수 있어 명시적으로 선언하는 것이 안전합니다.

실전 예제 2단계 — BOM 시나리오와 이벤트 처리

제조업의 자재 명세서(Bill of Materials)는 제품 → 모듈 → 부품 → 원자재로 이어지는 깊은 계층을 가지며, 사용자가 노드를 펼칠 때마다 재고 정보를 표시하고 선택 시 상세 패널을 갱신해야 합니다.

<Tree
    id="bomTree"
    mode="SingleSelectMaster"
    items="{
        path: '/assemblies',
        parameters: { arrayNames: ['subParts'] }
    }"
    selectionChange=".onPartSelected"
    toggleOpenState=".onNodeToggled">
    <StandardTreeItem
        title="{partName} ({partCode})"
        icon="{= ${stockLevel} > 0 ? 'sap-icon://accept' : 'sap-icon://warning' }"
        counter="{stockLevel}" />
</Tree>
sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/ui/model/json/JSONModel",
    "sap/base/Log",
    "sap/m/MessageToast"
], function (Controller, JSONModel, Log, MessageToast) {
    "use strict";

    return Controller.extend("kr.btpstacks.bom.controller.BomExplorer", {
        onInit: function () {
            var oBomModel = new JSONModel({
                assemblies: [{
                    partCode: "ASM-9001",
                    partName: "전동 드라이브 조립체",
                    stockLevel: 15,
                    subParts: [{
                        partCode: "MOD-204",
                        partName: "제어 모듈",
                        stockLevel: 4,
                        subParts: [
                            { partCode: "CHP-77A", partName: "MCU 칩셋", stockLevel: 0, subParts: [] },
                            { partCode: "RES-12K", partName: "정밀 저항 세트", stockLevel: 320, subParts: [] }
                        ]
                    }, {
                        partCode: "MOT-501",
                        partName: "BLDC 모터",
                        stockLevel: 7,
                        subParts: []
                    }]
                }]
            });
            this.getView().setModel(oBomModel);
        },

        onPartSelected: function (oEvent) {
            var oItem = oEvent.getParameter("listItem");
            var oContext = oItem.getBindingContext();
            var oPart = oContext.getObject();

            Log.info("부품 선택됨: " + oPart.partCode, null, "BomExplorer");

            if (oPart.stockLevel === 0) {
                MessageToast.show(oPart.partName + " — 재고 없음, 발주 검토 필요");
            }
            // 실제로는 라우터로 상세 화면 전환
            // this.getOwnerComponent().getRouter().navTo("partDetail", { code: oPart.partCode });
        },

        onNodeToggled: function (oEvent) {
            var bExpanded = oEvent.getParameter("expanded");
            var oItem = oEvent.getParameter("itemContext").getObject();
            Log.debug(oItem.partCode + " 노드 " + (bExpanded ? "펼침" : "접힘"));
        }
    });
});

mode="SingleSelectMaster"로 단일 선택 모드를 활성화했고, selectionChange에서 getBindingContext()를 통해 선택된 노드 객체에 직접 접근합니다. 재고가 0인 경우 사용자에게 즉시 토스트로 알림을 주는 식의 비즈니스 룰을 가볍게 얹을 수 있습니다.

실전 예제 3단계 — 동적 노드 조작과 대용량 대응

프로덕션 환경에서는 사용자가 새 하위 부품을 추가하거나, 트리 전체를 펼치거나, 수천 개 노드를 점진적으로 로딩해야 합니다.

sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/ui/model/json/JSONModel",
    "sap/m/MessageBox"
], function (Controller, JSONModel, MessageBox) {
    "use strict";

    return Controller.extend("kr.btpstacks.bom.controller.BomManager", {

        // 선택된 노드 아래 새 부품 추가
        onAddSubPart: function () {
            var oTree = this.byId("bomTree");
            var aSelected = oTree.getSelectedItems();
            if (aSelected.length === 0) {
                MessageBox.warning("부모 노드를 먼저 선택해주세요.");
                return;
            }
            var oContext = aSelected[0].getBindingContext();
            var oParent = oContext.getObject();
            var sParentPath = oContext.getPath();

            oParent.subParts = oParent.subParts || [];
            oParent.subParts.push({
                partCode: "NEW-" + Date.now(),
                partName: "신규 부품",
                stockLevel: 0,
                subParts: []
            });

            // 변경 알림 — 해당 경로만 갱신하여 전체 리렌더 회피
            this.getView().getModel().refresh(false);
            oTree.expand(oTree.indexOfItem(aSelected[0]));
        },

        // 전체 펼치기 (대용량 시 주의)
        onExpandAll: function () {
            var oTree = this.byId("bomTree");
            var iCount = oTree.getItems().length;
            if (iCount > 500) {
                MessageBox.confirm(
                    iCount + "개 노드를 모두 펼치면 성능이 저하될 수 있습니다. 계속하시겠습니까?",
                    {
                        onClose: function (sAction) {
                            if (sAction === MessageBox.Action.OK) {
                                oTree.expandToLevel(99);
                            }
                        }
                    }
                );
            } else {
                oTree.expandToLevel(99);
            }
        },

        // 지연 로딩 — 펼침 시 서버에서 자식 fetch
        onNodeToggled: function (oEvent) {
            if (!oEvent.getParameter("expanded")) { return; }

            var oCtx = oEvent.getParameter("itemContext");
            var oNode = oCtx.getObject();
            if (oNode._loaded || (oNode.subParts && oNode.subParts.length > 0)) {
                return;
            }
            var that = this;
            jQuery.ajax({
                url: "/api/bom/children?parent=" + encodeURIComponent(oNode.partCode),
                dataType: "json"
            }).done(function (aChildren) {
                oNode.subParts = aChildren;
                oNode._loaded = true;
                that.getView().getModel().refresh(false);
            }).fail(function () {
                MessageBox.error("하위 부품 조회 실패: " + oNode.partCode);
            });
        }
    });
});

대용량 트리에서는 expandToLevel(99)처럼 무차별 펼침을 피하고, 노드가 실제로 펼쳐질 때마다 자식을 비동기로 가져오는 패턴을 권장합니다. _loaded 플래그를 두어 동일 노드의 중복 요청을 막고, 보안 측면에서는 encodeURIComponent로 사용자 입력 키를 escape하는 점도 잊지 마세요. OData V4 환경이라면 $$aggregation 또는 hierarchical OData 어노테이션을 활용한 서버사이드 지연 로딩이 더 적합합니다.

흔한 실수와 트러블슈팅

Q1. 트리가 펼쳐지지 않거나 전혀 표시되지 않습니다.
가장 흔한 원인은 arrayNames 파라미터 누락입니다. 모델 객체에 자식 배열 외의 다른 배열(예: permissions: [])이 있으면 바인딩이 혼란을 겪습니다. 또한 루트 path가 객체가 아닌 배열을 가리키도록 path: '/divisions' 형태인지 확인하세요.

Q2. 노드를 동적으로 추가했는데 화면에 반영되지 않습니다.
JSONModel은 객체 참조 변경을 자동 감지하지 못합니다. 배열 push 후 반드시 oModel.refresh(false) 또는 해당 경로에 대해 oModel.setProperty(sPath, aNewArray)를 호출해 갱신을 알려야 합니다.

Q3. 노드가 1000개 이상이 되니 브라우저가 느려집니다.
sap.m.Tree는 기본적으로 모든 펼쳐진 노드를 DOM에 렌더링합니다. growing="true"growingThreshold로 점진 렌더링을 활성화하거나, 위 3단계 예제처럼 펼침 시점에 자식을 가져오는 지연 로딩으로 전환하세요. 또한 CustomTreeItem에 무거운 컨트롤(차트, 이미지 다수)을 넣는 것을 피하고, 가능하면 StandardTreeItem의 단순 표현을 사용하는 것이 일반적으로 권장됩니다.

Q4. selectionChange가 키보드 탐색에서도 발생합니다.
이는 의도된 동작입니다. 마우스 클릭만 구분하려면 itemPress 이벤트와 결합하거나, 핸들러 내에서 oEvent.getParameter("selectAll")이나 외부 플래그로 사용자 의도를 구분하세요.

심화 학습 방향

기본 트리를 익혔다면 다음 주제로 확장해보길 권장합니다. 첫째, sap.ui.table.TreeTable로 옮겨가 다중 컬럼과 정렬·필터를 결합한 분석형 트리 그리드를 구현해보세요. 둘째, OData V4의 계층 어노테이션(Hierarchy.RecursiveHierarchy)을 활용하면 서버 측에서 부모-자식 관계를 표현해 클라이언트 데이터 전송량을 크게 줄일 수 있습니다. 셋째, 드래그앤드롭(sap.ui.core.dnd.DragDropInfo)을 추가하면 사용자가 부서 이동이나 BOM 재구성을 직관적으로 수행할 수 있는 인터랙티브 UI로 발전시킬 수 있습니다.

더 읽어볼 자료

댓글 0

아직 댓글이 없습니다.