UI5

UI5 개발자 90%가 놓치는 EventBus 패턴 #shorts #SAP #UI5

왜 EventBus가 필요한가

대형 SAP Fiori 애플리케이션은 보통 여러 개의 Component로 쪼개져 있습니다. 주문 목록을 보여주는 컴포넌트, 주문 상세를 띄우는 컴포넌트, 사이드 알림 패널을 다루는 컴포넌트가 서로 다른 페이지·다른 라이프사이클을 가진 상태에서 "방금 사용자가 새 주문을 선택했어요"라는 한 가지 사실을 동시에 알아야 하는 상황이 자주 발생합니다. 이때 컨트롤러 간 참조를 직접 주입하면 결합도가 폭발하고, 모델을 공유해도 "변경 시점" 그 자체를 통지하기는 어렵습니다.

이 글에서는 SAPUI5/OpenUI5가 기본 제공하는 sap.ui.core.EventBus를 사용해 컴포넌트 간 결합도를 낮추면서 한 줄로 publish/subscribe 통신을 구현하는 방법을 단계별로 살펴봅니다. 끝까지 읽으면 다음을 직접 만들 수 있습니다.

  • 채널(channel)과 이벤트(event)를 기준으로 한 발행/구독 구조 설계
  • 컴포넌트별 EventBus 인스턴스와 글로벌 EventBus의 차이 이해
  • 구독 해제(unsubscribe)와 메모리 누수 방지 패턴
  • 여러 컴포넌트가 동일 이벤트를 받는 fan-out 시나리오 구현
  • QUnit/OPA5 기반 EventBus 테스트 및 운영 환경 디버깅 팁

이 글을 따라오기 전 알고 있어야 하는 것

SAPUI5 1.71 LTS 이상에 익숙해야 하며, Component.js의 라이프사이클(init, exit), Controller.onInit/onExit, JSON/OData 모델 바인딩의 기본 개념을 알고 있다고 가정합니다. manifest.json에서 componentUsages를 선언해 자식 컴포넌트를 끼워 넣어 본 경험이 있으면 4단계 이후의 코드를 훨씬 쉽게 따라올 수 있습니다.

실습 환경 및 사전 준비

이 글의 예제는 다음 조합에서 검증된 패턴을 사용합니다.

  • SAPUI5 1.108 / 1.120 LTS (OpenUI5 동일 버전 호환)
  • SAP Business Application Studio 또는 VS Code + Fiori Tools
  • Node.js 18 LTS, @sap/ux-ui5-tooling
  • 로컬 mock server (OData v2/v4 어느 쪽이든 무관)
  • SAP BTP Launchpad 또는 Fiori Launchpad sandbox(flpSandbox.html)

EventBus는 UI5 core가 항상 로드하는 싱글톤이므로 별도 라이브러리 의존성은 필요하지 않습니다. 다만 컴포넌트별 EventBus를 명시적으로 다루려면 UI5 1.88+ 에서 추가된 Component#getEventBus() API를 활용하는 편이 일반적으로 권장됩니다.

핵심 개념: 채널·이벤트·핸들러 삼각형

EventBus는 사내 우편함을 떠올리면 이해가 빠릅니다. "회계팀"이라는 채널이 있고, 그 안에 "지출결의 도착", "마감일 변경"이라는 이벤트 종류가 있습니다. 각 이벤트가 도착할 때 누구에게 전달할지는 사전에 구독 신청한 핸들러 목록을 보고 결정합니다. 발신자는 누가 듣는지 신경 쓸 필요가 없고, 수신자는 누가 보냈는지 알 필요가 없습니다. 이 느슨한 결합이 EventBus의 본질입니다.

"하나의 모듈이 다른 모듈을 직접 호출한다"는 호출 그래프 대신, "관심사가 같은 모듈들이 동일한 채널에 모인다"는 관심사 그래프로 사고를 바꿔야 EventBus 설계가 깔끔해집니다.

