UI5

UI5 배열 바인딩 이렇게 못 하셨다면? #shorts #SAP #UI5

▶ YouTube에서 보기

이 글에서 다루는 범위와 도달 목표

SAPUI5 애플리케이션에서 배열(컬렉션) 데이터를 화면에 표시할 때 개발자가 가장 자주 마주치는 의사결정은 "어떤 바인딩 방식을 선택할 것인가"입니다. 같은 Product 배열을 두고도 인덱스로 직접 꺼낼 수도 있고, 컨테이너 전체에 컨텍스트를 묶어두고 상대 경로로 접근할 수도 있으며, List나 Table에 자동 반복 렌더링을 시킬 수도 있습니다. 이 글에서는 동일한 상품 카탈로그 데이터를 세 가지 패턴으로 바인딩하면서 각 방식의 내부 동작과 장단점을 비교합니다.

  • JSONModel에 배열을 적재하고 절대 경로 인덱스로 단일 항목에 접근
  • bindElement로 상위 컨테이너에 컨텍스트를 고정하고 상대 경로로 자식 컨트롤 묶기
  • List/Table의 items aggregation에 template과 함께 반복 바인딩
  • ManagedObject 바인딩 컨텍스트와 BindingMode가 실제로 어떻게 전파되는지 이해

이 글을 따라오기 전에 익숙해두면 좋은 것

SAPUI5의 MVC 패턴, XML View 기본 문법, sap.ui.model.json.JSONModel 사용 경험이 어느 정도 있다고 가정합니다. this.getView().setModel()의 의미와 컨트롤 속성에 "{경로}"를 적었을 때 무슨 일이 일어나는지 한 번이라도 디버깅해본 적이 있으면 충분합니다. ODataModel V2/V4 차이는 마지막 비교 단락에서 잠깐 짚지만 핵심 흐름은 JSONModel만으로 진행합니다.

실습에 사용한 버전과 준비물

이 글에서 검증한 환경은 SAPUI5 1.120 LTS(Long-term Maintenance) 기준이며, BAS(Business Application Studio) 또는 VS Code + UI5 Tooling 3.x로 동일하게 동작합니다. UI5 1.71 이상에서는 동일한 API 시그니처가 유지되므로 큰 차이는 없습니다.

  • Node.js 18 LTS 이상
  • @ui5/cli 3.x (npm install --global @ui5/cli)
  • 샘플 앱 스캐폴드: npm init @sapui5/easy-ui5 project 또는 BAS의 Karma + List Report 템플릿
  • 브라우저: Chromium 계열 권장(UI5 Inspector 확장 사용 시 디버깅 편의)
  • 예제용 더미 JSON 파일: webapp/localService/products.json

이 글의 코드 조각은 모두 sap.msap.ui.layout 라이브러리를 manifest.jsonsap.ui5.dependencies.libs에 포함하고 있다고 가정합니다. 별도의 백엔드 없이 로컬 JSON으로 동작하므로 인증/CSRF 토큰 처리는 생략합니다.

바인딩을 둘러싼 핵심 개념: 경로, 컨텍스트, 에그리게이션

UI5의 데이터 바인딩을 한 줄로 비유하자면 "컨트롤이라는 우편함이 모델이라는 거대한 주소록에서 자기 우편물을 가져오는 구조"입니다. 이때 주소록을 어떻게 가리키느냐에 따라 세 가지 표기법이 나뉩니다.

  • 절대 경로(Absolute path): /Products/0/name처럼 슬래시(/)로 시작하는 경로입니다. 모델 루트부터 끝까지 적기 때문에 어떤 컨트롤에서 써도 동일한 값을 가리킵니다. 인덱스를 직접 박아두는 형태가 여기 해당합니다.
  • 상대 경로(Relative path) + 바인딩 컨텍스트: name처럼 슬래시 없이 시작하면 자기 자신 또는 부모 컨트롤에 설정된 바인딩 컨텍스트(Binding Context)를 기준으로 해석됩니다. bindElement("/Products/2")를 호출하면 해당 컨테이너 아래의 모든 자식 컨트롤이 /Products/2를 기준점으로 삼습니다.
  • 에그리게이션 바인딩(Aggregation binding): List, Table, VBox 같은 컨테이너의 items 또는 content 같은 0..n 카디널리티 속성에 배열을 묶고, 템플릿(template)을 한 번 정의하면 UI5가 배열 길이만큼 자동으로 복제해 렌더링합니다. 각 자식은 자기 자신만의 바인딩 컨텍스트(/Products/i)를 자동으로 부여받습니다.

