UI5

JSON Model 30초 만에 바인딩 — UI5 로컬 데이터 제어 #shorts #SAP #UI5

▶ YouTube에서 보기

개요 및 학습 포인트

SAPUI5 애플리케이션을 만들다 보면 모든 데이터를 OData 서비스에 의존하기보다 화면 내부에서만 잠깐 쓰는 상태값(편집 모드 플래그, 필터 조건, 장바구니 임시 데이터 등)을 관리해야 할 때가 많습니다. 이때 sap.ui.model.json.JSONModel은 서버 왕복 없이 클라이언트 메모리에서 JSON 구조를 그대로 다루며 양방향 바인딩까지 제공하므로 화면 반응성을 크게 끌어올려 줍니다. 이 글에서는 JSONModel의 생성·바인딩·갱신 흐름을 단계별로 살펴보고, 실무에서 자주 등장하는 SalesOrder 편집 폼과 필터·장바구니 시나리오로 마무리합니다.

  • JSONModel 인스턴스 생성과 초기 데이터 주입 패턴 익히기
  • setProperty / getProperty로 특정 경로 값 제어
  • 속성 바인딩, element 바인딩, aggregation 바인딩 구분
  • Default Model과 Named Model의 차이 이해
  • OData Model 대비 JSONModel을 선택하는 기준 정리

먼저 알아둘 개념

이 글을 따라가려면 SAPUI5 컨트롤(예: sap.m.Input, sap.m.Table)을 XML View로 작성해 본 경험과 컨트롤러 라이프사이클(onInit)에 대한 기본 이해가 필요합니다. 또한 바인딩 문법을 한 번이라도 사용해 봤다면 진입이 훨씬 수월합니다. MVC 패턴에서 모델(M)이 뷰(V)에 데이터를 공급하는 역할이라는 점만 기억해도 충분합니다.

환경 및 준비물

아래 예제는 SAPUI5 1.120 LTS 기준으로 작성되었고, 1.71 LTS에서도 동일하게 동작합니다. BTP의 SAP Build Work Zone, Fiori Launchpad, 또는 로컬 ui5-cli 환경 어디서든 재현할 수 있습니다.

  • 런타임: SAPUI5 1.120 (Evergreen) 또는 SAP Fiori elements 호환 1.108+
  • 로컬 개발: Node.js 18+, ui5/cli 3.x, VS Code + SAP Fiori Tools 확장
  • BAS Dev Space: SAP Fiori 템플릿 권장
  • 브라우저: Chromium 계열 최신, DevTools의 Network/Console 사용 권장

JSONModel 동작 원리 살펴보기

JSONModel은 이름 그대로 자바스크립트 객체(JSON)를 통째로 메모리에 보관하고, UI5 컨트롤이 이 객체의 특정 경로를 구독(observe)할 수 있게 해주는 클라이언트 사이드 모델입니다. 비유하자면 화이트보드와 같아서, 누군가 보드의 한 칸을 새로 적으면(setProperty) 그 칸을 바라보고 있던 모든 컨트롤이 동시에 자기 표시를 갱신합니다. 서버를 거치지 않으니 지연이 없고, 양방향 바인딩(oneWay/twoWay) 모두 지원합니다.

OData Model이 백엔드 엔티티 메타데이터를 기반으로 변경 추적·배치 전송을 책임진다면, JSONModel은 그러한 백엔드 동기화 메커니즘이 없는 대신 가볍고 자유로운 구조를 제공합니다. 따라서 다음과 같은 상황에 적합합니다.

  • 화면에서만 의미를 갖는 UI 상태(편집모드, 토글, 탭 인덱스)
  • 여러 컨트롤이 공유해야 하는 임시 입력값(필터 조건, 검색어)
  • 외부 REST API에서 받은 응답을 그대로 표시할 때
  • 장바구니처럼 서버 저장 전 임시로 누적되는 컬렉션

Named Model은 setModel(oModel, "ui")처럼 두 번째 인자로 이름을 부여한 모델로, 바인딩 시 ui>/editMode처럼 이름 접두어를 붙여 접근합니다. Default Model(이름 없음)이 비즈니스 데이터(예: OData) 용도라면, Named Model은 UI 상태 전용으로 분리해 두는 패턴이 일반적입니다.

