UI5

Table vs List vs Tree 선택 기준 3가지 #shorts #SAP #UI5

▶ YouTube에서 보기

이 글에서 다룰 것

UI5에서 다량의 비즈니스 데이터를 화면에 뿌릴 때 가장 먼저 부딪히는 고민은 "어떤 컨트롤을 써야 하지?"입니다. sap.m.Table, sap.m.List, sap.m.Tree는 겉보기에는 비슷하지만, 데이터 구조와 사용자 시나리오에 따라 명확히 다른 선택을 요구합니다. 이 글은 영업 주문(SalesOrder)과 직원 조직도를 예시로, 세 컨트롤의 차이와 실전 판단 기준을 정리합니다.

  • Table / List / Tree의 구조적 차이 이해
  • OData V2/V4 바인딩 패턴 비교
  • Responsive Table과 Grid Table의 차이 파악
  • 계층형 데이터 처리 시 Tree 바인딩 옵션 학습
  • 흔한 성능/렌더링 이슈 대응 방법 습득

사전에 알아두면 좋은 개념

이 글을 편하게 읽으려면 UI5의 MVC 구조와 XML View 문법, JSONModel/ODataModel의 기본 개념을 알고 있어야 합니다. 또한 items aggregation과 factory function, template 개념을 한 번쯤 다뤄본 경험이 있으면 좋습니다. OData 서비스에서 Expand/Navigation Property가 무엇인지도 미리 파악해두면 Tree 예제가 훨씬 명확하게 이해됩니다.

실습 환경 및 준비물

이 글의 코드는 다음 환경에서 검증됩니다.

  • SAPUI5 1.120 LTS (또는 1.108 이상 SAPUI5 배포판)
  • SAP Business Application Studio 또는 VS Code + Easy-UI5 Generator
  • Node.js 18 LTS, UI5 Tooling 3.x
  • OData V2 Northwind 데모 서비스 또는 CAP 프로젝트 기반 V4 서비스
  • Chrome/Edge 최신 버전 (렌더링 검증용)

SAP BTP에 배포하는 경우 SAP Fiori Launchpad 컨테이너에서 sap.m 라이브러리가 자동 로드되므로 별도 임포트는 필요 없습니다. 로컬 개발 시에는 manifest.jsonsap.ui5.dependencies.libssap.msap.ui.table이 등록되어 있는지 확인합니다.

Table, List, Tree 컨트롤의 본질

세 컨트롤은 모두 items aggregation을 갖고 있고 데이터 바인딩 방식이 유사하지만, 표현 목적이 다릅니다.

List는 "한 줄 = 한 개체"의 흐름을 그대로 보여주는 컨트롤입니다. 각 항목이 StandardListItem, ObjectListItem, FeedListItem 등 다양한 형태를 가질 수 있으며, 열(column) 개념이 없다는 것이 핵심입니다. 알림함, 채팅 목록, 최근 방문 문서처럼 속성보다 개체 그 자체가 중요한 경우 선택합니다.

Table은 "행 × 열"의 격자를 표현합니다. sap.m.Table(Responsive Table)은 모바일에서 열이 자동으로 접히거나 pop-in 되지만, sap.ui.table.Table(Grid Table)은 대량 데이터를 가상 스크롤로 렌더링합니다. 영업 주문 목록처럼 동일한 스키마의 데이터를 정렬/필터/비교하는 데 적합합니다.

Tree는 부모-자식 관계를 계층으로 표현합니다. 조직도, BOM(자재 명세서), 카테고리-상품처럼 Hierarchy가 데이터의 본질인 경우가 대상입니다. sap.m.Tree는 List 계열의 확장형이고, sap.ui.table.TreeTable은 Tree + Table의 하이브리드입니다.

비유하자면 List는 "포스트잇 벽", Table은 "엑셀 스프레드시트", Tree는 "폴더 탐색기"입니다. 같은 데이터라도 사용자가 무엇을 하려고 하느냐에 따라 답이 달라집니다.

실전 예제 1: List로 최근 주문 요약 보여주기

영업 주문 중 최근 항목을 간단히 요약해서 보여주는 화면입니다. 열이 필요 없고, 고객명과 총액 정도만 강조하고 싶을 때 List가 가장 적합합니다.

<mvc:View
    controllerName="com.example.orders.controller.RecentOrders"
    xmlns:mvc="sap.ui.core.mvc"
    xmlns="sap.m">
    <Page title="최근 영업 주문">
        <List
            id="recentOrderList"
            items="{
                path: '/SalesOrderSet',
                sorter: { path: 'OrderDate', descending: true },
                length: 20
            }"
            growing="true"
            growingThreshold="10">
            <ObjectListItem
                title="{CustomerName}"
                number="{
                    parts: [ 'GrossAmount', 'CurrencyCode' ],
                    type: 'sap.ui.model.type.Currency',
                    formatOptions: { showMeasure: false }
                }"
                numberUnit="{CurrencyCode}"
                type="Navigation"
                press=".onOrderPress">
                <attributes>
                    <ObjectAttribute text="주문번호: {SalesOrderId}" />
                    <ObjectAttribute text="{
                        path: 'OrderDate',
                        type: 'sap.ui.model.type.Date',
                        formatOptions: { style: 'medium' }
                    }" />
                </attributes>
            </ObjectListItem>
        </List>
    </Page>
