개요 — window 전역 변수와 결별하기
UI5 화면을 처음 만들 때 가장 흔한 실수 중 하나가 window.currentSalesOrderId = 4711; 같은 코드입니다. 빠르게 동작하기 때문에 유혹적이지만, 이 한 줄은 향후 수개월간 디버깅 지옥을 예약하는 행위와 같습니다. 이 글에서는 UI5(SAPUI5 1.120, OpenUI5 1.121 기준) 환경에서 글로벌 스코프 오염을 피하는 네 가지 정석 패턴을 SalesOrder 시나리오로 정리합니다.
- window/전역 누수가 일으키는 실제 버그 패턴 식별
- Controller 인스턴스 멤버(
this._xxx)와 생명주기 매칭 - JSONModel을 활용한 앱 단위 상태 공유
sap.ui.define클로저 스코프 변수와 EventBus 활용- 리팩터링 체크리스트로 레거시 코드 정리
이 글을 보기 전 알아두면 좋은 것
JavaScript 클로저와 스코프 개념, AMD 스타일 모듈 시스템(sap.ui.define), MVC 패턴에서 Controller가 View 인스턴스마다 새로 생성된다는 점, JSONModel/Binding 기초 정도면 충분합니다. ES6 let/const와 var의 호이스팅 차이를 알고 있다면 본문 설명을 훨씬 빠르게 이해할 수 있습니다.
실습 환경과 도구 준비
이 글의 코드는 다음 환경에서 검증되었습니다. SAP BTP, ABAP 환경 또는 BAS(Business Application Studio) 어디서나 동일하게 동작합니다.
- SAPUI5 런타임: 1.120.x LTS 또는 1.121.x
- Node.js: 18.x 이상 (ui5-tooling 사용 시)
- UI5 CLI:
@ui5/cli3.x - ESLint:
eslint-plugin-no-restricted-globals권장 - 브라우저: Chromium 계열 (DevTools Memory 탭으로 window 누수 확인)
ESLint 설정에 "no-restricted-globals": ["error", "currentUser", "orderCache"] 같은 규칙을 추가하면 의도치 않은 전역 할당을 빌드 시점에 차단할 수 있어 일반적으로 권장됩니다.
핵심 개념 — 왜 window가 위험한가
브라우저 window 객체는 모든 스크립트가 공유하는 거대한 칠판입니다. 누구나 쓸 수 있고, 누구나 지울 수 있고, 누가 마지막에 썼는지 기록되지 않습니다. UI5처럼 여러 컴포넌트가 동시에 떠 있는 SPA에서 이 칠판을 상태 저장소로 쓰면 다음과 같은 문제가 연쇄적으로 발생합니다.
- 이름 충돌: A 화면이
window.order = {...}로 쓴 변수를 B 라이브러리가 동명으로 덮어씁니다. 두 코드는 서로의 존재조차 모릅니다. - 생명주기 불일치: View가 destroy되어도 window 변수는 살아남아 메모리 누수를 유발합니다.
- 테스트 격리 실패: QUnit 테스트 케이스 간 상태가 전이되어 "혼자 돌리면 통과, 같이 돌리면 실패"하는 플레이키 테스트가 양산됩니다.
- 타입 추론 불가: TypeScript/IDE 자동완성이 window 확장 속성을 추적하지 못해 리팩터링 안전망이 사라집니다.
비유하자면 window는 사무실 한가운데 놓인 공용 화이트보드입니다. 부서마다 자기 자리(모듈 스코프)에 메모지를 붙이는 대신 매번 화이트보드에 갈겨쓰면, 결국 누가 어떤 회의 결과를 적었는지 아무도 모르게 됩니다.
UI5는 이 문제에 대응하기 위해 네 가지 레이어를 제공합니다.
- Controller 멤버 변수: View 한 화면 안에서만 의미 있는 임시 값 (예: 현재 선택된 행 ID)
- JSONModel: 바인딩 가능한 앱/뷰 단위 상태 (예: 장바구니, 사용자 프로필)
- 모듈 클로저 변수: 모듈이 외부로 노출하지 않는 헬퍼 상태 (예: 캐시, 디바운스 타이머)
- EventBus: 컴포넌트 간 느슨한 결합 통신 (예: 결제 완료 알림)
실전 코드 1단계 — Controller 멤버로 바꾸기
가장 흔한 안티패턴은 SalesOrder ID를 window에 박아두는 것입니다. 다음 코드를 보면 문제가 한눈에 들어옵니다.
// 안티패턴: window 오염
sap.ui.define([
"sap/ui/core/mvc/Controller"
], function (Controller) {
"use strict";
return Controller.extend("com.acme.sales.controller.OrderDetail", {
onInit: function () {
// 다른 화면도 같은 이름을 쓰면 즉시 충돌
window.selectedOrderId = null;
},
onRowSelect: function (oEvent) {
window.selectedOrderId = oEvent.getSource().getBindingContext().getProperty("OrderId");
},
onConfirm: function () {
console.log("확정:", window.selectedOrderId);
}
});
});
이 코드를 Controller 인스턴스 멤버로 옮기면, 한 화면이 여러 번 열려도 각자 자기 상태만 가지게 됩니다. 관례적으로 외부에 노출하지 않을 내부 멤버는 밑줄 접두어(_)를 붙입니다.
sap.ui.define([
"sap/ui/core/mvc/Controller"
], function (Controller) {
"use strict";
return Controller.extend("com.acme.sales.controller.OrderDetail", {
onInit: function () {
this._selectedOrderId = null;
},
onRowSelect: function (oEvent) {
this._selectedOrderId = oEvent.getSource()
.getBindingContext()
.getProperty("OrderId");
},
onConfirm: function () {
console.log("확정:", this._selectedOrderId);
},
onExit: function () {
// View가 destroy되면 자동으로 함께 회수됨
this._selectedOrderId = null;
}
});
});
Controller 인스턴스는 View가 destroy되는 순간 함께 사라집니다. 즉, 메모리 정리를 별도로 신경 쓸 필요가 거의 없습니다.
실전 코드 2단계 — JSONModel로 화면 간 데이터 공유
여러 View가 같은 데이터를 봐야 한다면 Controller 멤버로는 부족합니다. 예를 들어 SalesOrder 헤더 화면과 라인 아이템 화면이 같은 주문 정보를 공유해야 하는 경우, Component 레벨에 JSONModel을 띄우는 것이 일반적으로 권장됩니다.
// webapp/Component.js
sap.ui.define([
"sap/ui/core/UIComponent",
"sap/ui/model/json/JSONModel"
], function (UIComponent, JSONModel) {
"use strict";
return UIComponent.extend("com.acme.sales.Component", {
metadata: { manifest: "json" },
init: function () {
UIComponent.prototype.init.apply(this, arguments);
var oOrderState = new JSONModel({
currentOrder: null,
lineItems: [],
totals: { net: 0, tax: 0, gross: 0 },
ui: { busy: false, lastError: null }
});
// 두 번째 인자가 모델 이름. 비워두면 default 모델
this.setModel(oOrderState, "orderState");
// 로깅
sap.ui.require(["sap/base/Log"], function (Log) {
Log.info("orderState model initialized", null, "OrderApp");
});
}
});
});
이제 어느 Controller에서든 동일한 모델에 접근할 수 있습니다. 에러 처리와 로깅도 한 곳에 모아두면 추적이 쉬워집니다.
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/base/Log"
], function (Controller, Log) {
"use strict";
return Controller.extend("com.acme.sales.controller.OrderHeader", {
onLoadOrder: function (sOrderId) {
var oModel = this.getOwnerComponent().getModel("orderState");
oModel.setProperty("/ui/busy", true);
jQuery.ajax({
url: "/sap/opu/odata/sap/ZSALES_SRV/OrderSet('" + sOrderId + "')",
success: function (oData) {
oModel.setProperty("/currentOrder", oData);
oModel.setProperty("/ui/busy", false);
Log.info("Order " + sOrderId + " loaded", null, "OrderHeader");
},
error: function (oXhr) {
oModel.setProperty("/ui/busy", false);
oModel.setProperty("/ui/lastError", oXhr.statusText);
Log.error("Order load failed: " + oXhr.statusText, null, "OrderHeader");
}
});
}
});
});
View XML에서는 {orderState>/currentOrder/CustomerName}처럼 바인딩만 걸어두면 데이터가 갱신되는 순간 모든 화면이 자동으로 리렌더링됩니다.
실전 코드 3단계 — 모듈 클로저와 EventBus, 그리고 테스트
화면 간 결합을 더 느슨하게 가져가고 싶다면 모듈 클로저 변수와 EventBus를 함께 사용합니다. 다음은 SalesOrder 캐시를 모듈 스코프에 숨기고, 결제 완료 이벤트를 발행하는 프로덕션급 예제입니다.
// webapp/service/OrderCacheService.js
sap.ui.define([
"sap/ui/base/Object",
"sap/base/Log"
], function (BaseObject, Log) {
"use strict";
// 모듈 스코프 — 외부에서 절대 접근 불가
var _cache = new Map();
var _ttlMs = 60 * 1000;
function _isFresh(entry) {
return entry && (Date.now() - entry.ts) < _ttlMs;
}
return BaseObject.extend("com.acme.sales.service.OrderCacheService", {
get: function (sOrderId) {
var entry = _cache.get(sOrderId);
if (_isFresh(entry)) {
Log.debug("cache hit " + sOrderId, null, "OrderCache");
return entry.value;
}
return null;
},
put: function (sOrderId, oValue) {
_cache.set(sOrderId, { value: oValue, ts: Date.now() });
},
invalidate: function (sOrderId) {
_cache.delete(sOrderId);
},
clearAll: function () {
_cache.clear();
}
});
});
_cache는 sap.ui.define의 콜백 함수 안에 선언되어 있어 모듈 외부에서는 절대로 손댈 수 없습니다. window.orderCache처럼 전역 노출하지 않으면서도 모듈 내부에서는 자유롭게 공유 가능한 안전한 스토리지가 됩니다.
이어서 결제 완료 시 다른 화면(예: 대시보드의 미결제 카운터)을 갱신하려면 EventBus가 깔끔합니다.
// 결제 화면 — 이벤트 발행
sap.ui.define([
"sap/ui/core/mvc/Controller"
], function (Controller) {
"use strict";
return Controller.extend("com.acme.sales.controller.Payment", {
onPaymentSuccess: function (sOrderId, dAmount) {
var oBus = this.getOwnerComponent().getEventBus();
oBus.publish("sales", "orderPaid", {
orderId: sOrderId,
amount: dAmount,
at: new Date().toISOString()
});
}
});
});
// 대시보드 — 이벤트 구독
sap.ui.define([
"sap/ui/core/mvc/Controller"
], function (Controller) {
"use strict";
return Controller.extend("com.acme.sales.controller.Dashboard", {
onInit: function () {
var oBus = this.getOwnerComponent().getEventBus();
this._fnHandler = this._onOrderPaid.bind(this);
oBus.subscribe("sales", "orderPaid", this._fnHandler);
},
_onOrderPaid: function (sChannel, sEvent, oData) {
var oModel = this.getView().getModel("dashboard");
var nPending = oModel.getProperty("/pendingCount");
oModel.setProperty("/pendingCount", nPending - 1);
},
onExit: function () {
// 누수 방지 — 반드시 unsubscribe
var oBus = this.getOwnerComponent().getEventBus();
oBus.unsubscribe("sales", "orderPaid", this._fnHandler);
}
});
});
QUnit 테스트에서도 이 구조는 빛을 발합니다. OrderCacheService를 매 테스트마다 새로 require하지 않아도, clearAll()만 호출하면 상태가 초기화되어 테스트 격리가 보장됩니다.
QUnit.module("OrderCacheService", {
beforeEach: function () {
var that = this;
return new Promise(function (resolve) {
sap.ui.require(["com/acme/sales/service/OrderCacheService"], function (Svc) {
that.oSvc = new Svc();
that.oSvc.clearAll();
resolve();
});
});
}
});
QUnit.test("put/get 캐시 동작", function (assert) {
this.oSvc.put("4711", { net: 100 });
assert.deepEqual(this.oSvc.get("4711"), { net: 100 });
});
흔한 실수와 트러블슈팅 FAQ
Q1. "this._xxx도 결국 인스턴스 변수인데, 왜 window보다 안전한가요?"
Controller 인스턴스는 View와 생명주기가 묶여 있어 View destroy 시 GC 대상이 됩니다. 반면 window 속성은 명시적으로 delete하지 않는 한 페이지를 떠날 때까지 살아남습니다. 또한 인스턴스가 다르면 같은 이름이라도 충돌하지 않습니다.
Q2. "JSONModel을 전역에 두면 사실상 window랑 같은 거 아닌가요?"
다릅니다. JSONModel은 이름이 있는 컨테이너이고, 접근 경로(orderState>/currentOrder)가 명시되며, 바인딩을 통해 변경 추적이 가능합니다. 무엇보다 Component가 destroy되면 모델도 함께 정리됩니다. 디버깅 시 "누가 이 값을 바꿨나"를 모델의 attachPropertyChange로 추적할 수도 있습니다.
Q3. "기존 레거시 코드에 window가 100군데 박혀있는데 어떻게 옮기나요?"
단계적 마이그레이션을 권장합니다. ① ESLint no-restricted-globals로 신규 발생을 차단 ② grep으로 window.xxx 패턴 목록화 ③ 사용 범위가 한 Controller 내부면 this._로, 두 화면 이상이면 JSONModel로, 알림성이면 EventBus로 분류 ④ 각 항목별 PR을 작게 쪼개 회귀 위험을 줄입니다.
Q4. "EventBus 구독을 onExit에서 해제하지 않으면 어떻게 되나요?"
View가 사라져도 콜백이 EventBus 내부 핸들러 목록에 남아 destroy된 Controller를 참조합니다. 새 화면을 열 때마다 누적되어 메모리 누수와 함께 "이미 사라진 화면이 이벤트에 반응한다"는 유령 현상이 발생합니다. 반드시 unsubscribe를 짝지어 호출해야 합니다.
Q5. "개발 중에는 window에 두고 콘솔에서 만져보고 싶은데요?"
디버깅 편의를 위해서라면 sap.ui.getCore().byId(...) 또는 Component를 통해 접근하는 습관을 들이는 것이 좋습니다. 정 필요하면 if (location.hostname === "localhost") window.__debug = this;처럼 환경 가드를 걸고, 프로덕션 빌드에서는 제거되도록 하세요.
이어서 살펴보면 좋은 주제
전역 변수 회피를 한 단계 더 발전시키려면 다음 주제를 차례로 익히면 좋습니다.
- TypeScript + UI5: 모듈 스코프에 타입이 붙으면 리팩터링 안전망이 비약적으로 강해집니다.
- Flexible Programming Model의 Extension API: Fiori Elements 확장 시 글로벌 핸들러 대신 컨트롤러 확장 패턴을 쓰는 방법.
- Manifest 기반 모델 선언: Component.js에 직접 모델을 만들지 않고
manifest.json의sap.ui5.models에 선언하는 방식. - OPA5 통합 테스트: EventBus와 JSONModel 기반 코드의 종단 간 검증.
더 읽을거리
- SAPUI5 Demo Kit — Models (help.sap.com / sapui5.hana.ondemand.com)
- SAPUI5 Demo Kit — JSONModel API Reference
- SAPUI5 Demo Kit — EventBus 사용 가이드
- help.sap.com — UI5 Flexibility Services
- help.sap.com — SAP Fiori Elements 확장 가이드
- help.sap.com — SAPUI5 Developer Guide
- OpenUI5 공식 사이트
- ESLint — no-restricted-globals 규칙
댓글 0
아직 댓글이 없습니다.