도식으로 표현하면 다음과 같습니다.

JSONModel
└── /Products  (배열)
    ├── 0: { id: "P-001", name: "에스프레소 머신", price: 489000 }
    ├── 1: { id: "P-002", name: "캡슐 디스펜서", price: 32000 }
    └── 2: { id: "P-003", name: "원두 그라인더", price: 215000 }

[패턴 A 인덱스 접근]  Text text="{/Products/0/name}"        → "에스프레소 머신"
[패턴 B bindElement] Panel.bindElement("/Products/1")
                     └─ Text text="{name}"                 → "캡슐 디스펜서"
[패턴 C items 바인딩] List items="{/Products}"
                     └─ template: StandardListItem title="{name}"
                        → 3개 아이템 자동 생성

여기서 가장 자주 오해하는 부분은 "왜 어떤 곳은 슬래시를 붙이고 어떤 곳은 안 붙이는가" 입니다. 핵심은 "현재 컨트롤이 어떤 컨텍스트에 매여 있는가" 한 가지뿐입니다. 컨텍스트가 없으면 절대 경로가 필요하고, 컨텍스트가 있으면 그 컨텍스트를 베이스로 한 상대 경로를 권장합니다. 일반적으로 상대 경로가 재사용성과 유지보수 측면에서 더 깔끔합니다.

1단계: 인덱스 절대 경로로 단일 항목 표시

먼저 가장 단순한 패턴입니다. 컨트롤러에서 JSON 배열을 모델로 만들어 뷰에 주입하고, 뷰에서는 /Products/{인덱스}/{필드}를 그대로 적어 단일 상품을 표시합니다.

// webapp/controller/Main.controller.js
sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "sap/ui/model/json/JSONModel"
], function (Controller, JSONModel) {
  "use strict";

  return Controller.extend("kr.btpstacks.demo.controller.Main", {
    onInit: function () {
      var oCatalogModel = new JSONModel({
        Products: [
          { id: "P-001", name: "에스프레소 머신", price: 489000, stock: 12 },
          { id: "P-002", name: "캡슐 디스펜서",   price:  32000, stock:  8 },
          { id: "P-003", name: "원두 그라인더",   price: 215000, stock: 23 }
        ]
      });
      this.getView().setModel(oCatalogModel, "catalog");
    }
  });
});
<!-- webapp/view/Main.view.xml (발췌) -->
<VBox class="sapUiMediumMargin">
  <Title text="베스트셀러 한 줄 요약" level="H3"/>
  <Text text="{catalog>/Products/0/name} - {catalog>/Products/0/price} 원" />
</VBox>

장점은 "딱 한 곳만 보여주면 될 때" 코드가 가장 짧다는 것입니다. 반대로 단점은 명확합니다. 인덱스가 하드코딩이라 배열 정렬이 바뀌면 즉시 깨지고, 동일한 인덱스를 여러 컨트롤에 반복 작성하면 오타 위험이 큽니다. 따라서 이 패턴은 "랜딩 페이지 히어로 영역에 항상 0번을 노출" 같은 고정 슬롯에만 권장합니다.

2단계: bindElement로 디테일 패널 묶기와 에러 처리

두 번째 패턴은 디테일 화면이나 폼처럼 "여러 필드가 같은 행을 바라봐야 하는 경우"에 적합합니다. 상위 컨테이너에 한 번 bindElement를 호출하면 그 아래 모든 컨트롤이 상대 경로로 동작합니다.

// 사용자가 마스터 리스트에서 행을 클릭했을 때 호출
onProductPress: function (oEvent) {
  var sPath = oEvent.getSource().getBindingContext("catalog").getPath();
  // sPath 예: "/Products/2"

  var oDetailPanel = this.byId("productDetailPanel");
  oDetailPanel.bindElement({
    path: sPath,
    model: "catalog",
    events: {
      change:        this._onContextChange.bind(this),
      dataRequested: function () { /* OData일 때 로딩 인디케이터 */ },
      dataReceived:  this._onDataReceived.bind(this)
    }
  });
},

