UI5

Flat vs Nested — UI5 Subroutes 중첩 라우팅 #shorts #SAP #UI5

▶ YouTube에서 보기

개요 및 달성 목표

SAPUI5에서 Subroutes는 하나의 URL 패턴이 여러 개의 View를 동시에 활성화시키는 중첩 라우팅 메커니즘입니다. 주문 목록과 상세 화면을 한 화면에 나란히 보여주는 Master-Detail 패턴, 또는 좌측 카테고리 트리와 우측 콘텐츠를 동시에 유지하는 Side Navigation 패턴을 구현할 때 핵심 도구가 됩니다. 일반적인 단일 target 라우팅으로는 화면 전환 시 이전 View가 사라지지만, 다중 target을 활용하면 부모 View는 그대로 유지된 채 자식 View만 교체할 수 있습니다.

  • manifest.json의 routes와 targets를 다중 배열로 설계하는 방법 습득
  • SplitApp 컨트롤의 masterPages와 detailPages aggregation 이해
  • navTo() 호출 시 파라미터 바인딩과 URL 패턴 매칭 원리 파악
  • routeMatchedHandler로 양쪽 View 상태 동기화 전략 수립

알아두면 좋은 배경 지식

SAPUI5의 라우팅 시스템(sap.ui.core.routing)에 대한 기본 이해와 manifest.json 구조에 익숙해야 합니다. 또한 sap.m.SplitApp, sap.m.NavContainer 같은 컨테이너 컨트롤의 aggregation 개념을 알고 있으면 좋습니다. JavaScript의 Promise 패턴과 SAPUI5의 MVC 구조, 그리고 EventBus를 활용한 컨트롤러 간 통신 방식에 대한 사전 경험이 있다면 더 빠르게 응용할 수 있습니다.

실습 환경 및 준비물

이번 코드는 SAPUI5 1.120.x LTS 버전을 기준으로 작성되었으며, OpenUI5 1.108 이상에서도 동일하게 동작합니다. 개발 도구는 SAP Business Application Studio 또는 VS Code + UI5 Tooling 3.x 조합을 권장합니다. 로컬 실행을 위해서는 Node.js 18 이상과 ui5-cli(@ui5/cli) 패키지가 필요하며, 백엔드 데이터는 SAP BTP의 Mock 서버 또는 northwind.svc 같은 공개 OData v2/v4 엔드포인트를 사용하면 됩니다.

  • SAPUI5 1.120.x (LTS, 2024-2026 지원)
  • Node.js 18 LTS, @ui5/cli 3.x
  • sap.m 라이브러리(SplitApp, List, Page 컨트롤 포함)
  • Chrome DevTools + UI5 Inspector 확장 (디버깅용)

중첩 라우팅의 동작 원리

일반 라우팅을 엘리베이터에 비유한다면, Subroutes는 백화점 매장 안의 진열장과 같습니다. 엘리베이터(단일 target)는 한 번에 한 층만 보여주고 다른 층으로 이동하면 이전 층은 사라지지만, 진열장(다중 target)은 매장 전체 레이아웃을 유지한 채 일부 진열대만 교체합니다. 즉, 부모 컨테이너인 SplitApp은 화면에 계속 떠 있고, 그 안의 masterPages와 detailPages aggregation에 들어갈 View만 라우터가 동적으로 주입합니다.

SAPUI5 라우터는 URL 해시(hash)가 변경될 때마다 등록된 모든 route의 pattern을 순서대로 검사합니다. 매칭되는 패턴을 찾으면 해당 route의 target 배열을 순회하면서 각 target의 viewName으로 View 인스턴스를 생성하거나 캐시에서 가져옵니다. 이후 controlId로 지정한 컨테이너를 찾고, controlAggregation에 명시된 aggregation에 View를 add 또는 insert 합니다.

핵심은 controlId와 controlAggregation의 조합입니다. 동일한 컨테이너(splitApp)를 두 target이 공유하지만, 서로 다른 aggregation(masterPages, detailPages)에 들어가기 때문에 충돌 없이 양쪽에 동시에 렌더링됩니다. URL 패턴이 orders/{id}일 때 master와 detail 두 target이 모두 활성화되므로, 사용자가 detail URL을 북마크로 직접 열어도 좌측 목록이 함께 표시되는 딥링킹(deep linking) 효과를 자연스럽게 얻을 수 있습니다.

navContainer 기반의 단일 화면 전환과 달리, SplitApp의 multi-aggregation 구조는 태블릿 가로 모드에서 진가를 발휘합니다. 화면 폭이 좁아지면 SplitApp이 자동으로 ShowHideMode로 전환되어 모바일 UX까지 자연스럽게 처리합니다.

