UI5

Code Splitting 3가지로 UI5 첫 로딩 최적화 #shorts #SAP #UI5

▶ YouTube에서 보기

1. 개요 및 이 글에서 다룰 것

SAPUI5 / OpenUI5 애플리케이션은 기능이 늘어날수록 초기 번들 크기가 빠르게 증가합니다. View, Controller, Fragment, 비즈니스 로직 모듈을 모두 시작 시점에 로드하면 첫 화면 표시(First Meaningful Paint)가 늦어지고, 모바일이나 저속 네트워크에서는 사용자가 이탈하기 쉽습니다. Code Splitting은 이러한 문제를 해결하기 위해 모듈을 작은 단위로 쪼개고, 사용자가 실제로 그 화면이나 기능에 접근할 때 비로소 네트워크에서 가져오는 기법입니다.

본 글은 SAPUI5 1.120 LTS(2024) 기준으로 설명하지만, 1.84 이상이면 대부분의 API가 동일하게 동작합니다.

학습 체크리스트

  • UI5의 AMD 스타일 모듈 시스템과 sap.ui.require 동작 원리 이해
  • 동기 sap.ui.requireSync 와 비동기 sap.ui.require 의 차이 구분
  • Router의 routeMatched 이벤트에서 지연 로드 패턴 적용
  • ui5.yaml의 bundles 옵션으로 번들 분리 구성
  • Chrome DevTools Network 탭으로 분리된 번들 검증

2. 이 글을 보기 전에

본 글을 따라가려면 다음 사항을 어느 정도 알고 있어야 합니다.

  • SAPUI5 MVC 구조(View / Controller / manifest.json)에 대한 기본 이해
  • sap.ui.define 으로 모듈을 선언하는 AMD 패턴
  • Router와 Routing 설정의 routes / targets 구조
  • npm 기반 UI5 Tooling(@ui5/cli)으로 빌드/서빙 경험
  • JavaScript Promise, async/await

3. 환경 / 버전 / 준비물

실습을 위해 다음 환경을 권장합니다.

  • SAPUI5: 1.120.x LTS (또는 1.108 LTS). 일반적으로 1.84+ 부터 비동기 로딩과 모듈 프리로드가 안정적입니다.
  • Node.js: 18 LTS 이상
  • UI5 Tooling: @ui5/cli 3.x, @ui5/builder 4.x
  • 브라우저: Chrome 120+ (Network/Performance 탭 사용)
  • 프로젝트 구조: webapp/ 아래 manifest.json, Component.js, view, controller가 존재한다고 가정

설치는 다음과 같이 진행합니다.

npm install --save-dev @ui5/cli @ui5/builder
npx ui5 init
npx ui5 serve

manifest.json 의 sap.ui5 영역에 "async": true 가 활성화되어 있는지 확인하세요. Routing과 RootView 모두 비동기 모드여야 동적 로딩이 의도대로 작동합니다.

4. 핵심 개념

UI5의 모듈 시스템은 RequireJS 계열의 AMD(Asynchronous Module Definition) 패턴을 따릅니다. 모든 모듈은 sap.ui.define 으로 선언되고, 다른 모듈에서 사용할 때는 sap.ui.require 또는 의존성 배열로 가져옵니다.

Code Splitting을 비유하자면 도서관에서 책을 빌리는 방식과 비슷합니다. 처음부터 모든 책을 집으로 가져오면 가방이 너무 무겁습니다. 대신 필요할 때마다 도서관에 가서 한두 권씩 빌려오면 부담이 줄어듭니다. UI5에서는 이 "도서관"이 서버이고, "책"이 모듈입니다.

핵심 구성 요소

  • sap.ui.define: 모듈을 정의. 의존성을 명시하면 빌드 시점에 정적으로 분석 가능.
  • sap.ui.require (비동기): 콜백 패턴으로 런타임 시 모듈을 로드. 정적 분석에서 제외되므로 별도 번들로 분리할 수 있는 후보.
  • sap.ui.requireSync (동기, deprecated): 1.58 이후 비권장. 메인 스레드를 차단하므로 사용을 피해야 합니다.
  • preload manifest: Component-preload.js 파일에 여러 모듈을 묶어 단일 요청으로 가져오는 메커니즘.
  • ui5.yaml bundles: 빌드 단계에서 모듈을 분리된 preload 번들로 묶는 설정.

도식으로 표현하면 다음과 같습니다.

[초기 로드]
  index.html
    └─ sap-ui-core.js
    └─ Component-preload.js   (메인 화면 + 공통 모듈)

[라우트 이동 시 지연 로드]
  routeMatched("detail")
    └─ detail-preload.js      (상세 페이지 View/Controller/Fragment)
    └─ chart-lib-preload.js   (Chart 라이브러리 의존성)