_onContextChange: function (oEvent) {
  var oCtx = oEvent.getSource().getBoundContext();
  if (!oCtx) {
    sap.base.Log.warning("디테일 컨텍스트가 비어 있습니다. 경로를 확인하세요.");
    return;
  }
  sap.base.Log.info("디테일 바인딩 변경: " + oCtx.getPath());
},

_onDataReceived: function (oEvent) {
  var oData = oEvent.getParameter("data");
  if (!oData) {
    sap.m.MessageToast.show("선택한 상품 정보를 불러오지 못했습니다.");
  }
}
<Panel id="productDetailPanel" headerText="상품 상세">
  <f:SimpleForm xmlns:f="sap.ui.layout.form" editable="false">
    <Label text="상품 ID"/>  <Text text="{catalog>id}" />
    <Label text="상품명"/>   <Text text="{catalog>name}" />
    <Label text="단가"/>     <Text text="{
                                  path: 'catalog>price',
                                  formatter: '.formatter.toKrw'
                                }" />
    <Label text="재고"/>     <ObjectStatus text="{catalog>stock}"
                                state="{= ${catalog>stock} < 10 ? 'Warning' : 'Success' }"/>
  </f:SimpleForm>
</Panel>

여기서 주목할 점은 자식 컨트롤이 더 이상 /Products/2/name이 아니라 그냥 name이라고만 적는다는 것입니다. 다른 행을 클릭하면 Panel만 bindElement를 다시 호출하면 되고 자식들의 XML은 손대지 않아도 됩니다. 또한 change, dataReceived 이벤트를 등록해두면 컨텍스트가 비었거나 OData 응답이 실패했을 때 로깅과 사용자 알림을 일관되게 처리할 수 있습니다.

3단계: 에그리게이션 바인딩과 성능을 고려한 프로덕션 패턴

마지막 세 번째 패턴은 실무에서 가장 빈번한 List/Table 반복 렌더링입니다. 단순히 동작하게 하는 것을 넘어, 정렬·필터·그룹핑, growing(점진 로딩), 키 식별자 지정까지 함께 다룹니다.

<List id="productList"
      headerText="상품 카탈로그"
      growing="true"
      growingThreshold="20"
      growingScrollToLoad="true"
      items="{
        path: 'catalog>/Products',
        sorter: { path: 'price', descending: true },
        templateShareable: false,
        key: 'id'
      }">
  <StandardListItem
      title="{catalog>name}"
      description="{catalog>id}"
      info="{
        parts: [ 'catalog>price' ],
        formatter: '.formatter.toKrw'
      }"
      infoState="{= ${catalog>stock} < 10 ? 'Warning' : 'Success' }"
      type="Navigation"
      press=".onProductPress" />
</List>
// webapp/model/formatter.js
sap.ui.define([], function () {
  "use strict";
  return {
    toKrw: function (vValue) {
      if (vValue === null || vValue === undefined) { return ""; }
      return Number(vValue).toLocaleString("ko-KR") + " 원";
    }
  };
});

// 컨트롤러에서 동적 필터링 예시
onSearch: function (oEvent) {
  var sQuery = oEvent.getParameter("newValue") || "";
  var oBinding = this.byId("productList").getBinding("items");
  var aFilters = [];
  if (sQuery) {
    aFilters.push(new sap.ui.model.Filter({
      path: "name",
      operator: sap.ui.model.FilterOperator.Contains,
      value1: sQuery,
      caseSensitive: false
    }));
  }
  oBinding.filter(aFilters);
}

몇 가지 실무 포인트를 정리합니다.

  • templateShareable: 동일한 템플릿 인스턴스를 다른 바인딩에서 재사용할지를 결정합니다. 한 번만 쓰는 인라인 템플릿이면 false로 두는 편이 메모리 누수 경고를 피하기 좋습니다.
  • growing: 100건 넘는 배열을 한 번에 렌더링하면 DOM 트리가 비대해집니다. growingThreshold로 페이지 크기를 정하면 UI5가 IntersectionObserver 기반으로 점진 렌더링합니다.
  • sorter / filter: ClientListBinding이 메모리에서 직접 수행합니다. 데이터가 5,000건을 넘기기 시작하면 ODataListBinding으로 서버 측 정렬·필터로 옮기는 것을 권장합니다.
  • key: 항목의 안정적 식별자를 지정하면 데이터 갱신 시 UI5가 DOM diffing을 더 똑똑하게 수행합니다. 비즈니스 키(id) 사용을 권장합니다.
  • 보안: 사용자 입력을 그대로 HTML 컨트롤로 표시하면 XSS 위험이 생깁니다. 일반 텍스트는 TextStandardListItem처럼 자동 인코딩되는 컨트롤을 우선 선택하세요.

