UI5

BusyDialog vs BusyIndicator — 언제 뭘 써야 해? #shorts #SAP #UI5

▶ YouTube에서 보기

이 글의 목적과 도달점

SAPUI5 화면에서 사용자에게 "지금 처리 중"이라는 신호를 주는 방법은 크게 두 갈래입니다. 하나는 화면 전체를 막아버리는 sap.m.BusyDialog, 다른 하나는 컨트롤 단위로 부분 차단을 거는 setBusy(true) 혹은 전역 sap.ui.core.BusyIndicator입니다. 둘 다 "로딩 스피너"를 띄우지만 차단 범위, 사용자 인터랙션 가능 여부, 모달 동작이 전혀 다릅니다. 이 글이 끝나면 다음을 직접 결정할 수 있게 됩니다.

  • SalesOrder 저장처럼 절대 중단되면 안 되는 작업에 어떤 컨트롤이 맞는지
  • 테이블 데이터만 다시 불러올 때 전체 화면을 막지 않는 방법
  • setBusy(false) 해제 누락으로 화면이 영구히 잠기는 사고를 막는 패턴
  • 동시 비동기 요청에서 발생하는 BusyIndicator 카운팅 레이스 컨디션 회피

이 글을 따라가기 위한 사전 이해

SAPUI5 1.71 LTS 이상에서 XML View, Controller, JSONModel/ODataModel 비동기 호출(Promise)을 다뤄 본 경험이 필요합니다. sap.ui.define 모듈 시스템과 컨트롤러의 onInit, 이벤트 핸들러 구조를 알고 있다면 충분합니다. Promise/async-await에 익숙하지 않다면 먼저 익혀두는 편이 안전합니다. BusyDialog와 BusyIndicator는 비동기 흐름과 강하게 결합되어 있어, "언제 켜고 언제 끄느냐"가 코드의 전부이기 때문입니다.

실습 환경과 버전 정보

이 글의 코드는 다음 환경에서 검증한 패턴을 일반적으로 권장되는 형태로 정리한 것입니다.

  • SAPUI5 런타임: 1.120.x (LTS) 및 1.71.x 호환 — sap.m.BusyDialog, sap.ui.core.BusyIndicator API는 1.30+ 안정 상태
  • 개발 도구: SAP Business Application Studio 또는 VS Code + UI5 Tooling 3.x
  • 백엔드: SAP S/4HANA Cloud Public/Private Edition의 OData V2/V4 서비스 또는 CAP(Node.js) 모킹
  • 브라우저: Chrome/Edge 최신 (모달 포커스 트랩 동작 확인용)
  • 모델: sap.ui.model.odata.v2.ODataModel 또는 v4.ODataModel

BusyDialog와 setBusy는 표준 UI5 라이브러리에 포함되어 별도 의존성 설치가 필요 없습니다. 다만 sap.m 라이브러리가 manifest.jsonsap.ui5/dependencies/libs에 선언되어 있어야 합니다.

핵심 개념: 세 가지 차단 메커니즘의 차이

UI5의 로딩 표시 API는 비슷해 보이지만 작동 계층이 다릅니다. 비유하자면 BusyIndicator는 "건물 전체 셔터", BusyDialog는 "엘리베이터에 띄운 안내문", setBusy는 "특정 사무실 문에 거는 회의중 팻말"입니다.

API 차단 범위 모달 여부 주 용도
sap.ui.core.BusyIndicator.show() 전체 페이지 (body 오버레이) 강한 모달, ESC 불가 앱 초기 부트스트랩, 라우팅 전환
sap.m.BusyDialog 전체 화면 + 텍스트/취소 버튼 모달, 취소 콜백 지원 저장/제출 등 사용자 인지가 중요한 장기 작업
oControl.setBusy(true) 해당 컨트롤(및 하위) 영역 부분 차단, 페이지 일부는 조작 가능 테이블/패널 단위 부분 갱신