</mvc:View>
sap.ui.define([
    "sap/ui/core/mvc/Controller"
], function (Controller) {
    "use strict";
    return Controller.extend("com.example.orders.controller.RecentOrders", {
        onOrderPress: function (oEvent) {
            var oCtx = oEvent.getSource().getBindingContext();
            this.getOwnerComponent().getRouter().navTo("orderDetail", {
                orderId: oCtx.getProperty("SalesOrderId")
            });
        }
    });
});

실전 예제 2: Responsive Table로 영업 주문 목록 다루기

주문번호, 고객, 금액, 상태를 한눈에 비교해야 한다면 List로는 부족합니다. 열 헤더가 필요한 순간이 곧 Table을 선택할 순간입니다. 아래는 정렬 가능하고, 모바일에서는 자동으로 pop-in 되는 Responsive Table 예제입니다.

<mvc:View
    controllerName="com.example.orders.controller.OrderList"
    xmlns:mvc="sap.ui.core.mvc"
    xmlns="sap.m">
    <Table
        id="orderTable"
        items="{
            path: '/SalesOrderSet',
            parameters: { $count: true, $$updateGroupId: 'orderUpdate' }
        }"
        growing="true"
        growingThreshold="50"
        mode="MultiSelect"
        sticky="ColumnHeaders,HeaderToolbar">
        <headerToolbar>
            <OverflowToolbar>
                <Title text="영업 주문 ({= ${orderView>/count}})" />
                <ToolbarSpacer />
                <SearchField width="18rem" search=".onSearch" />
            </OverflowToolbar>
        </headerToolbar>
        <columns>
            <Column width="8rem">
                <Text text="주문번호" />
            </Column>
            <Column minScreenWidth="Tablet" demandPopin="true">
                <Text text="고객" />
            </Column>
            <Column hAlign="End">
                <Text text="총액" />
            </Column>
            <Column minScreenWidth="Desktop" demandPopin="true">
                <Text text="상태" />
            </Column>
        </columns>
        <items>
            <ColumnListItem type="Navigation" press=".onRowPress">
                <cells>
                    <ObjectIdentifier title="{SalesOrderId}" />
                    <Text text="{CustomerName}" />
                    <ObjectNumber
                        number="{GrossAmount}"
                        unit="{CurrencyCode}"
                        state="{= ${GrossAmount} > 10000 ? 'Success' : 'None' }" />
                    <ObjectStatus
                        text="{LifecycleStatusDescription}"
                        state="{= ${LifecycleStatus} === 'C' ? 'Success' : 'Warning' }" />
                </cells>
            </ColumnListItem>
        </items>
    </Table>
</mvc:View>
sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/ui/model/Filter",
    "sap/ui/model/FilterOperator",
    "sap/base/Log"
], function (Controller, Filter, FilterOperator, Log) {
    "use strict";
    return Controller.extend("com.example.orders.controller.OrderList", {
        onSearch: function (oEvent) {
            var sQuery = oEvent.getParameter("query") || "";
            var oBinding = this.byId("orderTable").getBinding("items");
            if (!oBinding) {
                Log.warning("Binding not ready", "OrderList");
                return;
            }
            var aFilters = sQuery ? [
                new Filter({
                    filters: [
                        new Filter("CustomerName", FilterOperator.Contains, sQuery),
                        new Filter("SalesOrderId", FilterOperator.EQ, sQuery)
                    ],
                    and: false
                })
            ] : [];
            oBinding.filter(aFilters);
        },
        onRowPress: function (oEvent) {
            var oCtx = oEvent.getSource().getBindingContext();
            Log.info("Row pressed: " + oCtx.getProperty("SalesOrderId"));
        }
    });
});

실전 예제 3: TreeTable로 조직도 렌더링 (프로덕션)

직원 조직도는 계층 구조가 본질입니다. 상위 관리자 아래 팀장, 팀원이 매달려 있으며 사용자는 이를 접었다 폈다 해야 합니다. sap.ui.table.TreeTable은 대량 계층 데이터를 가상 스크롤로 처리하며, OData V4의 $$aggregation 파라미터를 통해 서버 사이드 hierarchy를 지원합니다.