실전 예제 1단계 — 기본 바인딩 만들기

가장 단순한 사례로 고객 정보를 JSONModel에 담아 Input 컨트롤에 양방향 바인딩하는 예제를 살펴봅니다. 컨트롤러의 onInit에서 모델을 만들고 뷰에 연결합니다.

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

  return Controller.extend("acme.demo.controller.Customer", {
    onInit: function () {
      var oCustomer = new JSONModel({
        customerId: "C-1024",
        displayName: "민트베이커리",
        contact: {
          email: "owner@mintbakery.kr",
          phone: "02-555-0199"
        },
        vipFlag: true
      });
      this.getView().setModel(oCustomer);
    }
  });
});
<!-- webapp/view/Customer.view.xml -->
<mvc:View
    controllerName="acme.demo.controller.Customer"
    xmlns="sap.m"
    xmlns:mvc="sap.ui.core.mvc">
  <VBox class="sapUiMediumMargin">
    <Label text="고객 ID"/>
    <Text text="{/customerId}"/>
    <Label text="표시 이름"/>
    <Input value="{/displayName}"/>
    <Label text="이메일"/>
    <Input value="{/contact/email}"/>
    <CheckBox selected="{/vipFlag}" text="VIP 회원"/>
  </VBox>
</mvc:View>

여기서 주목할 점은 슬래시(/)로 시작하는 절대 경로 표기입니다. JSON 트리의 루트부터 차례대로 키를 따라간다고 생각하면 됩니다. Input에 입력한 값은 즉시 모델에도 반영되므로 별도의 이벤트 핸들러 없이도 양방향 동기화가 이뤄집니다.

실전 예제 2단계 — SalesOrder 편집모드와 필터 패널

실제 업무 화면에서는 "조회 → 편집 → 저장" 흐름을 자주 구현합니다. UI 상태(편집 여부, 로딩 플래그)는 별도의 Named Model에 보관하고, 비즈니스 데이터는 Default Model에 두는 식으로 책임을 나눕니다.

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

  return Controller.extend("acme.demo.controller.SalesOrder", {
    onInit: function () {
      var oOrder = new JSONModel({
        orderNo: "SO-2026-0511",
        soldTo: "민트베이커리",
        currency: "KRW",
        netAmount: 1820000,
        items: [
          { pos: "10", material: "M-001", qty: 12, unit: "EA" },
          { pos: "20", material: "M-014", qty:  4, unit: "EA" }
        ]
      });
      this.getView().setModel(oOrder);

      var oUiState = new JSONModel({
        editMode: false,
        busy: false,
        filter: { minQty: 0, materialKeyword: "" }
      });
      this.getView().setModel(oUiState, "ui");
    },

    onToggleEdit: function () {
      var oUi = this.getView().getModel("ui");
      var bEdit = oUi.getProperty("/editMode");
      oUi.setProperty("/editMode", !bEdit);
      MessageToast.show(bEdit ? "조회 모드" : "편집 모드");
    }
  });
});
<Panel headerText="주문 {/orderNo}">
  <Input value="{/soldTo}" editable="{ui>/editMode}"/>
  <Button text="편집/조회" press=".onToggleEdit"/>

  <Table items="{/items}" busy="{ui>/busy}">
    <columns>
      <Column><Text text="포지션"/></Column>
      <Column><Text text="자재"/></Column>
      <Column><Text text="수량"/></Column>
    </columns>
    <items>
      <ColumnListItem>
        <Text text="{pos}"/>
        <Input value="{material}" editable="{ui>/editMode}"/>
        <Input value="{qty}" editable="{ui>/editMode}"/>
      </ColumnListItem>
    </items>
  </Table>
</Panel>

Default Model의 items로 테이블 aggregation을 바인딩하면 배열 요소가 자동으로 행이 됩니다. 행 내부에서는 material처럼 상대 경로를 쓰는데, 이는 각 행이 자신의 컨텍스트(예: /items/0)에 묶여 있기 때문입니다.

실전 예제 3단계 — 장바구니, 외부 API 응답 처리

외부 REST API에서 추천 상품을 받아 장바구니에 누적하고, 로딩 플래그와 에러 메시지까지 한 모델에서 처리하는 시나리오입니다.

