📖 이 글이 답하는 질문
버튼을 눌렀는데 화면이 3초간 아무 반응이 없다면, 사용자는 앱이 멈췄다고 판단하고 버튼을 다시 누릅니다. 그 결과는 중복 요청, 이중 저장, 그리고 "앱이 느리다"는 평가입니다. SAPUI5(및 OpenUI5)에는 이 문제를 한 줄로 해결하는 setBusy가 있지만, 잘못 쓰면 오히려 화면이 영원히 잠기거나 깜빡거립니다. 이 글에서는 다음 질문에 답합니다.
setBusy(true)를 호출하면 내부에서 실제로 무슨 일이 일어나는가?setBusyIndicatorDelay의 기본값 1000ms는 왜 존재하고, 언제 바꿔야 하는가?- View 레벨과 Control 레벨 busy는 어떻게 구분해서 쓰는가?
- 에러가 나도, 중첩 호출이 겹쳐도 busy 상태가 꼬이지 않게 만드는 방법은?
📚 이 글을 보기 전에
UI5 MVC 구조(View/Controller)와 JavaScript의 Promise, async/await 문법을 대략 알고 있다고 가정합니다. OData 모델(sap.ui.model.odata.v2.ODataModel)로 데이터를 읽어본 경험이 있으면 2단계 예제가 더 쉽게 읽히지만, 없어도 따라갈 수 있는 난이도(beginner)입니다.
🔧 테스트 환경과 사용한 버전
- SAPUI5 1.120 LTS 기준으로 검증했습니다.
setBusy/setBusyIndicatorDelay는sap.ui.core.Control의 오래된 안정 API라 1.71 이후 어떤 버전에서도 동일하게 동작하는 것이 일반적입니다. - OpenUI5에서도 같은 API가 제공되므로 SAP BTP(Cloud Foundry), 온프레미스 Fiori Launchpad, 로컬 UI5 CLI(
ui5 serve) 어디서든 재현 가능합니다. - 백엔드 예제는 OData V2 서비스(구매요청
PurchaseRequestSet, 판매오더SalesOrderSet)를 가정하지만,fetch기반 REST 호출로 바꿔도 busy 처리 패턴은 동일합니다.
💡 핵심 개념 — Busy Indicator는 진동벨이다
식당에서 주문 후 진동벨을 받으면 "주문이 접수됐고, 기다리면 된다"는 확신이 생깁니다. 진동벨이 없으면 5분만 지나도 카운터에 가서 다시 주문하죠. UI5의 busy indicator가 정확히 이 진동벨 역할입니다.
setBusy(true)를 호출하면 UI5는 해당 컨트롤 위에 두 가지를 겹쳐 올립니다.
- 블록 레이어(block layer) — 반투명 오버레이가 컨트롤 영역을 덮어 클릭·키보드 입력을 차단합니다. 즉 busy 상태는 시각 효과이면서 동시에 이중 클릭 방지 장치이기도 합니다.
- 로컬 busy 애니메이션 — 회전하는 로딩 표시가 컨트롤 중앙에 나타납니다.
여기서 중요한 두 번째 축이 setBusyIndicatorDelay(ms)입니다. 기본값은 1000ms로, "busy를 켠 뒤 1초가 지나야 애니메이션을 그린다"는 뜻입니다. 왜냐하면 200ms 만에 끝나는 요청에까지 로딩 표시를 그리면 화면이 번쩍이는 깜빡임(flicker)만 남기 때문입니다. 단, 블록 레이어는 delay와 무관하게 즉시 적용되므로 입력 차단은 바로 시작됩니다. 정리하면:
| 구분 | 적용 시점 | 역할 |
|---|---|---|
| 블록 레이어 | setBusy(true) 즉시 | 입력 차단, 이중 제출 방지 |
| 로딩 애니메이션 | delay(기본 1초) 경과 후 | "기다리는 중" 시각 피드백 |
적용 범위도 선택할 수 있습니다. View 레벨(this.getView().setBusy(true))은 화면 전체를 잠그므로 초기 로딩이나 저장 트랜잭션에 적합하고, Control 레벨(예: 테이블에만 oTable.setBusy(true))은 나머지 화면은 계속 조작하게 두면서 갱신 중인 영역만 표시할 때 적합합니다. 참고로 sap.m.BusyDialog(모달 대화상자)나 sap.m.BusyIndicator(독립 컨트롤)도 있지만, 대부분의 화면에서는 컨트롤 내장 setBusy가 가장 가볍고 자연스러운 선택으로 권장됩니다.
💻 실전 예제 3단계
1단계 — 기본: View 전체를 잠그고 목록 읽기
구매요청 목록을 처음 여는 화면입니다. 초기 로딩이므로 View 전체를 busy로 잠급니다.
// PurchaseRequestList.controller.js — 최소 형태
onInit: function () {
var oView = this.getView();
var oODataModel = this.getOwnerComponent().getModel();
oView.setBusy(true); // 입력 차단은 이 순간부터
oODataModel.read("/PurchaseRequestSet", {
urlParameters: { "$top": "100", "$orderby": "CreatedAt desc" },
success: function (oData) {
oView.getModel("view").setProperty("/requests", oData.results);
oView.setBusy(false); // 성공 경로에서 해제
},
error: function () {
oView.setBusy(false); // 실패 경로에서도 반드시 해제
}
});
}
핵심은 success와 error 양쪽 모두에서 setBusy(false)를 호출한다는 점입니다. 초심자 실패 사례의 90%는 성공 콜백에만 해제를 넣어, 네트워크 오류 한 번에 화면이 영구히 잠기는 패턴입니다.
2단계 — 실무: try/finally + 에러 로깅 + 부분 busy
콜백 두 곳에 해제 코드를 중복하는 대신, Promise로 감싸고 finally에서 한 번만 해제합니다. 판매오더 테이블을 새로고침하는 시나리오로, 이번엔 테이블에만 busy를 겁니다. 사용자가 필터 바는 계속 조작할 수 있어야 하기 때문입니다.
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/m/MessageBox",
"sap/base/Log"
], function (Controller, MessageBox, Log) {
"use strict";
return Controller.extend("com.acme.orders.controller.SalesOrderList", {
onInit: function () {
// 새로고침은 짧게 끝날 때가 많으므로 delay를 기본 1000ms → 300ms로 조정
this.byId("salesOrderTable").setBusyIndicatorDelay(300);
},
onRefreshOrders: async function () {
var oTable = this.byId("salesOrderTable");
oTable.setBusy(true);
try {
var oData = await this._readOData("/SalesOrderSet", {
"$filter": "LifecycleStatus eq 'N'"
});
this.getView().getModel("view")
.setProperty("/orders", oData.results);
} catch (oError) {
Log.error("SalesOrderSet 조회 실패", oError.message,
"com.acme.orders.SalesOrderList");
MessageBox.error("판매오더를 불러오지 못했습니다. 잠시 후 다시 시도하세요.");
} finally {
oTable.setBusy(false); // 성공/실패/예외 어떤 경로든 단 한 곳에서 해제
}
},
_readOData: function (sPath, mUrlParameters) {
var oModel = this.getOwnerComponent().getModel();
return new Promise(function (resolve, reject) {
oModel.read(sPath, {
urlParameters: mUrlParameters,
success: resolve,
error: reject
});
});
}
});
});
이 구조의 장점은 세 가지입니다. (1) 해제 지점이 finally 한 곳이라 누락이 원천 차단되고, (2) sap/base/Log로 실패 원인이 남으며, (3) delay 300ms 덕분에 캐시에서 즉시 응답되는 경우엔 애니메이션이 아예 그려지지 않아 깜빡임이 없습니다.
3단계 — 프로덕션: 중첩 호출 카운터와 늦은 응답 방어
실무에서는 검색어 입력마다 조회가 나가거나, 저장과 재조회가 겹치는 등 비동기 호출이 중첩됩니다. 이때 먼저 끝난 호출이 setBusy(false)를 호출해 버리면, 아직 실행 중인 두 번째 호출이 있는데도 로딩 표시가 사라집니다. 해결책은 참조 카운터입니다. BaseController에 헬퍼로 만들어 두면 팀 전체가 재사용할 수 있습니다.
// BaseController.js — busy 카운터 헬퍼
withBusy: async function (oControl, fnTask) {
this._mBusyCount = this._mBusyCount || {};
var sId = oControl.getId();
this._mBusyCount[sId] = (this._mBusyCount[sId] || 0) + 1;
if (this._mBusyCount[sId] === 1) {
oControl.setBusy(true); // 첫 진입에서만 ON
}
try {
return await fnTask();
} finally {
if (--this._mBusyCount[sId] === 0) {
oControl.setBusy(false); // 마지막 호출이 끝날 때만 OFF
}
}
}
// 사용 예 — 검색: 늦게 도착한 응답이 최신 결과를 덮어쓰지 않도록 토큰 방어 추가
onSearch: function (oEvent) {
var sQuery = oEvent.getParameter("query");
var oTable = this.byId("salesOrderTable");
var iToken = (this._iSearchToken = (this._iSearchToken || 0) + 1);
this.withBusy(oTable, async () => {
var oData = await this._readOData("/SalesOrderSet", {
"$filter": "substringof('" + encodeURIComponent(sQuery) + "', CustomerName)"
});
if (iToken !== this._iSearchToken) {
return; // 더 최신 검색이 이미 시작됨 → 낡은 응답은 버린다
}
this.getView().getModel("view").setProperty("/orders", oData.results);
});
}
프로덕션 관점의 마무리 세 가지입니다. 첫째, 성능 — busy는 리렌더링을 유발하므로 행 단위 같은 잘게 쪼갠 컨트롤 수십 개에 개별로 걸지 말고, 논리 단위(테이블·패널·View) 하나에 거는 편이 좋습니다. 둘째, 테스트 — OPA5/QUnit에서 oControl.getBusy()를 단언(assert)하면 "요청 중 잠금 → 응답 후 해제" 흐름을 자동 검증할 수 있습니다. 셋째, 보안 부수 효과 — 저장 버튼 구간을 busy로 잠그면 이중 제출로 인한 중복 전표 생성을 UI 단에서 1차 방어하게 됩니다(물론 백엔드 멱등성 처리는 별도로 필요합니다).
⚠️ 자주 만나는 함정 (삽질 노트)
- Q1. 에러 후 화면이 영원히 잠겼어요. — 성공 콜백에만
setBusy(false)를 넣은 경우입니다. 반드시 Promise +try/finally로 해제 지점을 단일화하세요. 콜백 스타일을 유지해야 한다면error와success양쪽에 해제를 넣되, 코드 리뷰 체크리스트에 항목으로 올려두는 것을 권장합니다. - Q2. 로딩 표시가 번쩍하고 사라져 오히려 지저분해요. —
setBusyIndicatorDelay(0)을 전역처럼 남발한 경우입니다. delay 0은 수 초 이상 확실히 걸리는 작업(파일 업로드, 대량 저장)에만 쓰고, 일반 조회는 기본 1000ms 또는 300~500ms가 무난합니다. 애니메이션이 늦게 떠도 입력 차단은 즉시 걸린다는 점을 기억하세요. - Q3. 두 개 요청 중 하나만 끝났는데 로딩이 사라집니다. — 중첩 호출에서 먼저 끝난 쪽이 busy를 꺼버린 경우입니다. 3단계의 카운터 패턴(
withBusy)으로 마지막 호출 종료 시에만 끄도록 하세요. - Q4.
this.byId(...)가 undefined라 setBusy에서 죽어요. —onInit시점에 View 렌더링이 끝나지 않았거나 XML의 id 오타입니다. 초기 로딩이라면 개별 컨트롤 대신this.getView().setBusy(true)가 안전합니다. - Q5. busy를 걸었는데 표시가 전혀 안 보여요. — 해당 컨트롤 높이가 0이거나 아직 DOM에 없는 경우가 대부분입니다. 부모 컨테이너(Page, Panel)에 걸어 확인해 보세요.
🚀 더 파볼 주제
- OData V4 +
$$updateGroupId— V4 리스트 바인딩은dataRequested/dataReceived이벤트와 묶어 busy를 자동화하기 좋습니다. - sap.m.BusyDialog — 취소 버튼이 필요한 장시간 작업(대량 승인 처리 등)에 적합한 모달 방식.
- Skeleton/Placeholder 패턴 — Fiori 3에서는 목록 초기 로딩에 스피너 대신 자리표시자 UI를 쓰는 흐름도 일반적입니다.
- OPA5 통합 테스트 — busy 상태 전환을 자동 검증해 회귀를 막는 방법.
📚 더 읽어볼 자료
- SAPUI5 제품 도움말 (help.sap.com)
- Performance: Speed Up Your App — 로딩 UX·성능 권장 사항 (help.sap.com)
- OData V2 Model 사용 가이드 (help.sap.com)
- sap.ui.core.Control API — setBusy / setBusyIndicatorDelay (ui5.sap.com)
- sap.m.BusyDialog API (ui5.sap.com)
- SAP Fiori Design Guidelines — Busy Indicator
핵심 한 줄
busy는 켜는 것보다 끄는 설계가 전부다 — try/finally로 해제를 단일화하고, delay 기본값을 존중하고, 중첩 호출엔 카운터를 써라.
댓글 0
아직 댓글이 없습니다.