실전 코드 3단계 구현

1단계 — 기본 구조: manifest.json과 SplitApp 연결

가장 먼저 manifest.json의 sap.ui5.routing 섹션에 config, routes, targets를 정의합니다. config의 controlId는 라우터가 View를 주입할 기본 컨테이너 ID이고, viewPath는 모든 View의 공통 경로 prefix입니다.

{
  "sap.ui5": {
    "routing": {
      "config": {
        "routerClass": "sap.m.routing.Router",
        "viewType": "XML",
        "viewPath": "com.btpstacks.orders.view",
        "controlId": "splitApp",
        "transition": "slide",
        "async": true
      },
      "routes": [
        { "name": "master", "pattern": "orders", "target": "master" },
        { "name": "detail", "pattern": "orders/{id}", "target": ["master", "detail"] }
      ],
      "targets": {
        "master": {
          "viewName": "Master",
          "controlId": "splitApp",
          "controlAggregation": "masterPages"
        },
        "detail": {
          "viewName": "Detail",
          "controlId": "splitApp",
          "controlAggregation": "detailPages"
        }
      }
    }
  }
}

App.view.xml에는 SplitApp 컨트롤을 배치합니다. id 속성이 manifest.json의 controlId와 정확히 일치해야 합니다.

<mvc:View
    controllerName="com.btpstacks.orders.controller.App"
    xmlns:mvc="sap.ui.core.mvc"
    xmlns="sap.m">
  <SplitApp id="splitApp"
            defaultTransitionNameDetail="show"
            defaultTransitionNameMaster="show"/>
</mvc:View>

Component.js의 init() 메서드에서 this.getRouter().initialize()를 호출하면 라우터가 활성화됩니다. 이때 URL이 #/orders/4711 이면 master와 detail 두 View가 동시에 생성되어 SplitApp의 좌우 영역에 각각 표시됩니다.

2단계 — 실무 시나리오: navTo와 routePatternMatched 핸들링

Master 컨트롤러에서는 List의 아이템 클릭 이벤트에서 navTo()를 호출합니다. 두 번째 인자로 전달하는 객체의 키는 manifest.json의 pattern에 정의된 placeholder({id})와 정확히 일치해야 URL에 올바르게 치환됩니다.

sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "sap/base/Log"
], function (Controller, Log) {
  "use strict";

  return Controller.extend("com.btpstacks.orders.controller.Master", {

    onInit: function () {
      var oRouter = this.getOwnerComponent().getRouter();
      oRouter.getRoute("master").attachPatternMatched(this._onMasterMatched, this);
    },

    _onMasterMatched: function () {
      // URL이 #/orders 일 때 호출 - 기본 detail 페이지 표시
      this.byId("orderList").removeSelections(true);
    },

    onItemPress: function (oEvent) {
      var oContext = oEvent.getSource().getBindingContext();
      if (!oContext) {
        Log.warning("Binding context not found", null, "Master");
        return;
      }
      var sId = oContext.getProperty("OrderId");
      this.getOwnerComponent().getRouter().navTo("detail", {
        id: sId
      }, false); // 세 번째 인자 false = 히스토리에 기록
    }
  });
});

Detail 컨트롤러에서는 routePatternMatched 이벤트로 URL 파라미터를 수신하고, 해당 ID에 맞는 데이터를 ODataModel에 바인딩합니다. 로깅과 에러 처리를 함께 넣어 사용자가 존재하지 않는 ID로 진입했을 때 적절히 대응합니다.

sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "sap/m/MessageBox",
  "sap/base/Log"
], function (Controller, MessageBox, Log) {
  "use strict";

  return Controller.extend("com.btpstacks.orders.controller.Detail", {

    onInit: function () {
      this.getOwnerComponent().getRouter()
          .getRoute("detail")
          .attachPatternMatched(this._onDetailMatched, this);
    },

    _onDetailMatched: function (oEvent) {
      var sId = oEvent.getParameter("arguments").id;
      var sPath = "/Orders('" + sId + "')";
      var oView = this.getView();

      oView.bindElement({
        path: sPath,
        events: {
          dataRequested: function () { oView.setBusy(true); },
          dataReceived: function () { oView.setBusy(false); },
          change: function (oEv) {
            if (!oEv.getSource().getBoundContext()) {
              Log.error("Order not found: " + sId, null, "Detail");
              MessageBox.error("주문을 찾을 수 없습니다: " + sId);
            }
          }
        }
      });
    }
  });
});

3단계 — 프로덕션: 메모리 관리, 권한 체크, QUnit 테스트

실 운영 환경에서는 라우트 진입 시 사용자 권한을 검증하고, View가 destroy되지 않도록 캐시 전략을 명시해야 합니다. 또한 라우팅 에러를 일관되게 처리하는 bypassed route를 등록합니다.