여기서 자주 오해하는 점이 두 가지 있습니다. 첫째, BusyIndicator.show(delay)의 delay 파라미터는 "지연 후 표시"입니다. 기본값 1000ms라 짧은 호출에서는 스피너가 아예 안 보이는데, 이는 깜빡임 방지를 위한 의도된 동작입니다. 둘째, setBusy는 자식 컨트롤로 전파되지 않습니다. PagesetBusy(true)를 걸면 페이지 본문은 차단되지만 헤더 버튼이 별도 aggregation이라 클릭이 가능할 수 있습니다. 전체 차단이 목적이라면 BusyDialog가 더 안전한 선택입니다.

라이프사이클 관점에서 BusyDialog는 "한 번 생성하고 재사용"이 권장됩니다. 매 호출마다 new BusyDialog()를 만들면 메모리 누수와 z-index 충돌이 생깁니다. 컨트롤러 인스턴스 변수로 보관하고 onExit에서 destroy()로 정리하는 패턴이 일반적입니다.

1단계 — 가장 단순한 BusyDialog 호출

먼저 SalesOrder 저장 버튼을 누르면 전체 화면을 막고, 응답이 오면 해제하는 최소 골격입니다.

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

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

        onInit: function () {
            // 재사용 인스턴스 1개만 유지
            this._oSaveDialog = new BusyDialog({
                title: "주문 저장 중",
                text: "ERP에 전송하고 있습니다. 잠시만 기다려 주세요."
            });
        },

        onSavePress: function () {
            var oModel = this.getView().getModel();
            this._oSaveDialog.open();

            oModel.submitChanges({
                success: function () {
                    this._oSaveDialog.close();
                    MessageToast.show("주문 저장 완료");
                }.bind(this),
                error: function () {
                    this._oSaveDialog.close();
                    MessageToast.show("저장 실패");
                }.bind(this)
            });
        },

        onExit: function () {
            if (this._oSaveDialog) {
                this._oSaveDialog.destroy();
            }
        }
    });
});

여기서 핵심은 successerror 양쪽 모두에서 close()를 호출한다는 점입니다. 한쪽이라도 빠지면 네트워크 오류 시 다이얼로그가 영구히 떠 있게 됩니다.

2단계 — 테이블 부분 로딩과 취소 버튼, 에러 로깅

실무에서는 "전체 차단"보다 "테이블만 비활성화"가 훨씬 자주 필요합니다. 사용자가 필터 패널은 계속 조작할 수 있어야 하기 때문입니다. 동시에 장시간 백엔드 호출에는 사용자가 포기할 수 있는 취소 버튼이 권장됩니다.

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

    return Controller.extend("com.acme.so.controller.OrderList", {

        onInit: function () {
            this._oCancelablePost = new BusyDialog({
                title: "대량 승인 처리",
                text: "선택된 주문을 일괄 처리하고 있습니다.",
                showCancelButton: true,
                cancelButtonText: "취소",
                close: this._onCancelBusy.bind(this)
            });
            this._oAbortController = null;
        },

        onRefreshTable: function () {
            var oTable = this.byId("orderTable");
            oTable.setBusyIndicatorDelay(0); // 즉시 표시
            oTable.setBusy(true);

            this._loadOrders()
                .then(function (aData) {
                    this.getView().getModel("orders").setProperty("/items", aData);
                    Log.info("주문 " + aData.length + "건 로드", "OrderList");
                }.bind(this))
                .catch(function (oError) {
                    Log.error("주문 로딩 실패: " + oError.message, null, "OrderList");
                    MessageBox.error("데이터를 불러오지 못했습니다.");
                })
                .finally(function () {
                    // finally 보장 — 성공/실패 무관하게 반드시 해제
                    oTable.setBusy(false);
                });
        },

        onBulkApprove: function () {
            this._oAbortController = new AbortController();
            this._oCancelablePost.open();

            fetch("/sap/opu/odata/sap/SO_SRV/BulkApprove", {
                method: "POST",
                signal: this._oAbortController.signal
            })
            .then(function (res) { return res.json(); })
            .then(function (oResult) {
                Log.info("일괄 승인 " + oResult.count + "건 성공");
            })
            .catch(function (oErr) {
                if (oErr.name !== "AbortError") {
                    Log.error("일괄 승인 실패", oErr);
                }
            })
            .finally(function () {
                this._oCancelablePost.close();
            }.bind(this));
        },

        _onCancelBusy: function () {
            if (this._oAbortController) {
                this._oAbortController.abort();
                Log.warning("사용자가 일괄 처리를 취소함");
            }
        },

        _loadOrders: function () {
            return new Promise(function (resolve, reject) {
                this.getView().getModel().read("/SalesOrderSet", {
                    success: function (oData) { resolve(oData.results); },
                    error: reject
                });
            }.bind(this));
        }
    });
});

