개요 및 이 글에서 얻어갈 것
SAPUI5에서 Dialog를 컨트롤러 내부에서 new sap.m.Dialog(...)로 즉석 생성하는 코드를 흔히 볼 수 있습니다. 프로토타입 단계에서는 편리하지만 화면 수가 늘어나면 같은 다이얼로그 코드가 여러 파일에 흩어지고, 스타일 변경 한 번에 수십 개 파일을 수정해야 하는 상황이 생깁니다. 이 글에서는 XML Fragment로 Dialog를 별도 파일로 분리하고, 여러 뷰·컨트롤러에서 재사용하는 3단계 패턴을 다룹니다.
- XML Fragment의 로딩 메커니즘과
id스코프 이해 Fragment.load()비동기 API로 Dialog 인스턴스화- 다중 컨트롤러에서 재사용 가능한 DialogService 유틸 구성
- 메모리 누수 방지를 위한 라이프사이클 관리 포인트 파악
먼저 알고 있으면 좋은 것들
SAPUI5 XML View 문법과 컨트롤러 이벤트 핸들러 구조를 이해하고 있다면 충분합니다. sap.ui.core.mvc.Controller의 onInit, getView(), byId() 사용 경험이 있고 Promise/async-await 문법에 익숙하면 좋습니다. Component 기반 앱 구조와 manifest.json에서의 리소스 경로 설정도 알고 있으면 3단계 예제 이해가 수월합니다.
대상 릴리스와 실습 환경
이 글의 예제는 다음 환경을 기준으로 작성했습니다.
- SAPUI5 버전 1.120 LTS (1.108 이상에서
Fragment.load()Promise API 사용 가능) - SAP Business Application Studio 또는 VS Code + Fiori Tools
- Node.js 18 이상,
@sap/ux-ui5-tooling으로 로컬 서비스 실행 - 브라우저: 최신 버전 Chrome/Edge 권장
레거시 프로젝트가 sap.ui.xmlfragment() 동기 API를 사용 중이라면 이 글의 비동기 패턴으로 마이그레이션을 권장합니다. UI5 1.120 이후 동기 XHR은 브라우저 콘솔에 경고가 출력됩니다.
Fragment가 해결하는 문제와 동작 원리
Fragment는 "View의 일부를 떼어낸 재사용 가능한 컨트롤 트리"입니다. View와 결정적으로 다른 점은 자체 컨트롤러를 갖지 않는다는 것입니다. Fragment는 자신을 로드한 뷰나 컨트롤러의 컨텍스트에 붙어 살아가며, 이벤트 핸들러 press="onConfirm"은 부모 컨트롤러의 메서드를 참조합니다.
비유하자면 Fragment는 "인테리어 소품 세트"에 가깝습니다. 소파 자체는 어느 방에도 놓을 수 있지만, 전원 코드는 그 방의 콘센트에 꽂아야 작동합니다. Dialog Fragment도 마찬가지로 파일은 독립적이지만, 이벤트는 호출한 컨트롤러가 처리해야 합니다.
Fragment 로딩은 다음 순서로 진행됩니다.
Fragment.load({ name, controller })호출 → XML 파싱- 내부 컨트롤 트리 생성, ID는 owner view의 네임스페이스에 등록
- Promise가 resolve되면 최상위 컨트롤 반환
this.getView().addDependent(oDialog)로 뷰 라이프사이클과 연결
여기서 addDependent는 중요합니다. Dialog는 화면상 뷰의 자식이 아니지만(팝업 레이어에 렌더링), 라이프사이클 관점에서 뷰가 destroy되면 함께 정리되어야 합니다. 또한 뷰의 model, i18n, styleClass가 Dialog로 자동 전파되도록 하는 역할도 담당합니다.
실전 예제 3단계
가상의 시나리오로 "판매 주문 승인 시스템"을 가정합니다. 승인 확인 Dialog, 반려 사유 입력 Dialog, 상세 조회 Dialog가 여러 목록 화면에서 반복적으로 사용된다고 가정하고 단계별로 리팩터링합니다.
1단계 — Fragment 기본 분리
먼저 승인 확인 Dialog를 별도 XML Fragment로 분리합니다. 프로젝트 폴더 구조는 webapp/view/fragment/에 Fragment를 모으는 것을 권장합니다.
<!-- webapp/view/fragment/ConfirmApproval.fragment.xml -->
<core:FragmentDefinition
xmlns="sap.m"
xmlns:core="sap.ui.core">
<Dialog
id="confirmApprovalDialog"
title="{i18n>dialog.approval.title}"
type="Message">
<content>
<Text text="{i18n>dialog.approval.message}" />
<Text text="주문번호: {salesOrder>/orderId}" class="sapUiSmallMarginTop" />
</content>
<beginButton>
<Button
text="{i18n>btn.approve}"
type="Emphasized"
press=".onApproveConfirm" />
</beginButton>
<endButton>
<Button text="{i18n>btn.cancel}" press=".onApproveCancel" />
</endButton>
</Dialog>
</core:FragmentDefinition>
컨트롤러에서 이 Fragment를 로드해서 사용합니다.
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/ui/core/Fragment"
], function (Controller, Fragment) {
"use strict";
return Controller.extend("com.acme.sales.controller.OrderList", {
onOpenApprovalDialog: function () {
var oView = this.getView();
if (!this._pApprovalDialog) {
this._pApprovalDialog = Fragment.load({
id: oView.getId(),
name: "com.acme.sales.view.fragment.ConfirmApproval",
controller: this
}).then(function (oDialog) {
oView.addDependent(oDialog);
return oDialog;
});
}
this._pApprovalDialog.then(function (oDialog) {
oDialog.open();
});
},
onApproveConfirm: function () {
this._pApprovalDialog.then(function (oDialog) { oDialog.close(); });
},
onApproveCancel: function () {
this._pApprovalDialog.then(function (oDialog) { oDialog.close(); });
}
});
});
포인트는 this._pApprovalDialog에 Promise 자체를 캐시한다는 점입니다. Dialog 인스턴스가 아니라 Promise를 저장하면 로딩 중에 사용자가 버튼을 두 번 눌러도 중복 로드가 발생하지 않습니다.
2단계 — 에러 처리와 로깅을 갖춘 실무 시나리오
실제 승인 액션은 백엔드 호출을 수반합니다. 사용자 입력 검증, 서비스 호출 실패, 이벤트 취소 처리까지 담아봅니다.
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/ui/core/Fragment",
"sap/m/MessageBox",
"sap/m/MessageToast",
"sap/base/Log"
], function (Controller, Fragment, MessageBox, MessageToast, Log) {
"use strict";
var LOG_COMPONENT = "com.acme.sales.OrderList";
return Controller.extend("com.acme.sales.controller.OrderList", {
_loadDialog: function (sFragmentName, sCacheKey) {
var oView = this.getView();
if (!this[sCacheKey]) {
this[sCacheKey] = Fragment.load({
id: oView.getId(),
name: sFragmentName,
controller: this
}).then(function (oDialog) {
oView.addDependent(oDialog);
return oDialog;
}).catch(function (oErr) {
Log.error("Fragment load failed: " + sFragmentName, oErr, LOG_COMPONENT);
MessageBox.error("화면을 여는 중 문제가 발생했습니다.");
throw oErr;
});
}
return this[sCacheKey];
},
onOpenRejectDialog: function (oEvent) {
var oCtx = oEvent.getSource().getBindingContext("salesOrder");
if (!oCtx) {
MessageToast.show("주문을 먼저 선택하세요.");
return;
}
this._loadDialog(
"com.acme.sales.view.fragment.RejectReason",
"_pRejectDialog"
).then(function (oDialog) {
oDialog.setBindingContext(oCtx, "salesOrder");
oDialog.open();
});
},
onRejectSubmit: function () {
var that = this;
this._pRejectDialog.then(function (oDialog) {
var sReason = oDialog.getModel("form").getProperty("/reason");
if (!sReason || sReason.trim().length < 5) {
MessageBox.warning("반려 사유는 5자 이상 입력해주세요.");
return;
}
that._callRejectService(sReason)
.then(function () {
MessageToast.show("반려 처리되었습니다.");
oDialog.close();
})
.catch(function (oErr) {
Log.error("Reject service failed", oErr, LOG_COMPONENT);
MessageBox.error("반려 처리 중 오류가 발생했습니다.");
});
});
}
});
});
_loadDialog를 공통 헬퍼로 뽑아내 캐싱, 에러 핸들링, 뷰 의존성 등록을 일원화했습니다. Fragment마다 이 로직을 반복 작성하면 실수하기 쉽고, 특히 addDependent 누락 시 i18n 모델이 Dialog에서 undefined로 보이는 버그가 자주 발생합니다.
3단계 — 프로덕션급 DialogService 유틸
여러 컨트롤러에서 같은 Dialog를 열어야 한다면 유틸 클래스로 승격합니다. 예를 들어 상세 조회 Dialog는 목록, 대시보드, 검색 결과 화면 모두에서 사용됩니다.
sap.ui.define([
"sap/ui/base/Object",
"sap/ui/core/Fragment",
"sap/base/Log"
], function (BaseObject, Fragment, Log) {
"use strict";
return BaseObject.extend("com.acme.sales.service.DialogService", {
constructor: function (oOwnerView) {
BaseObject.call(this);
this._oOwnerView = oOwnerView;
this._mCache = {};
},
open: function (sFragmentName, oOptions) {
oOptions = oOptions || {};
var sKey = oOptions.cacheKey || sFragmentName;
var oView = this._oOwnerView;
var oController = oOptions.controller || oView.getController();
if (!this._mCache[sKey]) {
this._mCache[sKey] = Fragment.load({
id: oView.getId() + "-" + sKey,
name: sFragmentName,
controller: oController
}).then(function (oDialog) {
oView.addDependent(oDialog);
return oDialog;
});
}
return this._mCache[sKey].then(function (oDialog) {
if (oOptions.bindingContext) {
oDialog.setBindingContext(
oOptions.bindingContext,
oOptions.modelName
);
}
if (oOptions.beforeOpen) {
oOptions.beforeOpen(oDialog);
}
oDialog.open();
return oDialog;
}).catch(function (oErr) {
Log.error("DialogService.open failed", oErr, "DialogService");
delete this._mCache[sKey];
throw oErr;
}.bind(this));
},
destroy: function () {
Object.keys(this._mCache).forEach(function (sKey) {
this._mCache[sKey].then(function (oDialog) {
if (oDialog && !oDialog.bIsDestroyed) {
oDialog.destroy();
}
});
}.bind(this));
this._mCache = {};
BaseObject.prototype.destroy.apply(this, arguments);
}
});
});
컨트롤러의 onInit에서 인스턴스화하고, onExit에서 정리하면 됩니다.
onInit: function () {
this._oDialogSvc = new DialogService(this.getView());
},
onShowOrderDetail: function (oEvent) {
this._oDialogSvc.open(
"com.acme.sales.view.fragment.OrderDetail",
{
controller: this,
bindingContext: oEvent.getSource().getBindingContext("salesOrder"),
modelName: "salesOrder",
cacheKey: "orderDetail"
}
);
},
onExit: function () {
if (this._oDialogSvc) {
this._oDialogSvc.destroy();
}
}
이 구조의 이점은 테스트 용이성입니다. QUnit에서 DialogService를 목(mock)으로 대체해 컨트롤러 로직만 검증할 수 있고, OPA5 시나리오에서는 실제 서비스로 통합 검증이 가능합니다. 보안 관점에서는 Fragment 내부의 press=".onXxx" 이벤트가 controller 옵션으로 명시된 컨트롤러의 메서드만 호출하므로, 외부 스크립트가 임의 핸들러를 주입할 여지가 줄어듭니다.
자주 밟는 지뢰와 해결책
Q1. Dialog를 열 때마다 새로 생성해도 되지 않나요?
매번 Fragment.load() 후 destroy하는 방식도 동작은 합니다. 다만 다이얼로그 오픈 애니메이션 전에 파싱 지연이 발생하고, 열릴 때마다 입력값이 초기화됩니다. 상태 유지가 필요 없고 오픈 빈도가 낮다면 허용되지만, 일반적으로 캐싱 방식이 권장됩니다.
Q2. Fragment 내부 컨트롤을 this.byId("myInput")으로 찾으면 null이 나옵니다.
Fragment.load() 호출 시 id: oView.getId()를 넘겨야 Fragment 내부 ID가 뷰의 네임스페이스에 등록됩니다. 이 옵션을 생략하면 컨트롤 ID가 글로벌하게 생성되어 byId로 접근할 수 없습니다. 또한 여러 인스턴스가 필요하다면 id에 접미어를 붙여 충돌을 피해야 합니다.
Q3. Dialog가 열리는데 {i18n>...}이 그대로 문자열로 표시됩니다.
addDependent를 호출하지 않은 것이 원인일 확률이 높습니다. Dialog는 화면상 팝업 레이어에 렌더링되므로 뷰의 자식이 아니며, 명시적으로 addDependent해야 뷰의 모델이 propagate됩니다. 컴포넌트 레벨 i18n이라면 Component의 addDependent도 대안이 됩니다.
Q4. sap.ui.xmlfragment()를 계속 써도 되나요?
동기 API이며 UI5 1.58부터 deprecated로 표시되어 있습니다. 현재 코드가 동작한다고 해도 향후 릴리즈에서 제거될 가능성이 있고, 브라우저의 동기 XHR 정책 강화로 성능 경고가 발생합니다. Fragment.load()로 이전을 권장합니다.
이어서 살펴보면 좋은 주제
Fragment 패턴에 익숙해졌다면 다음 주제로 확장해보길 권합니다. 첫째, Fragment with own Controller — Dialog가 복잡한 자체 로직을 갖는 경우 Fragment.load()에 별도 컨트롤러 인스턴스를 넘겨 관심사를 분리할 수 있습니다. 둘째, Reuse Component — Fragment보다 더 큰 단위(뷰+컨트롤러+모델 세트) 재사용에는 재사용 컴포넌트 패턴이 적합합니다. 셋째, Table Personalization Dialog, ValueHelpDialog 같은 표준 Dialog 컨트롤을 Fragment 패턴 안에서 활용하는 방법도 실무 빈도가 높습니다.
더 깊이 보고 싶다면
- SAPUI5 Documentation — Fragments (help.sap.com/sapui5)
- SAPUI5 Documentation — Instantiation of Fragments
- SAPUI5 Documentation — Reusing UI Parts: Fragments
- SAPUI5 API Reference — sap.ui.core.Fragment
- SAPUI5 API Reference — sap.m.Dialog
- SAPUI5 Walkthrough Step 16 — Dialogs and Fragments
- SAP Help Portal — SAP Fiori Tools
댓글 0
아직 댓글이 없습니다.