왜 Hash Router가 SPA의 심장인가
SAPUI5 애플리케이션은 단일 페이지 애플리케이션(SPA)이다. 브라우저는 한 번 index.html을 받은 뒤 더 이상 서버에 페이지를 요청하지 않는다. 그렇다면 "목록 화면에서 상세 화면으로 이동"이라는 행위는 도대체 어떻게 일어나는 것일까. 답은 URL의 해시(#) 뒤쪽을 갈아끼우면서 sap.m.routing.Router가 적절한 View를 NavContainer에 push 하는 방식이다. 해시는 서버로 전송되지 않는 영역이라 라우팅 처리가 100% 클라이언트에서 일어나고, 그 덕분에 새로고침이나 뒤로가기, 즐겨찾기 같은 브라우저 기본 동작과도 자연스럽게 연동된다.
실무에서 라우팅을 대충 잡으면 어떤 일이 벌어지냐 하면, 사용자가 상세 화면에서 새로고침하는 순간 "잘못된 라우트"로 떨어지면서 빈 화면이 뜨거나, 뒤로가기를 눌렀을 때 데이터가 갱신되지 않는 현상이 흔하다. 이런 문제 대부분은 manifest.json의 routing 블록 설계가 부실하거나, attachPatternMatched를 잘못 거는 데서 출발한다. 이 글에서는 manifest 설정 → navTo 호출 → onRouteMatched 수신이라는 3단계를 실무 관점에서 짚어본다.
준비 환경과 버전 가정
설명은 SAPUI5 1.120 LTS(2024년 이후 권장 LTS)와 SAP Business Application Studio의 Fiori 프로젝트 템플릿 기준이다. 1.84 이상이면 코드 호환은 대부분 유지되며, Async View와 Targets 기반 라우팅을 사용한다는 전제를 깔고 간다. 로컬에서 따라하려면 npm i -g @ui5/cli로 ui5 tooling을 설치한 뒤 ui5 serve로 띄우면 된다. 빌드 배포 시에는 BTP의 HTML5 Application Repository나 Launchpad Service에 올리는 시나리오를 가정한다.
한 가지 미리 짚을 점은, Fiori Launchpad에 배포되면 외부 해시(#Shell-home)와 앱 내부 해시가 &/로 구분되어 합쳐진다는 사실이다. 즉 standalone에서 #/detail/42이던 URL이 Launchpad 안에서는 #MyApp-display&/detail/42 형태가 된다. Router 코드를 짤 때 이 차이를 의식하지 않아도 SAPUI5가 알아서 처리해 주지만, 디버깅할 때 "URL이 왜 이러지" 하면서 당황하지 않으려면 알아두는 편이 좋다.
manifest.json routing 블록 해부
UI5 라우팅의 모든 것은 manifest.json의 sap.ui5.routing 노드 안에서 결정된다. 크게 config, routes, targets 세 영역으로 나뉘는데, 첫 번째 단계에서는 config와 routes를 함께 정의하는 압축형으로 시작해 보자.
{
"sap.ui5": {
"rootView": {
"viewName": "myApp.view.App",
"type": "XML",
"id": "app",
"async": true
},
"routing": {
"config": {
"routerClass": "sap.m.routing.Router",
"viewPath": "myApp.view",
"controlId": "app",
"controlAggregation": "pages",
"viewType": "XML",
"async": true
},
"routes": [
{ "name": "main", "pattern": "", "target": "main" },
{ "name": "detail", "pattern": "detail/{id}", "target": "detail" }
],
"targets": {
"main": { "viewName": "Main", "viewLevel": 1 },
"detail": { "viewName": "Detail", "viewLevel": 2 }
}
}
}
}
핵심 옵션을 풀어보면, routerClass는 모바일 패턴(NavContainer push/pop)을 쓸 거면 sap.m.routing.Router, 데스크톱 마스터-디테일 전용이면 sap.m.routing.Router를 그대로 두고 viewLevel만 다르게 잡는 식이다. controlId는 rootView에서 정의한 컨테이너(App, SplitApp 등)의 ID와 정확히 일치해야 하며, controlAggregation은 그 컨테이너에서 자식 뷰가 들어갈 aggregation 이름이다. App 컨트롤이면 pages, SplitApp이면 masterPages/detailPages가 된다.
pattern은 URL 해시 부분을 매칭하는 패턴 문자열이다. 빈 문자열은 루트, {id}는 필수 파라미터, :id:처럼 콜론으로 감싸면 옵셔널 파라미터, {?query}는 쿼리스트링 객체로 받는다. 예컨대 "products/{category}/:productId:"로 잡으면 #/products/laptop도, #/products/laptop/p001도 모두 같은 라우트로 빠진다.
navTo로 화면 전환하기
두 번째 단계는 컨트롤러에서 라우터를 호출해 화면을 바꾸는 코드다. 목록에서 항목을 클릭하면 상세로 넘어가는 흔한 시나리오를 보자.
sap.ui.define([
"sap/ui/core/mvc/Controller"
], function (Controller) {
"use strict";
return Controller.extend("myApp.controller.Main", {
onItemPress: function (oEvent) {
var oCtx = oEvent.getParameter("listItem").getBindingContext();
var sId = oCtx.getProperty("id");
this.getOwnerComponent()
.getRouter()
.navTo("detail", {
id: sId
});
},
onOpenWithQuery: function () {
this.getOwnerComponent().getRouter().navTo("detail", {
id: "42",
"?query": { tab: "history", focus: "true" }
});
}
});
});
navTo(routeName, parameters, replaceHash)가 시그니처의 핵심이다. 첫 인자는 manifest에 정의한 라우트 이름, 둘째는 패턴에 들어갈 파라미터 객체, 셋째 인자는 브라우저 히스토리를 새로 쌓을지(false 또는 생략) 아니면 현재 항목을 치환할지(true) 결정한다. 로그인 화면에서 메인으로 넘어가는 것처럼 "뒤로가기로 돌아오면 안 되는" 전환은 반드시 true를 줘야 한다. 그렇지 않으면 사용자가 메인에서 뒤로가기를 누르는 순간 로그아웃 상태인데 로그인 폼이 다시 떠서 혼란을 부른다.
한 가지 자주 까먹는 디테일은, navTo는 비동기이지만 Promise를 직접 반환하지 않는다는 점이다. 전환 직후 화면 상태에 의존하는 후속 작업이 필요하다면 도착지의 onRouteMatched에서 처리해야지, navTo 호출 다음 줄에 적으면 안 된다.
attachPatternMatched로 파라미터 수신
세 번째 단계는 도착한 화면(컨트롤러)에서 URL 파라미터를 받아 데이터를 로드하는 코드다.
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("myApp.controller.Detail", {
onInit: function () {
var oRouter = this.getOwnerComponent().getRouter();
oRouter.getRoute("detail")
.attachPatternMatched(this._onMatched, this);
},
_onMatched: function (oEvent) {
var oArgs = oEvent.getParameter("arguments");
var sId = oArgs.id;
var oQuery = oArgs["?query"] || {};
this.getView().setBusy(true);
this._loadData(sId, oQuery)
.then(function (oData) {
this.getView().setModel(new JSONModel(oData), "detail");
}.bind(this))
.catch(function (oErr) {
MessageToast.show("데이터 로드 실패: " + oErr.message);
})
.finally(function () {
this.getView().setBusy(false);
}.bind(this));
},
_loadData: function (sId, oQuery) {
return new Promise(function (resolve, reject) {
jQuery.ajax({
url: "/odata/v4/svc/Products(" + sId + ")",
dataType: "json",
success: resolve,
error: function (xhr) { reject(new Error(xhr.statusText)); }
});
});
}
});
});
주의할 점은 두 가지다. 첫째, attachPatternMatched는 onInit에서 한 번만 걸어야 한다. routeMatched 이벤트는 라우터 전체에서 발생하지만, patternMatched는 해당 라우트가 매칭됐을 때만 호출되므로 라우트 단위 처리에 더 적합하다. 둘째, oEvent.getParameter("arguments")로 받은 객체에는 패턴의 명명된 파라미터(id, category 등)와 함께 쿼리 객체가 ?query 키로 들어 있다. 키 이름에 물음표가 있어 점 표기법이 안 되니 반드시 대괄호로 접근해야 한다.
프로덕션에서 흔히 추가하는 것들
샘플 코드가 돌아간다고 실서비스에 그대로 올리면 안 되고, 다음 항목을 더 챙겨야 한다. 잘못된 해시 처리, 비동기 데이터 동기화, 메모리 누수 방지가 3대 축이다.
// Component.js
sap.ui.define([
"sap/ui/core/UIComponent"
], function (UIComponent) {
"use strict";
return UIComponent.extend("myApp.Component", {
metadata: { manifest: "json" },
init: function () {
UIComponent.prototype.init.apply(this, arguments);
var oRouter = this.getRouter();
oRouter.attachBypassed(function (oEvent) {
// 매칭 실패 시 NotFound 라우트로
oRouter.getTargets().display("notFound", {
fromTarget: oEvent.getParameter("hash")
});
});
oRouter.initialize();
}
});
});
매칭 실패 시 bypassed 이벤트가 발생한다는 점이 중요하다. manifest의 routing.config.bypassed.target으로 설정해도 되지만, 코드에서 직접 잡으면 어떤 해시에서 실패했는지 로그로 남기기 좋다. 또 onExit에서 detachPatternMatched를 호출하지 않으면 동일 컨트롤러가 재생성될 때 핸들러가 중복 등록되어 같은 요청이 두 번씩 날아가는 현상이 발생한다. 라우터는 컴포넌트 라이프사이클을 따라가므로 일반적으로는 큰 문제가 없지만, 동적으로 컴포넌트를 여러 번 생성/파괴하는 컴포지트 앱에서는 반드시 detach 해 주는 편이 안전하다.
보안 관점에서는 URL 파라미터를 그대로 OData 키로 꽂아 넣는 코드를 조심해야 한다. 사용자가 주소창을 손으로 고쳐서 다른 ID를 넣고 들어올 수 있으므로, 백엔드에서 권한 체크는 별도로 걸어야 한다. UI5 라우팅은 어디까지나 화면 전환 메커니즘이지 인증/인가 도구가 아니다.
실무에서 자주 부딪히는 문제들
Q1. navTo 했는데 화면이 안 바뀐다. 90%는 manifest의 controlId가 rootView의 ID와 어긋났거나, controlAggregation이 컨테이너 컨트롤의 실제 aggregation 이름과 다른 경우다. App 컨트롤은 pages, SplitApp은 detailPages가 디폴트라는 점을 기억하자. 브라우저 콘솔에 "Control with ID xxx not found" 경고가 떴다면 십중팔구 이 케이스다.
Q2. URL 파라미터가 undefined로 들어온다. 라우트 이름과 navTo의 첫 인자가 정확히 일치하는지, pattern의 placeholder 이름과 navTo로 넘긴 키가 같은지 확인한다. 예를 들어 pattern이 "detail/{id}"인데 navTo로 {productId: "42"}를 넘기면 매칭은 되지만 id가 비어 있다. 또 attachPatternMatched 콜백에서 oEvent.getParameter()가 아니라 oEvent.getParameters()를 쓰면 arguments가 한 단계 더 들어가 있으니 구조분해할 때 주의한다.
Q3. 새로고침하면 빈 화면이 뜬다. Launchpad 외부에서 standalone으로 띄울 때 자주 보이는 증상이다. 웹 서버가 SPA 라우팅을 모르고 /detail/42를 실제 파일 경로로 해석해 404를 반환하는 케이스인데, 해시 라우터를 쓰면 #/ 뒤는 서버로 안 가니까 이 문제가 안 생긴다. 만약 History API 기반 라우터로 바꿨거나 nginx가 해시 앞부분만 보고 라우팅을 시도한다면, 모든 경로를 index.html로 fallback하도록 설정해야 한다.
Q4. 같은 라우트로 다시 navTo 했는데 onRouteMatched가 안 불린다. 동일한 해시로 이동하면 URL이 바뀌지 않으므로 매칭 이벤트가 발생하지 않는다. 강제로 데이터를 새로 고치고 싶다면 별도의 refresh 메서드를 만들거나, 더미 쿼리스트링을 붙여 해시를 변화시키는 우회법이 일반적이다.
다음에 살펴볼 주제
여기까지 잡고 나면 자연스럽게 다음 단계는 Targets와 Routes의 분리 설계, 중첩 라우팅(nested routing), FlexibleColumnLayout 기반 3-컬럼 마스터-디테일로 이어진다. 특히 FCL은 viewLevel과 layout 파라미터를 함께 다루기 때문에 routing 설정이 한 단계 더 복잡해진다. 그리고 OData V4와 결합할 때는 routeMatched에서 직접 fetch 하는 대신 bindElement로 라우트 파라미터를 컨텍스트에 바인딩하는 패턴이 권장된다. Fiori Elements로 넘어가면 라우팅 자체가 자동 생성되므로, manifest의 routing 노드를 직접 손으로 쓰는 일은 줄어들지만 내부 메커니즘을 알고 있으면 디버깅이 훨씬 수월하다.
참고할 만한 자료
- SAPUI5 SDK Demo Kit — Routing and Navigation 섹션 (sapui5.hana.ondemand.com 데모킷의 Topic 6)
- help.sap.com — SAPUI5 Developer Guide의 "Initializing and Loading a Router"
- help.sap.com — "Routing Configuration" (manifest.json sap.ui5.routing 레퍼런스)
- help.sap.com — "Navigating and Passing Data" (navTo 사용 패턴)
- SAP Community 블로그 — "Navigation and Routing in SAP Fiori Launchpad" (외부/내부 해시 결합 방식 설명)
- openui5.org GitHub — sap.m.routing.Router 소스 코드 (이벤트 발생 순서 확인용)
댓글 0
아직 댓글이 없습니다.