단위 테스트 측면에서는 OPA5 또는 QUnit으로 다음 시나리오를 검증하는 편이 안전합니다. (1) 모델 초기화 직후 List에 정확한 개수의 아이템이 렌더링되는지, (2) 필터 입력 후 items aggregation의 length가 기대치와 같은지, (3) 행 클릭 시 디테일 Panel의 getBindingContext().getPath()가 의도한 경로로 바뀌는지입니다.

자주 밟는 지뢰와 빠른 해결법

Q1. items 바인딩을 했는데 화면에 아무것도 안 보입니다.
가장 흔한 원인 세 가지를 순서대로 점검하세요. 첫째, 모델 이름을 빼먹은 경우입니다. setModel(m, "catalog")로 등록했다면 바인딩에서도 반드시 catalog>/Products처럼 접두어를 붙여야 합니다. 둘째, 경로가 객체 루트가 아니라 배열을 가리켜야 합니다. /Products는 배열이지만 //data는 객체일 수 있어 0개로 렌더링됩니다. 셋째, 템플릿에서 다른 라이브러리 컨트롤을 썼는데 manifest 의존성이 빠진 경우입니다. 브라우저 콘솔의 "Cannot load library" 경고를 먼저 확인하세요.

Q2. bindElement 후 디테일 폼이 이전 값을 그대로 유지합니다.
bindElement는 비동기일 수 있습니다(OData V2/V4 모두). change 이벤트를 구독해 컨텍스트가 실제로 도착했는지 확인하세요. JSONModel은 동기지만 모델 자체가 바뀌면 this.getView().getModel("catalog")가 null이 되어 컨텍스트가 비어버리는 경우도 있습니다. bindElement 호출 직후 oPanel.getBindingContext("catalog")가 truthy인지 디버거에서 확인하는 습관이 도움이 됩니다.

Q3. 인덱스로 접근했는데 정렬을 바꾸니 엉뚱한 행이 나옵니다.
당연한 결과입니다. /Products/0은 "현재 모델 데이터의 0번째"이지 "특정 상품 ID"가 아닙니다. 정렬·필터가 들어가는 화면에서는 절대 인덱스 접근을 쓰지 말고, 클릭 이벤트에서 oEvent.getSource().getBindingContext().getPath()로 그때그때 경로를 받아오는 방식을 권장합니다.

Q4. JSONModel과 ODataModel 사이에서 코드가 달라져야 하나요?
바인딩 구문 자체는 거의 동일합니다. 다만 ODataModel V4는 클라이언트 측 sorter/filter 대신 서버 측 시스템 쿼리 옵션($filter, $orderby, $expand)으로 동작하고, bindElement$expand 파라미터를 함께 받을 수 있습니다. 데이터 양이 많거나 권한 필터링이 필요한 화면은 일반적으로 ODataModel V4로 옮기는 것이 성능과 보안 모두에 유리합니다.

여기서 한 걸음 더 가보기

이 글의 세 패턴을 익혔다면 다음 주제로 자연스럽게 확장할 수 있습니다. (1) Expression Binding과 커스텀 Type을 활용한 양방향 폼 유효성 검사, (2) sap.ui.model.Filter와 FilterOperator로 구성하는 다중 조건 검색, (3) Smart Controls(SmartTable, SmartFilterBar)에서 어노테이션 기반으로 동일한 배열 시나리오를 선언적으로 풀어내는 방법, (4) ODataModel V4의 $$updateGroupId와 함께 쓰는 일괄 저장 패턴입니다. 각각 별도 글로 다룰 만한 깊이가 있으므로 본 시리즈의 후속 편에서 이어집니다.

더 깊이 파고들 수 있는 자료 모음

댓글 0

아직 댓글이 없습니다.