UI5에는 두 종류의 EventBus가 존재합니다.

  1. 글로벌 EventBussap.ui.getCore().getEventBus(). 전체 애플리케이션에서 단 하나만 존재하며, 모든 컴포넌트가 동일한 인스턴스를 봅니다. 빠르지만 namespace 충돌 위험이 크므로 채널명에 도메인 prefix를 붙이는 컨벤션이 일반적으로 권장됩니다.
  2. 컴포넌트 EventBusthis.getOwnerComponent().getEventBus(). 각 컴포넌트 자체가 보유한 인스턴스로, 부모-자식 컴포넌트 간 통신에 적합합니다. 컴포넌트가 destroy되면 같이 정리됩니다.

채널명은 com.acme.salesorder처럼 reverse-DNS 스타일을, 이벤트명은 orderSelected처럼 동사형 과거 시제를 사용하는 패턴이 유지보수에 유리합니다.

1단계: 가장 단순한 발행과 구독

SalesOrder 목록 컨트롤러에서 행이 선택될 때 ProductDetail 컨트롤러에 알리는 최소 구현부터 시작합니다. 두 컨트롤러는 같은 컴포넌트 안에 있고 부모-자식 관계가 아니라고 가정합니다.

// webapp/controller/SalesOrderList.controller.js
sap.ui.define([
  "sap/ui/core/mvc/Controller"
], function (Controller) {
  "use strict";

  return Controller.extend("com.acme.sales.controller.SalesOrderList", {

    onOrderPress: function (oEvent) {
      var oCtx = oEvent.getSource().getBindingContext();
      var oPayload = {
        orderId: oCtx.getProperty("SalesOrderID"),
        customer: oCtx.getProperty("CustomerName"),
        ts: Date.now()
      };

      // 한 줄 발행
      sap.ui.getCore().getEventBus()
        .publish("com.acme.sales", "orderSelected", oPayload);
    }
  });
});
// webapp/controller/ProductDetail.controller.js
sap.ui.define([
  "sap/ui/core/mvc/Controller"
], function (Controller) {
  "use strict";

  return Controller.extend("com.acme.sales.controller.ProductDetail", {

    onInit: function () {
      this._oBus = sap.ui.getCore().getEventBus();
      this._oBus.subscribe(
        "com.acme.sales", "orderSelected", this._onOrderSelected, this);
    },

    _onOrderSelected: function (sChannel, sEvent, oData) {
      this.getView().getModel("ui").setProperty("/selectedOrder", oData);
    },

    onExit: function () {
      this._oBus.unsubscribe(
        "com.acme.sales", "orderSelected", this._onOrderSelected, this);
    }
  });
});

발행 측은 publish 한 줄, 구독 측은 subscribe 한 줄이면 끝납니다. 핸들러 시그니처가 항상 (channelId, eventId, data) 세 개라는 점만 외워두면 됩니다. 네 번째 인자로 컨텍스트(this)를 넘기지 않으면 핸들러 내부에서 this.getView()가 동작하지 않으니 주의가 필요합니다.

2단계: 컴포넌트 EventBus와 fan-out 시나리오

실무에서는 한 부모 셸이 여러 자식 컴포넌트(FilterBar, DataTable, KpiTile)를 띄우는 구조가 흔합니다. FilterBar에서 일자 범위가 바뀌면 DataTable과 KpiTile이 동시에 반응해야 합니다. 글로벌 EventBus를 쓰면 다른 페이지의 동일 이름 이벤트와 충돌할 수 있으므로, 부모 컴포넌트가 자체 EventBus를 노출해 자식들이 공유하는 패턴이 일반적으로 권장됩니다.

