UI5

Dialog 중복 금지 — Fragment 30초 분리법 #shorts #SAP #UI5

▶ YouTube에서 보기

이 글에서 얻어갈 것

SAPUI5에서 Dialog는 사용자와의 상호작용에서 가장 자주 등장하는 컨트롤입니다. 그런데 많은 개발자들이 여전히 컨트롤러 안에서 new sap.m.Dialog({...})를 직접 호출하며 코드를 길게 늘어뜨리고 있습니다. 이 방식은 화면이 하나일 때는 괜찮지만, 여러 뷰에서 같은 Dialog를 띄우려는 순간 유지보수 지옥으로 이어집니다. 이 글에서는 XML Fragment를 활용해 Dialog를 완전히 분리하고, 재사용 가능한 구조로 확장하는 세 가지 단계를 다룹니다.

  • Fragment의 개념과 View와의 차이를 설명할 수 있다
  • Fragment.load()를 사용해 Dialog를 비동기로 로드하는 패턴을 익힌다
  • 여러 컨트롤러에서 공용으로 쓸 수 있는 BaseController 확장 패턴을 구현한다
  • 메모리 누수와 다중 인스턴스 문제를 방지하는 방법을 이해한다

시작 전에 알아야 할 것

SAPUI5의 MVC 구조와 XML View 문법을 이해하고 있다는 전제로 진행합니다. sap.ui.core.mvc.Controller의 기본 라이프사이클(onInit, onExit)과 this.getView()를 통한 뷰 접근이 익숙해야 합니다. sap.m.Dialogsap.m.Button 등 기본 컨트롤을 XML로 선언하는 문법도 알고 있어야 합니다. Model/Binding에 대한 기초 지식이 있으면 3단계 예제 이해에 도움이 됩니다.

버전과 준비물

이 글의 예제는 SAPUI5 1.120 LTS 기준으로 작성되었으며, 1.108 이상에서 대부분 동일하게 동작합니다. SAP Business Application Studio(BAS)의 SAP Fiori generator로 만든 프로젝트, 혹은 로컬 npm install @ui5/cli -g로 부트스트랩한 프로젝트를 사용합니다.

  • Node.js 18 LTS 이상, UI5 Tooling 3.x
  • manifest.json에서 sap.ui5.dependencies.libssap.msap.ui.core가 포함되어 있어야 함
  • 테스트용 백엔드는 Northwind 대신 사내 PurchaseOrderService OData V4를 가정
  • 브라우저: Chrome 최신 버전 권장 (개발자 도구의 Memory 탭 활용)
Fragment는 SAPUI5 1.16부터 도입된 오래된 기능이지만, 비동기 로드 API인 Fragment.load()는 1.58부터 안정화되었습니다. 이보다 낮은 버전을 쓴다면 sap.ui.xmlfragment를 사용해야 하나, 신규 개발에서는 권장되지 않습니다.

Fragment는 왜 필요한가

Fragment를 이해하는 가장 쉬운 방법은 컨트롤러가 없는 미니 View라고 생각하는 것입니다. XML View는 자기만의 컨트롤러를 가지고 라우팅의 대상이 되지만, Fragment는 컨트롤러도 없고 라우팅 대상도 아닙니다. 대신 자신을 로드한 뷰의 컨트롤러를 그대로 빌려 씁니다. 이것이 핵심입니다.

예를 들어 PurchaseOrderApproveDialog.fragment.xml이라는 파일을 만들면, 이 안에 정의된 버튼의 press="onApprove" 이벤트는 이 Fragment를 로드한 컨트롤러의 onApprove 메서드를 호출합니다. 이 덕분에 UI 정의는 파일로 분리되면서도 로직은 컨트롤러에 남아 있는 이상적인 구조가 됩니다.

Dialog를 컨트롤러 안에서 직접 new sap.m.Dialog(...)로 만들면 세 가지 문제가 생깁니다. 첫째, JavaScript 코드 안에 UI 트리가 섞여서 가독성이 떨어집니다. 둘째, 같은 Dialog를 다른 뷰에서 재사용하려면 그 코드를 복사·붙여넣기 해야 합니다. 셋째, i18n 바인딩과 modelling 규칙을 XML만큼 깔끔하게 표현하기 어렵습니다.

다음 도식으로 관계를 정리하겠습니다.

[View.controller.js]
    │
    ├── this.loadFragment("PurchaseOrderApproveDialog")
    │        │
    │        ▼
    │  [PurchaseOrderApproveDialog.fragment.xml]
    │        │
    │        └── press="onApprove" ──→ View.controller.js의 onApprove()
    │
    └── this.byId("approveDialog").open()

Fragment는 로드되는 순간 owner view의 aggregation에 붙습니다. 그래서 this.byId("...")로 접근할 수 있죠. 이 owner 개념을 놓치면 나중에 ID 충돌이나 destroy 타이밍 문제로 고생합니다.

1단계 — 기본 Fragment 분리

가장 간단한 형태로 시작합니다. 구매 발주(PurchaseOrder) 목록에서 특정 발주를 승인하기 위한 Dialog를 Fragment로 분리해봅시다.