두 가지 패턴에 주목하면 좋습니다. 첫째, .finally()에 해제 코드를 두면 try/catch 누락 사고를 원천 차단할 수 있습니다. 둘째, BusyDialog의 close 이벤트는 사용자가 취소 버튼을 누른 경우와 코드에서 close()를 호출한 경우 모두 발생합니다. 따라서 _onCancelBusy 안에서는 진짜 취소인지(AbortController 보유 여부 등) 판별이 필요합니다.

3단계 — 동시 호출 카운팅과 BusyHelper 추상화

실제 화면에서는 한 페이지에서 여러 비동기 호출이 동시에 일어납니다. 헤더 데이터, 아이템 목록, 첨부 파일을 병렬로 불러올 때 각자 setBusy(true/false)를 호출하면 한 호출이 먼저 끝나며 setBusy(false)를 부르고, 다른 호출이 아직 진행 중인데 차단이 풀려버리는 레이스 컨디션이 발생합니다. 이를 막는 표준 패턴은 참조 카운팅입니다.

sap.ui.define([
    "sap/ui/base/Object",
    "sap/m/BusyDialog",
    "sap/base/Log"
], function (BaseObject, BusyDialog, Log) {
    "use strict";

    return BaseObject.extend("com.acme.so.util.BusyHelper", {

        constructor: function (oOwnerControl) {
            this._iCount = 0;
            this._oControl = oOwnerControl; // null이면 전역 Dialog 사용
            this._oDialog = null;
            this._iMinShowMs = 300; // 너무 빨리 닫혀 깜빡임 방지
            this._iShownAt = 0;
        },

        acquire: function (sLabel) {
            this._iCount++;
            Log.debug("BusyHelper acquire(" + sLabel + ") count=" + this._iCount);
            if (this._iCount === 1) {
                this._show();
            }
        },

        release: function (sLabel) {
            this._iCount = Math.max(0, this._iCount - 1);
            Log.debug("BusyHelper release(" + sLabel + ") count=" + this._iCount);
            if (this._iCount === 0) {
                var iElapsed = Date.now() - this._iShownAt;
                var iWait = Math.max(0, this._iMinShowMs - iElapsed);
                setTimeout(this._hide.bind(this), iWait);
            }
        },

        wrap: function (sLabel, fnPromiseFactory) {
            this.acquire(sLabel);
            return fnPromiseFactory().finally(function () {
                this.release(sLabel);
            }.bind(this));
        },

        _show: function () {
            this._iShownAt = Date.now();
            if (this._oControl) {
                this._oControl.setBusyIndicatorDelay(0);
                this._oControl.setBusy(true);
            } else {
                if (!this._oDialog) {
                    this._oDialog = new BusyDialog({ title: "처리 중" });
                }
                this._oDialog.open();
            }
        },

        _hide: function () {
            if (this._iCount !== 0) { return; } // 사이에 다시 늘었으면 닫지 않음
            if (this._oControl) {
                this._oControl.setBusy(false);
            } else if (this._oDialog) {
                this._oDialog.close();
            }
        },

        destroy: function () {
            if (this._oDialog) { this._oDialog.destroy(); }
            this._oControl = null;
        }
    });
});

컨트롤러에서는 다음처럼 사용합니다.

onInit: function () {
    this._oBusy = new BusyHelper(this.byId("detailPanel"));
},

onLoadAll: function () {
    Promise.all([
        this._oBusy.wrap("header", this._loadHeader.bind(this)),
        this._oBusy.wrap("items",  this._loadItems.bind(this)),
        this._oBusy.wrap("attach", this._loadAttachments.bind(this))
    ]).catch(function (e) {
        sap.m.MessageBox.error("일부 데이터 로딩 실패");
    });
}

