UI5

:param vs * — UI5 Router 와일드카드 패턴 #shorts #SAP #UI5

▶ YouTube에서 보기

개요 및 다룰 내용

SAP UI5 SPA(Single Page Application)에서 URL 하나가 곧 화면의 상태이자 진입점이 됩니다. 주문 상세, 상품 조회, 고객 카드 등 수십~수백 종의 화면을 정적으로 라우팅 테이블에 나열하는 것은 비현실적이기 때문에, sap.m.routing.Router동적 세그먼트(:param)와일드카드(*)를 제공합니다. 이 글에서는 manifest.json의 routing 블록에 패턴을 선언하고, 컨트롤러의 attachPatternMatched로 이벤트를 구독한 뒤, oEvent.getParameter("arguments")로 파라미터를 추출해 decodeURIComponent로 안전하게 디코딩하는 실무 흐름을 정리합니다.

  • :orderId 같은 동적 세그먼트와 :all* 같은 catch-all 패턴 차이 이해
  • 특정 라우트만 구독하는 attachPatternMatched 패턴 적용
  • 한글·특수문자 URL에서 발생하는 인코딩 이슈 해결
  • 메모리 누수 방지를 위한 detach 처리

먼저 알아두면 좋은 배경

이 글은 UI5 1.71 LTS 이상에서 Component 기반 앱 구조(Component.js + manifest.json)를 사용해본 경험을 전제로 합니다. sap.ui.core.routing.Router의 라이프사이클(initialize, navTo)과 컨트롤러 훅(onInit, onExit)에 대한 기본적인 이해가 있다면 빠르게 따라올 수 있습니다.

환경과 준비물

아래 환경을 기준으로 코드를 검증했습니다. Fiori Launchpad에 배포하는 경우 라우팅 prefix(#&/)가 추가되므로 패턴은 동일하게 동작하지만 URL 외형이 달라집니다.

  • SAP UI5 1.120 (LTS), 하위 호환은 1.71 이상
  • BTP Build Code 또는 VS Code + Fiori Tools 확장
  • Node.js 18 LTS 이상, @ui5/cli 3.x
  • 샘플 앱: sap.m.App 기반 라우팅 컨테이너
  • 브라우저: Chromium 계열 권장(URL 디버깅 도구 풍부)

UI5 CLI로 로컬 서버를 띄울 때는 ui5 serve --open index.html 명령으로 hash 기반 라우팅이 동작하는지 즉시 확인할 수 있습니다.

패턴 매칭이 동작하는 원리

UI5 라우터는 내부적으로 crossroads.js 기반의 패턴 매처를 사용합니다. URL의 hash(#/ 이후 문자열)가 변경되면 등록된 패턴들을 선언 순서대로 검사해 가장 먼저 일치하는 라우트의 target을 표시합니다. 패턴 문법은 세 가지 토큰으로 구성됩니다.

  • 고정 세그먼트SalesOrder처럼 정확히 일치해야 하는 문자열
  • 동적 세그먼트 :name — 슬래시(/) 직전까지 임의의 문자열을 캡처해 arguments.name으로 전달
  • 와일드카드 :name* — 슬래시 포함 나머지 모든 경로를 통째로 캡처(catch-all)

비유하자면 도서관의 분류 체계와 비슷합니다. 고정 세그먼트는 "고전문학" 같은 구체적 서가 이름, 동적 세그먼트는 책장 번호처럼 단위가 정해진 라벨, 와일드카드는 "그 외 모든 자료"라는 후방 박스 역할을 합니다. 와일드카드 라우트는 항상 가장 마지막에 선언해야 정상적인 라우트들이 먼저 매칭될 기회를 갖습니다.

또한 :param?처럼 물음표를 붙이면 선택적 세그먼트가 되고, {query} 형태의 중괄호 표기는 쿼리스트링을 별도 객체로 분리해 받을 수 있습니다. 실무에서는 path 파라미터로는 식별자(ID, 코드)를, query 파라미터로는 필터·정렬 옵션을 받는 분리 전략이 일반적입니다.

핵심 규칙: URL hash가 바뀔 때마다 패턴 매칭이 발생하고, 매칭된 라우트의 patternMatched 이벤트가 발화합니다. 동일 라우트 내에서 파라미터만 바뀌어도 이벤트는 다시 발생하므로, 데이터 재조회 트리거로 활용할 수 있습니다.

실전 코드: 1단계 — manifest.json 라우팅 선언

1단계에서는 manifest.jsonsap.ui5.routing 블록에 동적 세그먼트와 catch-all 패턴을 함께 선언합니다. configrouterClass와 기본 viewType을 지정하고, routes 배열에 우선순위 순으로 라우트를 나열합니다.

{
  "sap.ui5": {
    "routing": {
      "config": {
        "routerClass": "sap.m.routing.Router",
        "viewType": "XML",
        "viewPath": "com.acme.orders.view",
        "controlId": "rootApp",
        "controlAggregation": "pages",
        "transition": "slide",
        "async": true
      },
      "routes": [
        {
          "name": "home",
          "pattern": "",
          "target": "Home"
        },
        {
          "name": "order",
          "pattern": "SalesOrder/{orderId}",
          "target": "OrderDetail"
        },
        {
          "name": "catch",
          "pattern": ":all*:",
          "target": "NotFound"
        }
      ],
      "targets": {
        "Home":        { "viewName": "Home",        "viewLevel": 1 },
        "OrderDetail": { "viewName": "OrderDetail", "viewLevel": 2 },
        "NotFound":    { "viewName": "NotFound",    "viewLevel": 0 }
      }
    }
  }
}

주의할 점은 "async": true를 반드시 켜는 것입니다. UI5 1.58 이후 동기 XHR 사용이 단계적으로 폐기되었고, 비동기 로딩이 권장됩니다. controlId는 root view에 둔 sap.m.App의 id와 일치해야 합니다.

실전 코드: 2단계 — 컨트롤러에서 이벤트 구독과 에러 처리

2단계에서는 OrderDetail 컨트롤러에서 특정 라우트만 골라 attachPatternMatched로 구독합니다. 전체 라우터에 거는 attachRouteMatched와 달리, 라우트별 구독은 다른 화면 진입 시 불필요한 콜백이 호출되지 않아 성능과 가독성이 모두 우수합니다.

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

  return Controller.extend("com.acme.orders.controller.OrderDetail", {

    onInit: function () {
      var oRouter = this.getOwnerComponent().getRouter();
      this._oRoute = oRouter.getRoute("order");
      this._oRoute.attachPatternMatched(this._onMatched, this);
    },

    _onMatched: function (oEvent) {
      var oArgs = oEvent.getParameter("arguments");
      var sRaw  = oArgs.orderId;

      if (!sRaw) {
        Log.warning("orderId 누락", "OrderDetail");
        this.getOwnerComponent().getRouter().getTargets().display("NotFound");
        return;
      }

      var sDecoded;
      try {
        sDecoded = decodeURIComponent(sRaw);
      } catch (e) {
        Log.error("URI 디코딩 실패: " + sRaw, e, "OrderDetail");
        MessageBox.error("주문 번호 형식이 올바르지 않습니다.");
        return;
      }

      this._loadOrder(sDecoded);
    },

    _loadOrder: function (sId) {
      var oModel = this.getView().getModel();
      var sPath  = "/SalesOrderSet('" + encodeURIComponent(sId) + "')";

      this.getView().bindElement({
        path: sPath,
        events: {
          dataRequested: function () {
            this.getView().setBusy(true);
          }.bind(this),
          dataReceived: function (oEvt) {
            this.getView().setBusy(false);
            if (!oEvt.getParameter("data")) {
              this.getOwnerComponent().getRouter()
                  .getTargets().display("NotFound");
            }
          }.bind(this)
        }
      });
    },

    onExit: function () {
      if (this._oRoute) {
        this._oRoute.detachPatternMatched(this._onMatched, this);
      }
    }
  });
});