// webapp/controller/Cart.controller.js
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("acme.demo.controller.Cart", {
    onInit: function () {
      var oCart = new JSONModel({
        loading: false,
        errorText: "",
        recommended: [],
        cart: [],
        totalQty: 0
      });
      this.getView().setModel(oCart, "shop");
    },

    onLoadRecommendation: async function () {
      var oModel = this.getView().getModel("shop");
      oModel.setProperty("/loading", true);
      try {
        var oResp = await fetch("/api/recommend?limit=20");
        if (!oResp.ok) throw new Error("HTTP " + oResp.status);
        var aList = await oResp.json();
        oModel.setData(Object.assign(oModel.getData(), { recommended: aList }));
      } catch (e) {
        oModel.setProperty("/errorText", e.message);
        MessageBox.error("추천 목록을 불러오지 못했습니다.");
      } finally {
        oModel.setProperty("/loading", false);
      }
    },

    onAddToCart: function (oEvent) {
      var oCtx = oEvent.getSource().getBindingContext("shop");
      var oItem = oCtx.getObject();
      var oModel = this.getView().getModel("shop");
      var aCart = oModel.getProperty("/cart").slice();
      aCart.push({ sku: oItem.sku, name: oItem.name, qty: 1 });
      oModel.setProperty("/cart", aCart);
      oModel.setProperty("/totalQty",
        aCart.reduce(function (n, x) { return n + x.qty; }, 0));
    }
  });
});

위 코드에서 주목할 점 세 가지입니다. 첫째, loading 플래그를 모델에 두면 컨트롤의 busy 바인딩으로 자동 연동됩니다. 둘째, 배열 수정 시 slice()로 복사한 뒤 setProperty로 다시 할당해 모델 변경을 명시적으로 알립니다. 셋째, 부득이하게 객체를 직접 수정했다면 oModel.refresh(true)로 강제 갱신을 트리거할 수 있습니다.

자주 마주치는 함정과 FAQ

  • Q1. 값이 바인딩되지 않습니다. 절대 경로는 슬래시로 시작해야 합니다. 루트 키라면 /customerId가 맞습니다. 상대 경로(customerId)는 부모 컨트롤이 이미 bindElement로 컨텍스트를 갖고 있을 때만 동작합니다.
  • Q2. 모델 데이터를 바꿨는데 화면 갱신이 안 됩니다. 자바스크립트에서 배열에 직접 push하면 모델은 변경을 감지하지 못합니다. setProperty로 새 배열을 할당하거나 oModel.refresh(true)를 호출하세요.
  • Q3. Named Model에서 경로가 동작하지 않습니다. Named Model은 모델이름>/path 형태로 접근해야 합니다. bindElement나 bindAggregation을 사용할 때도 모델명을 명시해야 합니다.
  • Q4. OData와 JSONModel을 동시에 쓰면 충돌하나요? 충돌하지 않습니다. 보통 Default Model에 OData, Named Model(ui, view)에 JSONModel을 두는 분리 패턴이 권장됩니다.
  • Q5. JSONModel 데이터를 그대로 백엔드에 보내도 되나요? getData()는 내부 참조를 반환하므로 직렬화 전에 JSON.parse(JSON.stringify(...))로 복제해 사이드이펙트를 차단하는 편이 안전합니다.

JSONModel vs OData Model — 선택 기준

기준JSONModelODataModel v4
데이터 출처클라이언트 메모리OData 서비스 백엔드
서버 동기화없음 (수동 fetch)자동 (CRUD, batch)
변경 추적없음자동 (pending changes)
적합한 용도UI 상태, 임시 데이터영속성 비즈니스 데이터

이어서 살펴보면 좋은 주제

JSONModel에 익숙해졌다면 다음 단계로 ResourceModel(i18n), ODataModel v4(서버 동기화), 그리고 Fiori Elements의 extensionAPI를 통해 표준 화면에 JSONModel을 주입하는 패턴을 살펴보길 권장합니다. 또한 컴포넌트 단위에서 manifest.json의 sap.ui5.models에 JSONModel을 선언하면 라우팅 간에도 상태가 유지되어 SPA 흐름에 잘 맞습니다.

댓글 0

아직 댓글이 없습니다.