UI5

모델 공유 vs 분리 — UI5 스코프 설계 차이 #shorts #SAP #UI5

▶ YouTube에서 보기

한눈에 보는 이 글의 목적

SAPUI5/OpenUI5 애플리케이션을 만들다 보면 같은 데이터 모델인데 어떤 화면에서는 값이 공유되고, 어떤 화면에서는 따로 노는 현상을 마주합니다. 이는 모델을 어디에 setModel() 했느냐에 따른 스코프(scope) 차이 때문입니다. 이 글은 글로벌·뷰 로컬·컴포넌트 단위 모델 배치의 차이를 코드 단위로 비교하고, 실무 상태 오염 사고를 예방하는 설계 기준을 정리합니다.

  • sap.ui.getCore().setModel과 Component-level setModel의 동작 차이 비교
  • onInit에서 View-local 모델을 붙일 때 라이프사이클 주의점
  • 재사용 가능한 Reuse Component 내부 데이터 격리 패턴
  • 바인딩 경로(>) prefix가 의미하는 스코프 해석 방식

읽기 전 알아두면 좋은 배경

이 글은 SAPUI5 Manifest(`manifest.json`) 기반 앱 구조, JSONModel/ODataModel 기본 사용법, Component-based App 부트스트랩 흐름을 어느 정도 다뤄본 분을 대상으로 합니다. MVC 컨트롤러의 onInit 호출 시점, byId·getView() API, 그리고 Router 기반 화면 전환을 한 번이라도 다뤄본 경험이 있다면 본문 예제 흐름을 자연스럽게 따라갈 수 있습니다.

실행 환경과 준비물

본 예제는 다음 환경 기준으로 작성했습니다. 버전이 다르면 매니페스트 키와 일부 API 시그니처가 달라질 수 있으니 확인 후 적용하시는 것을 권장합니다.

  • SAPUI5 1.120 LTS (OpenUI5 1.120도 동일하게 동작)
  • Node.js 18 LTS + @ui5/cli 3.x (로컬 실행용)
  • BAS(Business Application Studio) 또는 VS Code + Fiori Tools 확장
  • BTP Cloud Foundry 또는 SAP Build Work Zone 배포 환경 (선택)
  • JSONModel 중심 예제 (ODataV4Model에서도 스코프 원리는 동일)

로컬에서 가볍게 돌려보고 싶다면 ui5 init으로 빈 앱을 만든 뒤 ui5 serve로 띄우면 됩니다. 모든 스니펫은 webapp 폴더 구조를 가정합니다.

스코프를 이해하는 정신 모델

UI5의 모델은 어디에 "꽂아두느냐"에 따라 자식 컨트롤이 상속받는 범위가 결정됩니다. 컨트롤 트리는 위에서 아래로 흐르는 강물과 비슷합니다. 강 상류(Core)에 모델을 풀면 모든 지류(View, Component)가 그 물을 마시고, 특정 호수(View)에만 풀면 그 호수에서만 접근 가능합니다.

  • Core(글로벌) 스코프: sap.ui.getCore().setModel(model, "shared")로 등록. 앱 전역 어디서든 {shared>/...} 바인딩 가능. 단, 누가 언제 바꿨는지 추적이 어렵고, 멀티 컴포넌트가 같은 키를 덮어쓰면 사이드이펙트가 생깁니다.
  • Component 스코프: this.setModel(model, "appCtx")를 Component.js 내부에서 호출. 해당 컴포넌트가 루트인 모든 뷰에서 접근 가능. 재사용 컴포넌트를 여러 번 인스턴스화해도 인스턴스별로 분리됩니다.
  • View 로컬 스코프: this.getView().setModel(model, "panel")를 컨트롤러 onInit에서 호출. 해당 뷰와 자식 fragment에서만 접근 가능. 다른 뷰는 같은 키를 써도 충돌하지 않습니다.

바인딩 해석 순서는 자식 → 부모 방향입니다. 컨트롤이 {panel>/title}을 만나면 자기 자신부터 시작해 부모 컨트롤, View, Component, Core까지 위로 올라가며 같은 이름의 모델을 탐색합니다. 즉 같은 키를 여러 레벨에 두면 가장 가까운 곳이 이깁니다. 이 규칙을 활용해 의도적으로 "지역 우선" 오버라이드를 만들 수도 있고, 반대로 누가 어디서 덮어썼는지 파악이 안 되어 디버깅 지옥에 빠질 수도 있습니다.

실무 권장 패턴은 다음과 같습니다. 사용자 프로필·테마·i18n처럼 진짜 전역적인 것은 Component 스코프, 화면 한 장에서만 쓰는 토글/검색 조건은 View 로컬, 라이브러리화된 위젯은 자체 Component 스코프. Core 모델은 가급적 피하고 꼭 필요할 때만 명시적으로 문서화하는 것을 권장합니다.

1단계 예제 — 글로벌(Core) 모델의 함정 재현

