이 글의 목적과 도달점
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.BusyIndicatorAPI는 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.json의 sap.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는 자식 컨트롤로 전파되지 않습니다. Page에 setBusy(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();
}
}
});
});
여기서 핵심은 success와 error 양쪽 모두에서 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.Router의 routeMatched 이벤트와 BusyIndicator를 결합한 페이지 전환 로딩 표시. 둘째, OData V4의 $batch 요청 단위로 BusyHelper를 묶어 N+1 호출 시 한 번만 차단하는 최적화. 셋째, Fiori Elements의 FlexibleColumnLayout에서 컬럼별 busy 처리. 넷째, MessageManager와 BusyDialog를 함께 써서 백엔드 메시지를 다이얼로그 닫힘 직후 자동 표시하는 UX. 다섯째, OPA5/QUnit으로 busy 상태 변화를 검증하는 자동화 테스트 작성.
관련 문서와 더 읽을 거리
- SAPUI5 API Reference — sap.m.BusyDialog
- SAPUI5 API Reference — sap.ui.core.BusyIndicator
- help.sap.com — SAP Fiori tools 가이드
- help.sap.com — UI5 Flexibility 및 컨트롤 라이프사이클
- help.sap.com — SAPUI5 개발자 가이드 (Async, Promise 패턴)
- SAP Fiori Design Guidelines — Busy Dialog
- SAP Fiori Design Guidelines — Loading States
댓글 0
아직 댓글이 없습니다.