먼저 webapp/view/fragment/ApproveDialog.fragment.xml을 생성합니다.

<core:FragmentDefinition
    xmlns="sap.m"
    xmlns:core="sap.ui.core">
    <Dialog
        id="approveDialog"
        title="{i18n>dialog.approve.title}"
        contentWidth="30rem">
        <content>
            <VBox class="sapUiSmallMargin">
                <Label text="발주번호" />
                <Text text="{purchaseOrder>/PoNumber}" />
                <Label text="승인 코멘트" class="sapUiSmallMarginTop" />
                <TextArea
                    id="approveComment"
                    value="{approve>/comment}"
                    rows="4" />
            </VBox>
        </content>
        <beginButton>
            <Button
                text="승인"
                type="Emphasized"
                press=".onApproveConfirm" />
        </beginButton>
        <endButton>
            <Button text="취소" press=".onApproveCancel" />
        </endButton>
    </Dialog>
</core:FragmentDefinition>

이제 컨트롤러에서 이 Fragment를 로드하고 엽니다. PurchaseOrderList.controller.js의 핵심 부분입니다.

sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/ui/model/json/JSONModel"
], function (Controller, JSONModel) {
    "use strict";

    return Controller.extend("com.acme.po.controller.PurchaseOrderList", {

        onOpenApproveDialog: function (oEvent) {
            var oView = this.getView();
            var oCtx = oEvent.getSource().getBindingContext("purchaseOrder");

            oView.setModel(new JSONModel({ comment: "" }), "approve");

            if (!this._pApproveDialog) {
                this._pApproveDialog = this.loadFragment({
                    name: "com.acme.po.view.fragment.ApproveDialog"
                });
            }
            this._pApproveDialog.then(function (oDialog) {
                oDialog.setBindingContext(oCtx, "purchaseOrder");
                oDialog.open();
            });
        },

        onApproveConfirm: function () {
            var sComment = this.getView().getModel("approve").getProperty("/comment");
            // 실제 승인 처리 호출
            this.byId("approveDialog").close();
        },

        onApproveCancel: function () {
            this.byId("approveDialog").close();
        }
    });
});

여기서 주목할 점 두 가지. 첫째, this.loadFragment()는 UI5 1.93부터 제공되는 컨트롤러 헬퍼로, 자동으로 owner view에 aggregation 시켜줍니다. 둘째, Promise를 this._pApproveDialog에 캐싱해서 재호출 시 새 인스턴스를 만들지 않습니다.

2단계 — 에러 처리와 로깅을 갖춘 실무 패턴

1단계 코드는 데모 수준입니다. 실무에서는 Dialog가 로드되지 않거나, 사용자가 여러 번 클릭하거나, 백엔드 호출이 실패하는 경우를 모두 처리해야 합니다.

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

    var LOG_COMPONENT = "com.acme.po.PurchaseOrderList";

    return Controller.extend("com.acme.po.controller.PurchaseOrderList", {

        onOpenApproveDialog: async function (oEvent) {
            var oCtx = oEvent.getSource().getBindingContext("purchaseOrder");
            if (!oCtx) {
                Log.warning("승인 대상 컨텍스트가 없습니다", null, LOG_COMPONENT);
                return;
            }

            try {
                var oDialog = await this._getApproveDialog();
                this.getView().setModel(
                    new JSONModel({ comment: "", busy: false }),
                    "approve"
                );
                oDialog.setBindingContext(oCtx, "purchaseOrder");
                oDialog.open();
            } catch (oError) {
                Log.error("ApproveDialog 로드 실패", oError, LOG_COMPONENT);
                MessageBox.error("승인 화면을 여는 중 오류가 발생했습니다.");
            }
        },

        _getApproveDialog: function () {
            if (!this._pApproveDialog) {
                this._pApproveDialog = this.loadFragment({
                    name: "com.acme.po.view.fragment.ApproveDialog"
                });
            }
            return this._pApproveDialog;
        },

        onApproveConfirm: async function () {
            var oModel = this.getView().getModel("approve");
            var sComment = (oModel.getProperty("/comment") || "").trim();

            if (sComment.length < 5) {
                MessageToast.show("승인 코멘트는 5자 이상 입력해주세요.");
                return;
            }

            oModel.setProperty("/busy", true);
            try {
                await this._callApproveService(sComment);
                MessageToast.show("승인이 완료되었습니다.");
                this.byId("approveDialog").close();
            } catch (oError) {
                Log.error("승인 서비스 실패", oError, LOG_COMPONENT);
                MessageBox.error("승인 처리에 실패했습니다: " + oError.message);
            } finally {
                oModel.setProperty("/busy", false);
            }
        },

        onExit: function () {
            if (this.byId("approveDialog")) {
                this.byId("approveDialog").destroy();
            }
        }
    });
});

주요 개선점을 정리하면, async/await로 흐름을 직관화했고, sap.base.Log로 컴포넌트별 로깅을 남겼으며, 입력값 검증과 busy 상태 처리를 추가했습니다. 그리고 onExit에서 Dialog를 destroy()하여 SPA에서 뷰가 사라질 때 리스너와 DOM을 함께 정리합니다.