이 구조는 세 가지 이점을 줍니다. 첫째, 어떤 호출이든 카운터를 통해 해제 시점이 정확히 동기화됩니다. 둘째, 최소 표시 시간(_iMinShowMs)으로 화면 깜빡임을 줄입니다. 셋째, 컨트롤러가 BusyDialog/setBusy 중 무엇을 쓰는지 신경 쓸 필요가 없어집니다. 테스트 관점에서도 BusyHelper를 QUnit으로 단독 검증할 수 있어 유리합니다. 보안 측면에서는 BusyDialog가 떠 있는 동안에도 백엔드 응답에 포함된 사용자 입력은 반드시 이스케이프해야 하며, 다이얼로그 text에 외부 문자열을 그대로 바인딩하면 안 됩니다(텍스트 자체는 안전하나, HTML 렌더 컨트롤로 우회되는 경우 주의).

자주 만나는 사고와 해결 체크리스트

현장에서 가장 흔한 BusyDialog/setBusy 사고 사례를 FAQ 형식으로 정리합니다.

Q1. 화면이 영구히 잠겨서 새로고침해야 풀립니다.
setBusy(false) 또는 close()가 호출되지 않은 경로가 있다는 뜻입니다. 거의 100% 에러 콜백 누락이나 예외 throw 후 미처리입니다. 해결책은 try/finally 또는 Promise의 .finally()로 해제 코드를 분리하는 것입니다. Chrome DevTools에서 $0.getBusy()로 현재 상태를 디버깅할 수 있습니다.

Q2. setBusy(true)를 했는데 스피너가 안 보입니다.
기본 busyIndicatorDelay가 1000ms입니다. 응답이 그보다 빠르면 스피너 자체가 표시되지 않습니다. 사용자에게 "처리 중"임을 반드시 알려야 한다면 setBusyIndicatorDelay(0)으로 즉시 표시하거나, BusyDialog로 바꿉니다. 단, delay 0은 깜빡임을 유발할 수 있으니 BusyHelper처럼 최소 표시 시간을 둡니다.

Q3. BusyDialog가 여러 개 겹쳐 떠 있습니다.
이벤트 핸들러마다 new BusyDialog()를 만드는 코드가 원인입니다. 인스턴스 하나를 컨트롤러 멤버로 보관하고 재사용하세요. 또한 onExit에서 destroy()를 잊지 않아야 라우팅 전환 시 메모리 누수가 안 생깁니다.

Q4. BusyIndicator.show()BusyDialog.open()을 같이 썼더니 한쪽만 닫힙니다.
둘은 서로 별개의 오버레이라 카운팅이 독립입니다. 한 화면에서는 하나의 메커니즘만 사용하는 것이 권장됩니다. 전역 라우팅 진입에는 BusyIndicator, 사용자 트리거 작업에는 BusyDialog 식으로 역할을 분리하세요.

추가로 점검하면 좋은 항목입니다.

  • 네트워크 오류 시뮬레이션: DevTools Offline 모드에서 해제 누락 여부 확인
  • 접근성: BusyDialog의 title은 스크린리더가 읽으므로 의미 있는 문구로 작성
  • i18n: 다이얼로그 텍스트를 모델 바인딩({i18n>busySaving})으로 빼두기

여기서 더 나아가려면

이 글의 패턴을 익혔다면 다음 주제로 확장해 볼 수 있습니다. 첫째, sap.ui.core.routing.RouterrouteMatched 이벤트와 BusyIndicator를 결합한 페이지 전환 로딩 표시. 둘째, OData V4의 $batch 요청 단위로 BusyHelper를 묶어 N+1 호출 시 한 번만 차단하는 최적화. 셋째, Fiori Elements의 FlexibleColumnLayout에서 컬럼별 busy 처리. 넷째, MessageManager와 BusyDialog를 함께 써서 백엔드 메시지를 다이얼로그 닫힘 직후 자동 표시하는 UX. 다섯째, OPA5/QUnit으로 busy 상태 변화를 검증하는 자동화 테스트 작성.

관련 문서와 더 읽을 거리

댓글 0

아직 댓글이 없습니다.