핵심은 세 가지입니다. 첫째, try/catchdecodeURIComponentURIError를 잡아 잘못된 인코딩이 들어와도 앱이 죽지 않도록 합니다. 둘째, 데이터가 비어있으면 NotFound 타깃으로 폴백합니다. 셋째, onExit에서 detach를 호출해 뷰가 파괴된 뒤에도 콜백이 살아남는 메모리 누수를 차단합니다.

실전 코드: 3단계 — 운영 환경 패턴(쿼리·테스트·보안)

운영 단계에서는 path 파라미터에 더해 쿼리스트링까지 받는 라우트, 그리고 컨트롤러 단위 테스트를 위한 구조 분리가 필요합니다. 또한 URL에 들어오는 값은 외부 입력이므로 OData 호출 직전에 화이트리스트 검증을 거치는 편이 안전합니다.

{
  "name": "orderFiltered",
  "pattern": "SalesOrder/{orderId}/lines:?query:",
  "target": "OrderLines"
}
sap.ui.define([
  "sap/ui/core/mvc/Controller",
  "sap/base/Log"
], function (Controller, Log) {
  "use strict";

  // 화이트리스트: 영문 대문자/숫자/하이픈 최대 20자
  var RX_ORDER_ID = /^[A-Z0-9-]{1,20}$/;

  return Controller.extend("com.acme.orders.controller.OrderLines", {

    onInit: function () {
      this._oRoute = this.getOwnerComponent()
                         .getRouter()
                         .getRoute("orderFiltered");
      this._oRoute.attachPatternMatched(this._onMatched, this);
    },

    _onMatched: function (oEvent) {
      var oArgs  = oEvent.getParameter("arguments");
      var sId    = this._safeDecode(oArgs.orderId);
      var oQuery = oArgs["?query"] || {};

      if (!sId || !RX_ORDER_ID.test(sId)) {
        Log.error("잘못된 orderId: " + sId, null, "OrderLines");
        this._navToNotFound();
        return;
      }

      this._refresh(sId, {
        status: oQuery.status || "OPEN",
        sort:   oQuery.sort   || "deliveryDate"
      });
    },

    _safeDecode: function (sValue) {
      if (!sValue) { return null; }
      try {
        return decodeURIComponent(sValue);
      } catch (e) {
        Log.warning("디코딩 실패: " + sValue, e);
        return null;
      }
    },

    _refresh: function (sId, mOptions) {
      // 비즈니스 로직은 별도 헬퍼로 분리 → 단위 테스트 용이
      this.getView().getModel("orderHelper")
          .fetch(sId, mOptions)
          .catch(function (oError) {
            Log.error("주문 조회 실패", oError, "OrderLines");
          });
    },

    _navToNotFound: function () {
      this.getOwnerComponent().getRouter()
          .getTargets().display("NotFound");
    },

    onExit: function () {
      this._oRoute.detachPatternMatched(this._onMatched, this);
    }
  });
});