3단계 — BaseController 확장으로 진짜 재사용

같은 확인 Dialog를 PurchaseOrderList, SalesQuoteDetail, BusinessPartnerEdit 세 화면에서 모두 쓴다고 가정합시다. 매번 loadFragment 로직을 복사할 순 없습니다. BaseController를 만들어 공용 API로 승격시킵니다.

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

    return Controller.extend("com.acme.common.controller.BaseController", {

        openReusableDialog: async function (sFragmentName, sDialogId, oContext) {
            this._mDialogs = this._mDialogs || {};

            if (!this._mDialogs[sFragmentName]) {
                this._mDialogs[sFragmentName] = this.loadFragment({
                    name: sFragmentName
                });
            }

            var oDialog = await this._mDialogs[sFragmentName];
            if (oContext) {
                oDialog.setBindingContext(oContext.ctx, oContext.modelName);
            }
            oDialog.open();
            return oDialog;
        },

        closeReusableDialog: function (sDialogId) {
            var oDialog = this.byId(sDialogId);
            if (oDialog && oDialog.isOpen()) {
                oDialog.close();
            }
        },

        onExit: function () {
            var mDialogs = this._mDialogs || {};
            Object.keys(mDialogs).forEach(function (sKey) {
                mDialogs[sKey].then(function (oDialog) {
                    if (oDialog && !oDialog.bIsDestroyed) {
                        oDialog.destroy();
                    }
                }).catch(function (oErr) {
                    Log.warning("Dialog destroy skipped", oErr);
                });
            });
        }
    });
});

이제 각 화면의 컨트롤러는 BaseController를 상속하고, 한 줄로 Dialog를 열 수 있습니다.

sap.ui.define([
    "com/acme/common/controller/BaseController"
], function (BaseController) {
    "use strict";

    return BaseController.extend("com.acme.po.controller.PurchaseOrderList", {
        onOpenApproveDialog: function (oEvent) {
            this.openReusableDialog(
                "com.acme.po.view.fragment.ApproveDialog",
                "approveDialog",
                {
                    ctx: oEvent.getSource().getBindingContext("purchaseOrder"),
                    modelName: "purchaseOrder"
                }
            );
        }
    });
});

테스트 관점에서는 openReusableDialog가 Promise를 반환하므로 OPA5 테스트에서 await로 대기할 수 있고, QUnit에서는 Fragment 로드를 Stub 처리해 순수 로직만 검증할 수 있습니다. 보안 측면에서는 Fragment 파일 경로를 상수로 관리하여 임의 문자열이 loadFragment에 주입되지 않도록 하는 것이 좋습니다.

자주 마주치는 실수와 해결

Q1. Dialog가 두 번 열리거나 ID 중복 에러가 뜹니다. 대부분 매 호출마다 loadFragment를 새로 실행하기 때문입니다. Promise를 인스턴스 변수에 캐싱해서 최초 한 번만 로드하도록 하세요. 특히 Fragment.load()를 직접 쓸 때는 owner view에 aggregation 시키기 위해 oView.addDependent(oDialog)를 잊지 말아야 합니다.

Q2. Fragment 안의 컨트롤을 this.byId로 찾을 수 없습니다. Fragment의 컨트롤 ID는 owner view의 prefix가 붙습니다. 따라서 oView.byId("approveDialog") 혹은 컨트롤러에서 this.byId("approveDialog")로 접근해야 합니다. sap.ui.getCore().byId는 prefix가 없는 전역 ID일 때만 동작합니다.

Q3. 라우팅으로 뷰를 떠났다 돌아오면 Dialog 상태가 이상합니다. Dialog는 뷰의 dependent aggregation에 붙어 있어 뷰가 destroy되면 함께 사라져야 정상입니다. 라우팅에서 뷰를 재사용하도록 설정했다면 onBeforeRendering이나 라우팅 이벤트에서 모델 상태를 리셋해주세요. 캐싱된 Promise가 이전 인스턴스를 가리키고 있지 않은지도 확인해야 합니다.

추가로 조심할 부분은 접근성(ARIA)입니다. Dialog에 title을 반드시 지정하고, 첫 번째 포커스 가능한 요소를 initialFocus aggregation으로 지정하면 스크린리더 사용성이 향상됩니다.

이어서 익혀볼 주제

Fragment 재사용 패턴을 익혔다면, 이어서 ExtensionPoint를 활용한 SAP Fiori elements 확장, Custom Control로 컨트롤 자체를 재사용 가능한 패키지로 만드는 방법, Component Reuse를 통한 UI 컴포넌트 단위 재활용을 학습해보시기 바랍니다. 또한 대규모 앱에서는 Fragment를 manifest.jsonsap.ui5.rootView와 함께 dependency injection 패턴으로 관리하는 방식도 검토해볼 만합니다. 테스트 자동화가 필요하다면 OPA5의 iOpenTheDialog 액션을 활용하는 것이 일반적으로 권장됩니다.

더 읽어볼 문서

댓글 0

아직 댓글이 없습니다.