<mvc:View
    controllerName="com.example.org.controller.OrgChart"
    xmlns:mvc="sap.ui.core.mvc"
    xmlns:t="sap.ui.table"
    xmlns="sap.m">
    <Page title="직원 조직도">
        <t:TreeTable
            id="orgTree"
            rows="{
                path: '/EmployeeHierarchy',
                parameters: {
                    $$aggregation: {
                        hierarchyQualifier: 'EmpHierarchy',
                        expandTo: 2
                    }
                }
            }"
            selectionMode="Single"
            visibleRowCountMode="Auto"
            rowSelectionChange=".onEmpSelect">
            <t:columns>
                <t:Column width="18rem">
                    <Label text="이름" />
                    <t:template>
                        <Text text="{EmpName}" wrapping="false" />
                    </t:template>
                </t:Column>
                <t:Column>
                    <Label text="직책" />
                    <t:template>
                        <Text text="{JobTitle}" />
                    </t:template>
                </t:Column>
                <t:Column hAlign="End">
                    <Label text="팀원 수" />
                    <t:template>
                        <ObjectNumber number="{ReportsCount}" />
                    </t:template>
                </t:Column>
            </t:columns>
        </t:TreeTable>
    </Page>
</mvc:View>
sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/base/Log",
    "sap/m/MessageToast"
], function (Controller, Log, MessageToast) {
    "use strict";
    return Controller.extend("com.example.org.controller.OrgChart", {
        onInit: function () {
            var oTree = this.byId("orgTree");
            oTree.attachBusyStateChanged(function (oEvent) {
                Log.debug("Tree busy: " + oEvent.getParameter("busy"));
            });
        },
        onEmpSelect: function (oEvent) {
            var oRow = oEvent.getParameter("rowContext");
            if (!oRow) { return; }
            var sName = oRow.getProperty("EmpName");
            var sMgr = oRow.getProperty("ManagerName") || "(최상위)";
            MessageToast.show(sName + " ← 보고 대상: " + sMgr);
        },
        onExpandAll: function () {
            var oTree = this.byId("orgTree");
            try {
                oTree.expandToLevel(99);
            } catch (e) {
                Log.error("Expand failed", e);
            }
        }
    });
});

프로덕션에서는 다음을 반드시 챙깁니다. 첫째, TreeTable의 visibleRowCountMode="Auto"는 컨테이너 크기가 계산 가능해야 하므로 상위에 height가 확정된 컨테이너를 둡니다. 둘째, OData V4의 hierarchy는 백엔드(CAP 또는 ABAP CDS)에서 @Hierarchy.RecursiveHierarchy 어노테이션이 선언되어야 합니다. 셋째, QUnit + OPA5 테스트에서는 rows 바인딩 로딩을 iWaitForElement로 명시적으로 기다려야 flaky 테스트를 피할 수 있습니다.

실무에서 자주 걸리는 함정

세 컨트롤을 오가면서 가장 자주 만나는 문제 3가지를 정리합니다.

Q1. Responsive Table에 수만 건을 바인딩했더니 스크롤이 버벅입니다. sap.m.Table은 DOM에 실제 행을 렌더링하므로 5,000건 이상에서는 성능이 급격히 떨어집니다. 이 경우 sap.ui.table.Table(Grid Table)로 교체하는 것이 일반적으로 권장됩니다. Grid Table은 가상 스크롤(row recycling)을 사용해 화면에 보이는 행만 DOM에 유지합니다.

Q2. Tree가 확장되지 않고 빈 화면만 나옵니다. V2 ODataTreeBinding을 쓴다면 parametershierarchyLevelFor, hierarchyParentNodeFor, hierarchyNodeFor, hierarchyDrillStateFor 4가지가 모두 매핑되어야 합니다. V4에서는 백엔드 어노테이션 미비가 원인인 경우가 많습니다. 네트워크 탭에서 $apply=ancestors 요청이 400을 반환한다면 서버 hierarchy 정의를 다시 점검합니다.

Q3. List의 growing과 Table의 growing이 어떻게 다른가요? 동작 원리는 유사하지만, Responsive Table의 growingThreshold는 pop-in 계산 비용이 함께 늘어난다는 점에 주의합니다. 열이 많을수록 임계값을 낮추는 편이 부드럽습니다. 반대로 List는 시각적 요소가 단순해 growingThreshold를 크게 잡아도 큰 부담이 없습니다.

이 외에도 ColumnListItem을 List에 넣는 실수, StandardTreeItem을 Table에 넣는 실수가 흔합니다. aggregation 이름이 모두 items이라 IDE에서 잡히지 않으므로, XML 저장 후 콘솔에 Unknown aggregation content in element 같은 경고가 뜨는지 반드시 확인합니다.

이어서 살펴볼 주제

  • SmartTable / SmartFilterBar를 통한 어노테이션 기반 자동 생성
  • UI5 flexible column layout과 List+Detail 패턴 결합
  • OData V4 $$aggregation을 활용한 서버 사이드 그룹핑
  • Fiori Elements List Report / Object Page의 내부 구조 파악
  • Grid Table의 thresholdenableBusyIndicator 튜닝

댓글 0

아직 댓글이 없습니다.