QUnit/OPA5 테스트에서는 라우터 매칭을 다음처럼 직접 트리거할 수 있습니다.

QUnit.test("orderId 패턴 매칭", function (assert) {
  var done = assert.async();
  var oRouter = this.oComponent.getRouter();

  oRouter.getRoute("order").attachPatternMatched(function (oEvent) {
    assert.strictEqual(
      oEvent.getParameter("arguments").orderId,
      "SO-1001",
      "동적 세그먼트가 정확히 추출됨"
    );
    done();
  });

  oRouter.navTo("order", { orderId: "SO-1001" });
});

보안 관점에서 두 가지를 강조합니다. 첫째, URL에서 받은 값을 그대로 OData URL 문자열에 보간하면 인젝션·이스케이프 이슈가 생길 수 있으므로 encodeURIComponent로 다시 감싸거나 bindElement의 path 빌더를 통해 안전하게 조립합니다. 둘째, 정규식 화이트리스트로 허용 문자 범위를 좁히면 비정상적인 입력 자체를 사전 차단할 수 있습니다.

흔한 실수와 트러블슈팅

실무에서 반복적으로 만나는 함정과 해결 방향을 정리합니다.

  • 와일드카드 라우트를 맨 앞에 둠:all* 패턴은 모든 경로를 잡아채므로 다른 라우트가 매칭될 기회를 잃습니다. 반드시 routes 배열의 마지막에 배치합니다.
  • detach 누락 — 뷰가 destroy된 뒤에도 라우터는 살아 있어 이벤트 핸들러가 누적됩니다. onExit에서 detachPatternMatched 호출을 잊지 마세요.
  • 한글 파라미터에서 깨짐 — 사용자가 북마크/공유한 URL은 이미 인코딩된 상태입니다. decodeURIComponent를 거치지 않으면 %ED%95%9C%EA%B8%80 같은 문자열이 그대로 노출됩니다.
  • 새로고침 시 라우트가 동작하지 않음 — 서버가 SPA fallback(index.html로 리라이트)을 지원해야 합니다. BTP HTML5 App Repo는 자동 처리되지만 자체 서버는 별도 설정이 필요합니다.

FAQ 1. 같은 라우트에서 orderId만 바뀌어도 patternMatched가 다시 발생하나요? 네. 파라미터가 달라지면 동일 라우트라도 매번 이벤트가 발화하므로 데이터 재조회 트리거로 사용할 수 있습니다.

FAQ 2. navTo로 이동했는데 브라우저 히스토리에 남기지 않으려면? oRouter.navTo("order", { orderId: "X" }, true)처럼 세 번째 인자에 true를 주면 hash가 replace 되어 뒤로가기 스택에 쌓이지 않습니다.

FAQ 3. 동적 세그먼트와 와일드카드를 한 패턴에 섞어도 되나요? 가능합니다. 예를 들어 SalesOrder/{orderId}/:rest* 형태로 ID는 정확히 받고 나머지 경로는 통째로 받을 수 있습니다. 다만 의미가 모호해지기 쉬워 분리된 라우트 두 개로 표현하는 편이 가독성이 좋습니다.

다음으로 살펴볼 주제

패턴 매칭에 익숙해졌다면 다음 단계로 확장해보길 권장합니다. 첫째, 중첩 라우팅(Nested Routes)으로 마스터-디테일 화면을 동시 표시하는 구조를 익히면 Fiori Elements 스타일에 가까워집니다. 둘째, Targets API로 동일 라우트에서 조건부 view 전환을 구현하면 권한별 화면 분기가 깔끔해집니다. 셋째, OPA5로 라우팅 시나리오 자동화 테스트를 추가하면 회귀 안정성이 크게 올라갑니다. 마지막으로 Fiori Launchpad 배포 시 cross-app navigation(CrossApplicationNavigation 서비스)과의 차이를 정리해두면 인터-앱 흐름까지 일관되게 설계할 수 있습니다.

참고할 만한 자료

댓글 0

아직 댓글이 없습니다.