즉, 메인 번들에는 최소한의 화면 렌더링 코드만 두고, 라우트별로 별도 번들을 만들어 필요한 시점에만 다운로드합니다. 이렇게 하면 초기 페이로드를 30~60% 수준까지 줄일 수 있는 경우가 많습니다.

5. 실전 코드 3단계

1단계 — 기본: sap.ui.require로 비동기 모듈 로드

버튼을 클릭하면 그제서야 sap/m/MessageBox 를 로드하는 가장 단순한 예제입니다.

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

  return Controller.extend("com.example.controller.Home", {

    onShowDialog: function () {
      // 클릭 시점에 비로소 MessageBox 모듈을 가져온다
      sap.ui.require(["sap/m/MessageBox"], function (MessageBox) {
        MessageBox.information("Lazy loaded!");
      });
    }
  });
});

이 패턴의 핵심은 의존성 배열에 sap/m/MessageBox 를 넣지 않았다는 점입니다. 빌드 도구는 sap.ui.define 의 의존성만 정적으로 분석하므로, 이 모듈은 초기 번들에서 빠지고 사용자가 버튼을 누를 때 별도 요청으로 가져옵니다.

2단계 — 실무: routeMatched와 오류 처리, 로깅

이번에는 Detail 라우트로 이동할 때 무거운 차트 컨트롤러를 지연 로드하고, 실패 시 사용자에게 메시지를 표시합니다.

sap.ui.define([
  "sap/ui/core/UIComponent",
  "sap/base/Log",
  "sap/m/MessageToast"
], function (UIComponent, Log, MessageToast) {
  "use strict";

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

    init: function () {
      UIComponent.prototype.init.apply(this, arguments);
      var oRouter = this.getRouter();

      oRouter.getRoute("detail").attachMatched(this._onDetailMatched, this);
      oRouter.initialize();
    },

    _onDetailMatched: function (oEvent) {
      // Promise 래퍼로 비동기 require를 감싸 가독성을 높인다
      this._loadModules([
        "com/example/lib/HeavyChartHelper",
        "com/example/fragment/DetailDialogController"
      ]).then(function (aModules) {
        var HeavyChartHelper = aModules[0];
        Log.info("[CodeSplit] HeavyChartHelper loaded", null, "com.example");
        HeavyChartHelper.warmup();
      }).catch(function (oError) {
        Log.error("[CodeSplit] module load failed: " + oError.message);
        MessageToast.show("일부 모듈을 불러오지 못했습니다. 네트워크를 확인하세요.");
      });
    },

    _loadModules: function (aPaths) {
      return new Promise(function (resolve, reject) {
        sap.ui.require(aPaths, function () {
          resolve(Array.prototype.slice.call(arguments));
        }, function (oErr) {
          reject(oErr);
        });
      });
    }
  });
});

주의할 점은 sap.ui.require 의 두 번째 콜백이 성공, 세 번째 콜백이 실패 핸들러라는 것입니다. 1.56 이상에서 지원되며, 에러 콜백을 지정하지 않으면 모듈 로딩 실패가 조용히 묻혀버려 디버깅이 어려워집니다.

3단계 — 프로덕션: 조건부 로드, 캐싱, 단위 테스트

실제 운영에서는 사용자의 권한, 디바이스 종류, 피처 플래그에 따라 모듈을 다르게 로드합니다. 또한 같은 모듈을 두 번 요청하지 않도록 캐시도 고려해야 합니다.

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

  var ModuleLoader = BaseObject.extend("com.example.util.ModuleLoader", {});
  var _mCache = {};

  ModuleLoader.load = function (sName) {
    if (_mCache[sName]) {
      return _mCache[sName]; // 동일 모듈에 대한 Promise 재사용
    }
    _mCache[sName] = new Promise(function (resolve, reject) {
      sap.ui.require([sName], resolve, function (oErr) {
        delete _mCache[sName]; // 실패 시 캐시 무효화
        reject(oErr);
      });
    });
    return _mCache[sName];
  };

  ModuleLoader.loadIf = function (bCondition, sName) {
    return bCondition ? ModuleLoader.load(sName) : Promise.resolve(null);
  };

  return ModuleLoader;
});

컨트롤러에서는 다음과 같이 조건부로 호출합니다.

var bIsAdmin = this.getOwnerComponent().getModel("user").getProperty("/isAdmin");
var bIsDesktop = sap.ui.Device.system.desktop;

Promise.all([
  ModuleLoader.loadIf(bIsAdmin, "com/example/admin/AuditPanel"),
  ModuleLoader.loadIf(bIsDesktop, "com/example/lib/HeavyChart")
]).then(function (aResults) {
  var AuditPanel = aResults[0];
  var HeavyChart = aResults[1];
  if (AuditPanel) { AuditPanel.attach(this.getView()); }
  if (HeavyChart) { HeavyChart.render(this.byId("chartArea")); }
}.bind(this));

QUnit 단위 테스트에서는 sinon으로 sap.ui.require 를 스텁 처리해 검증할 수 있습니다.