먼저 Core에 모델을 꽂아 전역 공유했을 때 어떤 문제가 생기는지 의도적으로 재현해 봅니다. 두 개의 화면(주문 목록, 주문 상세)이 같은 filterState를 공유한다고 가정합니다.

// webapp/Component.js (1단계: 의도적으로 Core 사용)
sap.ui.define([
  "sap/ui/core/UIComponent",
  "sap/ui/model/json/JSONModel"
], function (UIComponent, JSONModel) {
  "use strict";
  return UIComponent.extend("kr.acme.order.Component", {
    metadata: { manifest: "json" },
    init: function () {
      UIComponent.prototype.init.apply(this, arguments);
      var oGlobal = new JSONModel({ filterState: { keyword: "", onlyOpen: false } });
      // 안티패턴: Core에 직접 등록
      sap.ui.getCore().setModel(oGlobal, "shared");
      this.getRouter().initialize();
    }
  });
});
// webapp/controller/OrderList.controller.js
sap.ui.define(["sap/ui/core/mvc/Controller"], function (Controller) {
  "use strict";
  return Controller.extend("kr.acme.order.controller.OrderList", {
    onSearch: function (oEvt) {
      var sQuery = oEvt.getParameter("query");
      sap.ui.getCore().getModel("shared").setProperty("/filterState/keyword", sQuery);
    }
  });
});

증상: 사용자가 OrderList에서 키워드를 입력한 뒤 OrderDetail로 들어가면, Detail 화면 측 컨트롤이 동일한 {shared>/filterState/keyword}를 바라보고 있을 경우 검색창에 키워드가 그대로 남거나, 반대로 Detail에서 지운 값이 Master 목록에까지 반영됩니다. Core 모델은 라우터 destroy 이벤트와도 분리되어 있어 화면을 떠나도 살아 있습니다.

2단계 예제 — View 로컬 모델로 부작용 차단 + 에러/로깅

같은 화면 두 장을 이번에는 각자 자기만의 모델을 들고 가도록 분리합니다. 컨트롤러 onInit 시점에 View에 직접 모델을 붙이는 패턴입니다.

// webapp/controller/OrderList.controller.js (2단계)
sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "sap/ui/model/json/JSONModel",
  "sap/base/Log"
], function (Controller, JSONModel, Log) {
  "use strict";
  return Controller.extend("kr.acme.order.controller.OrderList", {
    onInit: function () {
      var oViewModel = new JSONModel({
        keyword: "",
        onlyOpen: false,
        busy: false,
        loadedAt: null
      });
      // 핵심: View 로컬에만 등록 → 다른 화면과 완전히 분리
      this.getView().setModel(oViewModel, "panel");
      Log.info("OrderList panel model attached", null, "kr.acme.order");
    },
    onSearch: function (oEvt) {
      var oModel = this.getView().getModel("panel");
      try {
        var sQuery = oEvt.getParameter("query") || "";
        if (sQuery.length > 80) {
          throw new Error("Keyword too long: " + sQuery.length);
        }
        oModel.setProperty("/keyword", sQuery.trim());
        oModel.setProperty("/loadedAt", new Date().toISOString());
      } catch (e) {
        Log.error("Search failed", e.message, "kr.acme.order");
        sap.m.MessageToast.show("검색 조건을 다시 확인해 주세요.");
      }
    },
    onExit: function () {
      // View가 파괴될 때 모델도 함께 destroy → 메모리 누수 방지
      var oModel = this.getView().getModel("panel");
      if (oModel) { oModel.destroy(); }
    }
  });
});

XML 뷰 측 바인딩은 그대로 {panel>/keyword}로 유지하면 됩니다. OrderDetail 컨트롤러에서 동일하게 panel이라는 이름을 쓰더라도, 두 뷰는 서로 다른 인스턴스이기 때문에 데이터가 섞이지 않습니다. sap/base/Log를 통한 구조적 로깅, onExit에서의 명시적 destroy() 호출이 운영 환경에서 누수와 좀비 모델을 막아주는 안전장치입니다.

3단계 예제 — Reuse Component로 데이터 영역 격리

화면 일부(예: 첨부파일 패널)를 여러 앱에서 재사용한다고 가정합니다. 이 위젯이 자체 모델을 들고 다녀야 호스트 앱의 전역 키와 충돌하지 않습니다.

// webapp/reuse/attach/Component.js
sap.ui.define([
  "sap/ui/core/UIComponent",
  "sap/ui/model/json/JSONModel"
], function (UIComponent, JSONModel) {
  "use strict";
  return UIComponent.extend("kr.acme.reuse.attach.Component", {
    metadata: {
      manifest: "json",
      properties: {
        objectKey: { type: "string", defaultValue: "" }
      }
    },
    init: function () {
      UIComponent.prototype.init.apply(this, arguments);
      // 컴포넌트 인스턴스별로 독립된 모델 (외부에서 절대 못 봄)
      var oCtx = new JSONModel({
        files: [],
        uploadBusy: false,
        quotaMb: 50,
        ownerKey: this.getProperty("objectKey")
      });
      this.setModel(oCtx, "attach");
    },
    refreshFiles: async function () {
      var oModel = this.getModel("attach");
      oModel.setProperty("/uploadBusy", true);
      try {
        var sKey = oModel.getProperty("/ownerKey");
        // 보안: 서버 호출 시 키를 URL 인코딩, CSRF 토큰은 ODataModel이 자동 처리
        var oRes = await fetch("/api/files?owner=" + encodeURIComponent(sKey), {
          headers: { "Accept": "application/json" }
        });
        if (!oRes.ok) { throw new Error("HTTP " + oRes.status); }
        oModel.setProperty("/files", await oRes.json());
      } finally {
        oModel.setProperty("/uploadBusy", false);
      }
    }
  });
});