// webapp/Component.js (parent shell)
sap.ui.define([
  "sap/ui/core/UIComponent",
  "sap/base/Log"
], function (UIComponent, Log) {
  "use strict";

  return UIComponent.extend("com.acme.salesdash.Component", {
    metadata: { manifest: "json" },

    init: function () {
      UIComponent.prototype.init.apply(this, arguments);

      var oBus = this.getEventBus(); // 컴포넌트 전용 인스턴스
      // 진단용 wildcard 구독 (개발 모드에서만)
      if (Log.getLevel() >= Log.Level.DEBUG) {
        oBus.subscribe("com.acme.salesdash", function (ch, ev, data) {
          Log.debug("[EventBus] " + ch + "/" + ev, JSON.stringify(data));
        });
      }
    },

    getSharedBus: function () {
      return this.getEventBus();
    }
  });
});
// 자식 컴포넌트 FilterBar 컨트롤러
onApplyFilter: function () {
  var oOwner = this.getOwnerComponent();
  // 부모 컴포넌트가 componentUsages로 주입된 경우
  var oParent = oOwner.oContainer
    && oOwner.oContainer.getParent()
    && oOwner.oContainer.getParent()._oOwnerComponent;

  var oBus = (oParent && oParent.getSharedBus())
              || sap.ui.getCore().getEventBus();

  oBus.publish("com.acme.salesdash", "filterChanged", {
    dateFrom: this.byId("dpFrom").getValue(),
    dateTo:   this.byId("dpTo").getValue(),
    region:   this.byId("cbRegion").getSelectedKey()
  });
}

DataTable 컴포넌트와 KpiTile 컴포넌트는 동일한 채널·이벤트를 각자 구독하기만 하면 됩니다. EventBus 한 번 publish로 N개의 구독자가 동시에 핸들러를 실행하는 fan-out이 자연스럽게 성립합니다. 핸들러 내부에서 발생할 수 있는 예외는 EventBus 자체가 swallow하지 않으므로, 운영 환경에서는 항상 try/catch와 sap.base.Log.error로 감싸는 편이 안전합니다.

_onFilterChanged: function (sCh, sEv, oData) {
  try {
    var oBinding = this.byId("tblOrders").getBinding("items");
    var aFilters = [];
    if (oData.region) {
      aFilters.push(new sap.ui.model.Filter("Region",
        sap.ui.model.FilterOperator.EQ, oData.region));
    }
    oBinding.filter(aFilters);
  } catch (e) {
    sap.base.Log.error("filterChanged handler failed", e, "DataTable");
  }
}

3단계: 운영 환경을 위한 강화 패턴

여기까지가 동작하는 코드라면, 운영 단계에서는 다음 네 가지를 추가로 챙겨야 장기적으로 문제가 줄어듭니다.

(1) 채널 상수화 — 채널/이벤트 문자열을 하드코딩하면 오타가 곧 버그가 됩니다. 별도 모듈로 분리해 import해 쓰는 패턴이 관리에 유리합니다.

// webapp/util/BusTopics.js
sap.ui.define([], function () {
  "use strict";
  return Object.freeze({
    CH_SALES: "com.acme.salesdash",
    EV_FILTER_CHANGED: "filterChanged",
    EV_ORDER_SELECTED: "orderSelected",
    EV_NAV_REQUESTED:  "navigationRequested"
  });
});

(2) 라이프사이클 안전한 구독 헬퍼 — 컨트롤러마다 onInit/onExit를 매번 작성하면 unsubscribe를 빼먹기 쉽습니다. BaseController에 헬퍼를 만들면 누수 위험이 줄어듭니다.

// webapp/controller/BaseController.js
sap.ui.define([
  "sap/ui/core/mvc/Controller"
], function (Controller) {
  "use strict";

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

    _aBusSubs: null,

    onInit: function () { this._aBusSubs = []; },

    subscribeBus: function (oBus, sCh, sEv, fn) {
      oBus.subscribe(sCh, sEv, fn, this);
      this._aBusSubs.push({ bus: oBus, ch: sCh, ev: sEv, fn: fn });
    },

    onExit: function () {
      (this._aBusSubs || []).forEach(function (s) {
        s.bus.unsubscribe(s.ch, s.ev, s.fn, this);
      }, this);
      this._aBusSubs = null;
    }
  });
});

(3) QUnit 단위 테스트 — EventBus 기반 코드는 모킹이 까다로워 보이지만, 실제로는 임시 인스턴스를 만들어 주입하기만 하면 됩니다.

QUnit.module("FilterBar publishing");