QUnit.test("loadIf returns null when condition is false", function (assert) {
  return ModuleLoader.loadIf(false, "any/Mod").then(function (oRes) {
    assert.strictEqual(oRes, null, "조건이 false면 모듈을 로드하지 않는다");
  });
});

보안 측면에서는 사용자 입력값을 모듈 경로로 직접 넘기지 않도록 주의합니다. 화이트리스트 객체로 매핑한 뒤 그 키에 해당하는 모듈만 로드해야 임의 모듈 로드 취약점을 막을 수 있습니다.

ui5.yaml — 번들 분리 설정 예

specVersion: "3.0"
metadata:
  name: com.example.app
type: application
framework:
  name: SAPUI5
  version: "1.120.4"
builder:
  bundles:
    - bundleDefinition:
        name: com/example/app/Component-preload.js
        defaultFileTypes: [".js", ".fragment.xml", ".view.xml", ".properties"]
        sections:
          - mode: preload
            filters:
              - "com/example/app/Component.js"
              - "com/example/app/manifest.json"
              - "com/example/app/view/Home.view.xml"
              - "com/example/app/controller/Home.controller.js"
            resolve: false
      bundleOptions:
        optimize: true
        usePredefineCalls: true
    - bundleDefinition:
        name: com/example/app/detail-preload.js
        sections:
          - mode: preload
            filters:
              - "com/example/app/view/Detail.view.xml"
              - "com/example/app/controller/Detail.controller.js"
              - "com/example/app/lib/HeavyChartHelper.js"
            resolve: false

두 번째 bundleDefinition이 detail 라우트 전용 번들입니다. 이 파일은 routeMatched 시점에 자동으로 요청되며, 한 번 로드된 후에는 브라우저 캐시에 보관됩니다.

6. 흔한 실수 / 트러블슈팅

현장에서 자주 만나는 함정과 해결책을 FAQ 형태로 정리했습니다.

Q1. sap.ui.require로 분리했는데도 초기 번들 크기가 그대로입니다.
가장 흔한 원인은 같은 모듈을 sap.ui.define 의 의존성 배열에 이미 넣어둔 경우입니다. 의존성 배열에 들어간 모듈은 빌드 도구가 메인 preload에 포함시키므로, 지연 로드 대상은 반드시 define 배열에서 제거해야 합니다. 또한 ui5.yaml에서 해당 모듈이 메인 번들 filter에 포함되어 있지 않은지도 확인하세요.

Q2. requireSync를 쓰면 콜백 없이 간편한데 왜 안 되나요?
sap.ui.requireSync 는 1.58부터 deprecated 상태이며, 일반적으로 비활성 환경에서 경고가 발생하거나 향후 버전에서 동작이 보장되지 않습니다. 또한 동기 XHR을 유발해 메인 스레드를 차단하므로 성능에도 해롭습니다. 반드시 비동기 API로 마이그레이션하는 것을 권장합니다.

Q3. detail-preload.js 가 404로 돌아옵니다.
ui5.yaml의 builder.bundles 정의는 ui5 build 단계에서만 생성됩니다. 개발 서버(ui5 serve)는 개별 파일을 그대로 서빙하므로 preload 파일이 존재하지 않습니다. 운영 환경 빌드 산출물(dist/)로 확인하거나, ui5 serve --config ui5-dist.yaml 형태로 빌드 후 서빙해서 검증해야 합니다.

Q4. routeMatched에서 await를 쓰면 라우트 전환이 끊겨 보입니다.
모듈 로딩 동안 사용자에게 시각적 피드백이 없으면 빈 화면이 노출됩니다. BusyDialog 또는 View 레벨 busy 상태(oView.setBusy(true))를 켜둔 뒤, 로딩이 끝나면 해제하세요. 또한 빈번히 방문하는 라우트는 idle 시점에 미리 sap.ui.require 를 호출해 프리로드(워밍업)하는 것도 좋은 패턴입니다.

7. 다음 단계 / 관련 주제

Code Splitting은 성능 최적화의 한 축에 불과합니다. 다음 주제로 확장해보면 효과가 배가됩니다.

  • Async manifest: manifest.json 의 sap.ui5.rootViewrouting.config.async 를 모두 true로 설정해 라우터 자체를 비동기화
  • Cache Buster: 분리된 번들의 캐시 무효화를 위한 sap-ui-cachebuster 활성화
  • Resource bundle splitting: i18n 도 언어별로 분리 로드
  • Custom Library Bundle: 자체 라이브러리도 library-preload.js 단위로 분리
  • UI5 flexibility: 사용자 커스터마이징 영역도 지연 로드 가능

성능 측정은 Chrome DevTools Performance 탭의 Largest Contentful Paint 와 Lighthouse Total Blocking Time 지표를 기준으로 잡으면 효과를 정량적으로 확인할 수 있습니다.

8. 참고 자료

댓글 0

아직 댓글이 없습니다.