호스트 앱은 ComponentContainer로 이 위젯을 끼워 넣기만 하면, 같은 페이지에 여러 인스턴스를 두어도 각자 별도의 attach 모델을 갖습니다.

<mvc:View xmlns:mvc="sap.ui.core.mvc" xmlns:core="sap.ui.core" xmlns="sap.m">
  <Panel headerText="문서함 A">
    <core:ComponentContainer name="kr.acme.reuse.attach"
        settings='{"objectKey":"PO-1001"}' async="true" />
  </Panel>
  <Panel headerText="문서함 B">
    <core:ComponentContainer name="kr.acme.reuse.attach"
        settings='{"objectKey":"PO-1002"}' async="true" />
  </Panel>
</mvc:View>

QUnit 테스트도 컴포넌트 격리 덕분에 단순해집니다. 모델을 oComponent.getModel("attach")로 꺼내 직접 단언하면 되며, 다른 컴포넌트 상태에 영향받지 않습니다.

QUnit.test("attach component keeps its own model", function (assert) {
  var oCompA = sap.ui.component({ name: "kr.acme.reuse.attach",
    componentData: { objectKey: "PO-1" } });
  var oCompB = sap.ui.component({ name: "kr.acme.reuse.attach",
    componentData: { objectKey: "PO-2" } });
  assert.notStrictEqual(oCompA.getModel("attach"), oCompB.getModel("attach"),
    "두 인스턴스는 서로 다른 모델 인스턴스를 가져야 합니다");
});

현장에서 자주 밟는 지뢰와 해결법

스코프 설계 실수는 보통 "왜 값이 안 바뀌지" 혹은 "왜 값이 멋대로 바뀌지"로 나타납니다. 빈도 높은 3가지를 정리합니다.

  • Q1. Fragment에서 모델이 안 잡혀요. Fragment.load()로 만든 다이얼로그가 컨트롤 트리에 붙기 전에 바인딩을 평가하려 합니다. 해결: this.getView().addDependent(oDialog)를 호출해 부모 View에 연결하세요. 그러면 View 로컬 모델까지 자연스럽게 상속됩니다.
  • Q2. 라우트 이동 후에도 이전 화면 값이 남아 있어요. 모델을 Core나 Component에 등록해 두면 라이프사이클이 길어서 발생합니다. 해당 데이터가 한 화면 전용이라면 View 로컬로 옮기고, onExit에서 destroy()를 호출하세요. Component 스코프라도 라우트 패턴 매칭마다 setProperty("/", {...})로 초기화하는 패턴이 안전합니다.
  • Q3. 같은 이름의 모델을 여러 레벨에 두면 어떻게 되나요? 자식이 우선합니다. View에 "panel"이 있고 Component에도 "panel"이 있다면 View 안의 컨트롤은 View 것만 봅니다. 의도된 오버라이드라면 좋지만, 무심코 같은 키를 쓰면 디버깅이 어렵습니다. 컨벤션으로 Component 스코프 모델은 app*, View 스코프는 view* 같이 접두어를 두면 사고가 줄어듭니다.
  • Q4. ODataV4Model을 View 로컬로 둬도 되나요? 기술적으로는 가능하지만 권장하지 않습니다. ODataV4는 그룹·캐시·배치 처리에 비용이 들기 때문에 Component 스코프에 한 번만 두고 바인딩 컨텍스트로 분기하는 편이 일반적으로 더 효율적입니다.

이 글에서 한 걸음 더 나아가려면

스코프를 이해했다면 다음 주제로 확장해 보세요. 첫째, ResourceModel(i18n)의 Component 스코프 활용 — 다국어 번들도 동일한 스코프 규칙을 따릅니다. 둘째, BindingContextrelative binding으로 같은 모델에서 서로 다른 화면 영역에 다른 컨텍스트를 매핑하는 기법. 셋째, Flexible Programming Model 기반 Object Page에서 사이드 콘텐츠가 자체 View 모델을 갖도록 만드는 패턴. 마지막으로 SAP Build Work Zone에 여러 앱을 합쳐 배포할 때 sap.ui.getCore().setModel이 어떤 사이드이펙트를 부르는지 직접 실험해 보면 본문 내용이 체감됩니다.

더 깊이 파고들 수 있는 외부 자료

댓글 0

아직 댓글이 없습니다.