이 글의 목표와 체크포인트
SAPUI5의 sap.m.List에서 단순 정렬을 넘어 의미 있는 그룹으로 묶어 보여주는 작업은 실무에서 매우 자주 등장합니다. 이 글은 SalesOrder(판매 주문) 리스트를 SalesOrg(판매 조직) 단위로 그룹핑하는 시나리오를 통해, Sorter의 group 옵션, bindItems의 sorter 배열 전달, 그리고 groupHeaderFactory를 통한 커스텀 헤더 출력까지 한 번에 정리합니다. 예제는 SAP UI5 1.120 기준이며 OpenUI5 1.108 이상에서도 동작합니다.
- Sorter 객체에서
group:true가 어떤 동작을 트리거하는지 이해 bindItems옵션 객체에 sorter를 배열로 넘기는 정확한 형식 숙지GroupHeaderListItem을 활용한 헤더 커스터마이징 패턴 적용- 실무에서 자주 발생하는 3가지 실수(group 누락 / sorter 미연결 / 팩토리 누락) 인지
- 다중 그룹 키, 다국어 라벨, 카운트 표시까지 프로덕션 수준 확장
먼저 알고 있어야 할 것들
이 글은 SAPUI5 Model/Binding 개념에 익숙한 개발자를 대상으로 합니다. 구체적으로는 JSONModel 또는 ODataModel(V2/V4)에 대한 기본 사용, XML View와 Controller 분리, Aggregation Binding(items="{...}" 표기)의 동작 방식, 그리고 sap.ui.model.Sorter 클래스의 기본 호출 시그니처에 대한 이해가 필요합니다. Fiori Elements가 아닌 Freestyle UI5 기준으로 설명합니다.
환경과 준비물
예제는 다음 스택을 가정합니다.
- SAPUI5 1.120.x (LTS) — OpenUI5 1.108 이상 호환
- Node.js 20.x, UI5 Tooling 3.x, npm 10.x
- VS Code + SAP Fiori Tools 확장
- BTP ABAP Environment 또는 S/4HANA Cloud Public Edition의 OData V4 서비스(선택)
- 로컬 mock 데이터는
webapp/localService/mockdata/SalesOrders.json으로 준비
UI5 Tooling이 설치된 프로젝트가 없다면 npm init ui5-app 또는 yo easy-ui5 project로 베이스를 생성하고, sap.m, sap.ui.core 라이브러리 의존을 manifest.json에 선언해 두면 됩니다. 그룹핑은 클라이언트 측 정렬에 의존하기 때문에, OData V4를 쓴다면 $orderby가 서버에서도 적용되도록 모델 파라미터를 점검해야 합니다.
그룹핑이 동작하는 원리
UI5 리스트의 그룹핑은 "정렬을 한 번 더 똑똑하게 한다"는 발상에서 출발합니다. sap.ui.model.Sorter는 본질적으로 정렬기이지만, 생성자 인자 group을 true 또는 함수로 넘기는 순간, 정렬 결과의 인접한 동일 키 구간을 묶어 그룹 경계를 생성합니다. 비유하자면 책장에 책을 알파벳 순으로 꽂는 것이 일반 Sorter라면, 알파벳이 바뀌는 지점마다 칸막이(헤더)를 끼우는 것이 group:true입니다.
구체적으로 List 컨트롤은 렌더링 직전에 바인딩으로부터 contexts 배열을 받아옵니다. Sorter에 group 플래그가 있으면 List는 인접 컨텍스트의 그룹 키를 비교해 변경 지점에 별도의 ListItem을 삽입합니다. 기본 동작은 GroupHeaderListItem을 자동 생성해 그룹 키 문자열을 표시하지만, bindItems에 groupHeaderFactory를 함께 넘기면 개발자가 직접 헤더 컨트롤을 생성할 수 있습니다.
여기서 자주 오해하는 부분이 있습니다. View XML에 <Sorter path="SalesOrg"/>만 적어두면 정렬은 되지만 그룹 헤더는 나타나지 않습니다. 반드시 group="true"가 추가되어야 합니다. 또한 Controller에서 bindItems로 동적으로 바인딩한다면, sorter는 옵션 객체의 sorter 프로퍼티에 배열 형태로 전달해야 합니다. 단일 객체로 넘기면 일부 버전에서 무시되거나 경고가 출력됩니다.
마지막으로 groupHeaderFactory는 그룹 객체({key, text})를 받아 컨트롤을 반환하는 함수입니다. 여기서 반환 타입은 반드시 sap.m.GroupHeaderListItem 또는 그 호환 타입이어야 하며, 일반 StandardListItem을 반환하면 시각적으로는 표시되지만 접근성 속성과 키보드 내비게이션이 깨질 수 있습니다.
1단계 — 가장 단순한 그룹 리스트
먼저 XML View에 정적인 Sorter를 선언해 동작 원리를 확인합니다. SalesOrder 데이터를 SalesOrg 필드로 그룹화합니다.
<mvc:View
controllerName="com.example.so.controller.SalesOrderList"
xmlns:mvc="sap.ui.core.mvc"
xmlns="sap.m">
<Page title="판매 주문 목록">
<List id="soList" items="{
path: '/SalesOrders',
sorter: {
path: 'SalesOrg',
descending: false,
group: true
}
}">
<StandardListItem
title="{SalesOrderID}"
description="{CustomerName}"
info="{
path: 'GrossAmount',
formatter: '.formatter.currency'
}" />
</List>
</Page>
</mvc:View>
이 단계에서 핵심은 group: true 한 줄입니다. 이것만으로도 SalesOrg 값이 바뀌는 경계마다 기본 헤더(예: "1010", "2010")가 자동 삽입됩니다. 모델은 다음과 같이 Component.js나 Controller의 onInit에서 설정합니다.
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/ui/model/json/JSONModel"
], function (Controller, JSONModel) {
"use strict";
return Controller.extend("com.example.so.controller.SalesOrderList", {
onInit: function () {
var oModel = new JSONModel(sap.ui.require.toUrl(
"com/example/so/localService/mockdata/SalesOrders.json"));
this.getView().setModel(oModel);
}
});
});
2단계 — Controller에서 동적 바인딩 + 커스텀 헤더
실무에서는 사용자가 "조직별", "고객별", "통화별"처럼 그룹 기준을 토글하는 경우가 많습니다. 이 경우 XML 정적 선언으로는 부족하고 Controller에서 bindItems를 호출하면서 sorter와 groupHeaderFactory를 함께 넘겨야 합니다.
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/ui/model/Sorter",
"sap/m/GroupHeaderListItem",
"sap/base/Log"
], function (Controller, Sorter, GroupHeaderListItem, Log) {
"use strict";
return Controller.extend("com.example.so.controller.SalesOrderList", {
_applyGrouping: function (sGroupField) {
var oList = this.byId("soList");
var oBinding = oList.getBinding("items");
if (!oBinding) {
Log.error("리스트 바인딩이 아직 준비되지 않았습니다.",
null, "SalesOrderList");
return;
}
var oSorter = new Sorter(sGroupField, false, true);
// 세 번째 인자가 group 플래그입니다. 누락 시 헤더가 사라집니다.
oBinding.sort(oSorter);
},
createGroupHeader: function (oGroup) {
return new GroupHeaderListItem({
title: this._formatGroupTitle(oGroup.key),
upperCase: false
});
},
_formatGroupTitle: function (sKey) {
var oBundle = this.getView().getModel("i18n").getResourceBundle();
// 실무에서는 SalesOrg 코드를 텍스트로 매핑
var mLabel = {
"1010": oBundle.getText("salesOrg.de"),
"2010": oBundle.getText("salesOrg.us"),
"3010": oBundle.getText("salesOrg.kr")
};
return mLabel[sKey] || sKey;
},
onGroupBySalesOrg: function () {
this._applyGrouping("SalesOrg");
}
});
});
그리고 View에서는 groupHeaderFactory를 명시적으로 연결합니다.
<List id="soList" items="{
path: '/SalesOrders',
sorter: [{ path: 'SalesOrg', group: true }],
groupHeaderFactory: '.createGroupHeader'
}">
<StandardListItem title="{SalesOrderID}" description="{CustomerName}" />
</List>
이 단계에서 두 가지가 동시에 만족됩니다. 첫째, sorter가 배열로 전달되어 다중 그룹/정렬 키 확장에 대비할 수 있습니다. 둘째, groupHeaderFactory가 컨트롤러 메서드를 가리키므로 i18n 라벨, 카운트 배지, 토글 아이콘 등 자유로운 헤더 디자인이 가능합니다. sap/base/Log로 바인딩 미준비 상태를 명시적으로 로깅하면 라이프사이클 디버깅이 훨씬 수월해집니다.
3단계 — 프로덕션 패턴: 다중 그룹·성능·테스트
실 운영에서는 (a) 대용량 데이터의 그룹 카운트, (b) 그룹 키 변경 시 깜빡임 최소화, (c) 테스트 가능한 팩토리 설계가 중요합니다. 아래는 그룹 단위 합계와 항목 수를 포함하는 확장 패턴입니다.
createGroupHeader: function (oGroup) {
var oList = this.byId("soList");
var aContexts = oList.getBinding("items").getContexts();
var iCount = 0;
var fTotal = 0;
aContexts.forEach(function (oCtx) {
var oRow = oCtx.getObject();
if (oRow.SalesOrg === oGroup.key) {
iCount += 1;
fTotal += Number(oRow.GrossAmount || 0);
}
});
return new GroupHeaderListItem({
title: this._formatGroupTitle(oGroup.key)
+ " (" + iCount + "건, "
+ fTotal.toLocaleString("ko-KR") + " 원)",
upperCase: false
}).addStyleClass("sapUiSmallMarginTop");
},
OData V4 환경이라면 가능한 한 서버 측 $orderby=SalesOrg가 적용되도록 모델 파라미터를 설정해 클라이언트 정렬 부담을 줄입니다. 또한 growing="true" growingThreshold="50"으로 가상 스크롤을 켜되, growing 동작 중 그룹 헤더가 페이지 경계에서 중복되지 않도록 sorter 객체를 재사용해야 합니다. 매번 새 Sorter 인스턴스를 만들면 바인딩이 reset되어 깜빡임이 발생합니다.
// 권장: sorter 캐싱
_getSorter: function (sField) {
this._mSorters = this._mSorters || {};
if (!this._mSorters[sField]) {
this._mSorters[sField] = new Sorter(sField, false, true);
}
return this._mSorters[sField];
}
테스트는 QUnit + OPA5로 작성합니다. 그룹 헤더가 정확히 N개 렌더링되는지, 각 헤더의 title 텍스트가 i18n 키와 일치하는지 검증하는 것이 일반적입니다. 단위 테스트에서는 createGroupHeader를 직접 호출해 반환 타입이 GroupHeaderListItem인지 확인하는 패턴을 권장합니다.
자주 마주치는 실수와 해결 가이드(FAQ)
Q1. Sorter는 적용됐는데 그룹 헤더가 보이지 않습니다. 가장 흔한 원인은 group: true가 누락된 경우입니다. XML에서는 sorter: { path: 'SalesOrg' }처럼 적어두고 그룹이 나오길 기대하는 경우가 많은데, 이 형태는 단순 정렬만 수행합니다. group: true 또는 그룹 키 산출 함수를 명시적으로 넘겨야 합니다.
Q2. groupHeaderFactory를 지정했는데 호출되지 않습니다. 이때는 두 가지를 확인합니다. 첫째, factory 함수 경로 표기가 '.createGroupHeader'처럼 점(.)으로 시작하는지(View 컨트롤러 메서드 참조 규칙). 둘째, 바인딩 옵션에 sorter가 함께 들어가 있는지. sorter 없이 factory만 지정하면 그룹 경계가 만들어지지 않아 factory도 호출되지 않습니다.
Q3. 동적으로 그룹 기준을 바꾸면 화면이 깜빡이거나 헤더가 중복됩니다. 매 호출마다 새 Sorter를 생성하지 말고 캐싱하세요. 또한 oBinding.sort(aSorters)를 호출할 때 배열을 통째로 새로 만들기보다 기존 배열을 mutate하지 말고 일관된 인스턴스를 유지하는 편이 안전합니다. growing 리스트라면 그룹 변경 직전 oList.setBusy(true)로 잠시 잠가두는 것도 사용자 경험 측면에서 권장됩니다.
Q4. OData V4에서 그룹 카운트가 안 맞습니다. 클라이언트 측 그룹 헤더는 현재 로드된 컨텍스트만 셉니다. 서버 페이징이 적용된 상태라면 실제 총합과 다를 수 있으므로, 정확한 집계가 필요하다면 별도 $apply=groupby((SalesOrg),aggregate(GrossAmount with sum as Total)) 쿼리를 사용해 보조 모델로 헤더를 구성해야 합니다.
이어서 확장해 볼 만한 주제
이 글에서 다룬 패턴을 익혔다면 다음 영역으로 확장해 보세요. 첫째, sap.m.Table의 그룹핑은 List와 유사하지만 컬럼 정렬과 함께 다뤄야 하므로 별도의 노하우가 필요합니다. 둘째, Fiori Elements의 List Report에서 presentationVariant 어노테이션으로 그룹핑을 선언적으로 정의하는 방법을 살펴보면 메타데이터 주도 개발의 장점을 체감할 수 있습니다. 셋째, sap.ui.comp.smarttable.SmartTable은 P13nDialog와 결합해 사용자가 직접 그룹 기준을 선택할 수 있게 해주므로, 본 글의 동적 바인딩 패턴과 함께 적용하면 강력합니다.
더 깊이 파고들 수 있는 링크 모음
댓글 0
아직 댓글이 없습니다.
💬 댓글 작성, 좋아요, 북마크는 UI5 모드에서 사용할 수 있습니다.
UI5 모드에서 사용하기