// Component.js 내부
init: function () {
  UIComponent.prototype.init.apply(this, arguments);

  var oRouter = this.getRouter();

  // 매칭되지 않는 모든 URL 처리
  oRouter.attachBypassed(function (oEvent) {
    var sHash = oEvent.getParameter("hash");
    Log.warning("Unmatched route: " + sHash, null, "Component");
    oRouter.navTo("notFound", { path: encodeURIComponent(sHash) }, true);
  });

  // 라우트 진입 직전 권한 체크
  oRouter.attachBeforeRouteMatched(function (oEvent) {
    var sName = oEvent.getParameter("name");
    if (sName === "detail" && !this._hasReadPermission()) {
      oEvent.preventDefault();
      oRouter.navTo("forbidden");
    }
  }, this);

  oRouter.initialize();
},

_hasReadPermission: function () {
  var oUserModel = this.getModel("user");
  return oUserModel && oUserModel.getProperty("/scopes/OrderRead") === true;
}

QUnit 기반 테스트 코드 예시입니다. opaTest에서 라우터 navigation을 직접 트리거하고 양쪽 View가 모두 렌더링되는지 확인합니다.

opaTest("Detail URL은 master와 detail을 동시에 렌더링한다", function (Given, When, Then) {
  Given.iStartMyAppOnAUrl("orders/10248");

  Then.waitFor({
    id: "splitApp",
    success: function (oSplitApp) {
      Opa5.assert.ok(oSplitApp.getMasterPages().length > 0, "Master 영역 활성");
      Opa5.assert.ok(oSplitApp.getDetailPages().length > 0, "Detail 영역 활성");
    }
  });
});

현장에서 자주 만나는 함정과 해결법

FAQ 1. navTo()를 호출했는데 URL은 바뀌었지만 View가 전환되지 않습니다.
controlId가 manifest.json과 XML 뷰에서 일치하지 않거나, SplitApp이 아직 렌더링되기 전에 라우터가 초기화된 경우입니다. Component.js의 init()에서 라우터 초기화는 반드시 UIComponent.prototype.init 호출 이후, 그리고 rootView가 생성된 다음에 이루어져야 합니다. createContent()로 App.view.xml을 반환하는 구조라면 자동으로 보장됩니다.

FAQ 2. detail로 진입했을 때 master가 표시되지 않습니다.
target 배열 순서를 확인하세요. ["master", "detail"]처럼 master를 먼저 두어야 좌측에 master가, 우측에 detail이 배치됩니다. 또한 master target의 controlAggregation이 masterPages가 아닌 pages 같은 잘못된 이름이면 라우터가 무시합니다. SplitApp이 노출하는 aggregation은 masterPages, detailPages, initialMaster, initialDetail 네 가지입니다.

FAQ 3. 페이지를 새로고침하면 ODataModel 바인딩이 풀립니다.
routePatternMatched 이벤트는 새로고침 직후에도 발생하지만, ODataModel의 metadata loading이 끝나기 전이면 bindElement가 실패합니다. this.getView().getModel().metadataLoaded().then(...)으로 감싸거나, manifest.json의 dataSources에 preload: true를 설정하면 안정적으로 동작합니다.

추가로 자주 발생하는 이슈로 transition이 매번 slide로 들어가서 어색한 경우가 있습니다. defaultTransitionNameDetail을 "show"로 두면 즉시 전환이 일어나 Master-Detail UX에 더 적합합니다. 그리고 비동기 라우팅(async: true)을 켜지 않으면 1.90 이후 버전에서 성능 경고가 발생하므로 신규 프로젝트는 반드시 true로 설정합니다.

이어서 살펴볼 주제

중첩 라우팅이 익숙해지면 3단계 이상 깊이의 nested routes(예: orders/{id}/items/{itemId})로 확장할 수 있습니다. 이때 FlexibleColumnLayout 컨트롤을 사용하면 한 화면에 세 개의 열을 동시에 표시하는 Fiori 3-Column 패턴을 구현할 수 있습니다. 또한 라우팅 상태를 URL 쿼리 파라미터로 직렬화하는 query 옵션, 그리고 라우터 레벨의 EventBus 활용을 추가로 학습하면 SAP Fiori Elements의 동작 원리를 더 깊이 이해할 수 있습니다.

  • FlexibleColumnLayout과 3-Column 라우팅 패턴
  • nested routes의 parent-child 관계 설계
  • routing query parameter와 상태 보존 전략
  • SAP Fiori Elements의 라우팅 메타 정보 분석

참고 자료

댓글 0

아직 댓글이 없습니다.