QUnit.test("publishes filterChanged with selected region", function (assert) {
  var done = assert.async();
  var oBus = sap.ui.getCore().getEventBus();

  oBus.subscribeOnce("com.acme.salesdash", "filterChanged",
    function (ch, ev, data) {
      assert.strictEqual(data.region, "APJ", "region forwarded");
      done();
  });

  // 컨트롤러 인스턴스 onApplyFilter 호출 (실제 view 없이도 가능하도록 stub)
  oFilterBarCtrl.onApplyFilter();
});

(4) 보안과 페이로드 검증 — EventBus는 같은 페이지 내 JS 컨텍스트를 가정하므로 외부 신뢰 경계가 아니지만, 페이로드를 그대로 OData 호출 파라미터에 흘리면 안 됩니다. 구독자 측에서 타입·길이·허용값을 검증하고, PII에 해당하는 필드는 가능한 한 ID만 실어 보내는 편이 일반적으로 권장됩니다.

자주 만나는 함정과 해결법

Q1. 핸들러가 두 번씩 호출돼요. 대부분 unsubscribe 누락 또는 onInit이 두 번 호출되는 라우팅 시나리오가 원인입니다. subscribeOnce를 쓰거나, 위의 BaseController 헬퍼처럼 구독 목록을 관리해 onExit에서 일괄 해제하는 방식이 효과적입니다. 같은 핸들러 함수 참조와 동일한 context로 unsubscribe를 호출해야 실제로 해제된다는 점도 자주 놓치는 부분입니다.

Q2. publish 했는데 구독자가 못 받아요. 발행자와 구독자가 서로 다른 EventBus 인스턴스를 보고 있는 경우가 압도적으로 많습니다. 한쪽은 글로벌(sap.ui.getCore().getEventBus()), 다른 한쪽은 컴포넌트(getOwnerComponent().getEventBus())를 쓰면 영원히 만나지 못합니다. 디버깅 시 oBus === otherBus를 콘솔에서 확인해 보면 빠르게 진단됩니다.

Q3. 핸들러에서 this가 undefined입니다. subscribe의 네 번째 인자(listener context)를 빠뜨린 경우입니다. 화살표 함수를 쓰면 더 안전하지만, 이후 동일 참조로 unsubscribe를 호출하려면 함수를 변수에 저장해 두는 절차가 필요합니다.

Q4. 컴포넌트 destroy 후에도 핸들러가 살아 있어요. 컴포넌트 EventBus를 썼다면 자동 정리되지만, 글로벌 EventBus를 썼다면 명시적으로 unsubscribe해야 합니다. Chrome DevTools의 Memory 스냅샷에서 detached DOM 트리가 보인다면 EventBus 구독 잔존이 흔한 범인입니다.

Q5. 이벤트 순서가 보장되나요? 같은 publish에 대해 구독자들이 호출되는 순서는 구독 등록 순서를 따르지만, 비동기 작업(OData 호출 등)을 핸들러 안에서 시작하면 완료 순서는 별개입니다. 순서가 의미를 가질 때는 페이로드에 sequence number나 timestamp를 실어 수신 측에서 정렬하는 패턴이 일반적으로 권장됩니다.

여기서 더 나아가려면

EventBus가 익숙해졌다면 다음 주제로 확장해 보면 좋습니다. 첫째, sap.ushell.Container의 CrossApplicationNavigation과 결합해 Fiori Launchpad의 앱-간 통신으로 영역을 넓히는 패턴. 둘째, EventBus 위에 얇은 Pub/Sub 추상화를 얹어 RxJS Observable로 변환하는 어댑터를 만드는 방법. 셋째, sap.ui.model.Model의 propertyChange 이벤트와 EventBus를 조합해 "모델 변경 → 도메인 이벤트"로 번역하는 도메인 이벤트 패턴. 마지막으로 BTP 환경에서 BAS의 work zone과 결합해 micro-frontend 간 메시지 버스로 활용하는 시나리오를 검토해 보면 시야가 한층 넓어집니다.

더 읽을거리

댓글 0

아직 댓글이 없습니다.