SAPUI5 Custom Control 개발 완전 가이드 — extend, renderer, metadata부터 재사용 라이브러리까지

AI News

SAPUI5 Custom Control 타이틀

핵심 구조 3가지

기본 Custom Control 코드

SAPUI5 Custom Control 개발 완전 가이드 — extend, renderer, metadata부터 재사용 라이브러리까지

1. 개요 및 학습 목표

SAPUI5는 Label부터 PlanningCalendar까지 풍부한 내장 컨트롤을 제공하지만, 프로젝트 요구사항이 복잡해질수록 내장 컨트롤만으로는 한계가 있습니다. 이때 Custom Control을 직접 만들면 SAPUI5 프레임워크의 렌더링 파이프라인과 라이프사이클 관리를 그대로 활용하면서도, 원하는 UI와 동작을 자유롭게 구현할 수 있습니다. 단순히 jQuery나 바닐라 JavaScript로 DOM을 조작하는 방식과 달리, 프레임워크가 데이터 바인딩·재렌더링·소멸을 일관되게 관리해 주므로 유지보수성과 개발 효율이 크게 높아집니다.

이 가이드를 마치면 다음을 할 수 있습니다:

2. 선수 지식

이 가이드를 효과적으로 따라가려면 다음 지식이 필요합니다:

3. 환경 / 버전 / 준비물

항목권장 사양
SAPUI5 / OpenUI51.120 LTS 이상 (apiVersion 2 렌더러 지원)
SAP NetWeaver7.5 SP05 이상 또는 SAP BTP Cloud Foundry 환경
개발 도구SAP Business Application Studio 또는 VS Code + Fiori Tools Extension
Node.js18.x 이상 (UI5 Tooling CLI 사용 시)
브라우저Chrome, Edge, Firefox 최신 버전

프로젝트 생성은 yo easy-ui5 제너레이터 또는 SAP BAS의 Fiori 프로젝트 위저드를 사용하면 빠르게 시작할 수 있습니다. 이 가이드의 코드 예제는 sap.ui.define 기반 AMD 모듈 형식을 사용합니다.

4. 핵심 개념

커스텀 컨트롤을 이해하려면 세 가지 핵심 축을 먼저 파악해야 합니다: metadata, renderer, lifecycle.

4-1. metadata — 컨트롤의 설계도

metadata는 컨트롤이 외부에 노출하는 인터페이스를 선언합니다. 건축 도면에 비유하면, 어떤 방(properties)이 있고, 어떤 가구(aggregations)를 넣을 수 있으며, 이웃 집과 어떤 통로(associations)로 연결되고, 어떤 신호(events)를 보낼 수 있는지 정의하는 것입니다.

4-2. renderer — DOM을 그리는 붓

renderer는 컨트롤의 상태를 실제 HTML로 변환하는 함수입니다. SAPUI5의 RenderManager(oRm)를 통해 DOM 요소를 작성하며, 프레임워크가 언제 다시 그릴지(재렌더링)를 제어합니다. renderer는 컨트롤 내부에 인라인으로 정의하거나, 별도의 ControlRenderer.js 파일로 분리할 수 있습니다.

4-3. lifecycle — 탄생에서 소멸까지

컨트롤은 다음 생명주기를 따릅니다:

  1. init() — 인스턴스가 생성될 때 한 번만 호출됩니다. 내부 변수 초기화, 자식 컨트롤 생성 등을 수행합니다.
  2. renderer.render() — 프레임워크가 DOM을 생성/갱신할 때 호출됩니다.
  3. onAfterRendering() — DOM이 실제 페이지에 삽입된 직후 호출됩니다. 외부 라이브러리 초기화, DOM 직접 조작 등에 사용합니다.
  4. exit() — 컨트롤이 소멸될 때 호출됩니다. 이벤트 리스너 해제, 타이머 정리 등 리소스를 반환합니다.

비유: init()은 배우가 무대에 오르기 전 분장실에서 준비하는 단계, renderer는 무대 위 공연, onAfterRendering()은 공연 직후 관객 반응 확인, exit()은 공연 종료 후 분장 해제에 해당합니다.

5. 실전 코드 3단계

1단계: 기본 커스텀 컨트롤

가장 단순한 형태의 커스텀 컨트롤을 만들어 봅니다. 도서 정보를 표시하는 BookControl입니다.

// webapp/controls/Book.js
sap.ui.define([
    "sap/ui/core/Control"
], function(Control) {
    "use strict";

    var Book = Control.extend("myapp.controls.Book", {
        metadata: {
            properties: {
                title:  { type: "string", defaultValue: "" },
                author: { type: "string", defaultValue: "" },
                price:  { type: "float",  defaultValue: 0 }
            }
        },

        init: function() {
            // 인스턴스 초기화 — 필요한 내부 상태를 설정합니다
        },

        renderer: {
            apiVersion: 2,
            render: function(oRm, oControl) {
                oRm.openStart("div", oControl);
                oRm.class("book-card");
                oRm.openEnd();

                // 제목
                oRm.openStart("h4");
                oRm.openEnd();
                oRm.text(oControl.getTitle());
                oRm.close("h4");

                // 저자
                oRm.openStart("p");
                oRm.openEnd();
                oRm.text("저자: " + oControl.getAuthor());
                oRm.close("p");

                // 가격
                oRm.openStart("span");
                oRm.class("book-price");
                oRm.openEnd();
                oRm.text(oControl.getPrice() + " 원");
                oRm.close("span");

                oRm.close("div");
            }
        }
    });

    return Book;
});

핵심 포인트: Control.extend()의 첫 번째 인자는 점(.)으로 구분된 정규화된 이름입니다. renderer 안에서 apiVersion: 2를 선언하면 openStart/openEnd 방식의 새 렌더러 API를 사용하며, 이는 성능 최적화(in-place DOM patching)를 지원합니다.

2단계: 실무 시나리오

(A) 이벤트와 aggregation 활용

// webapp/controls/RatingBox.js
sap.ui.define([
    "sap/ui/core/Control"
], function(Control) {
    "use strict";

    return Control.extend("myapp.controls.RatingBox", {
        metadata: {
            properties: {
                maxRating: { type: "int", defaultValue: 5 },
                value:     { type: "int", defaultValue: 0 }
            },
            aggregations: {
                _stars: {
                    type: "sap.ui.core.Icon",
                    multiple: true,
                    visibility: "hidden"   // 내부 전용
                }
            },
            events: {
                ratingChanged: {
                    parameters: {
                        newValue: { type: "int" }
                    }
                }
            }
        },

        init: function() {
            // 별 아이콘을 미리 생성하여 aggregation에 추가
            for (var i = 0; i < this.getMaxRating(); i++) {
                this.addAggregation("_stars",
                    new sap.ui.core.Icon({
                        src: "sap-icon://unfavorite"
                    }).addStyleClass("ratingIcon")
                );
            }
        },

        _onStarClick: function(iIndex) {
            this.setValue(iIndex + 1);
            this.fireEvent("ratingChanged", { newValue: iIndex + 1 });
        },

        renderer: {
            apiVersion: 2,
            render: function(oRm, oControl) {
                oRm.openStart("div", oControl);
                oRm.class("rating-box");
                oRm.openEnd();

                var aStars = oControl.getAggregation("_stars") || [];
                aStars.forEach(function(oStar, i) {
                    oStar.setSrc(i < oControl.getValue()
                        ? "sap-icon://favorite"
                        : "sap-icon://unfavorite");
                    oRm.renderControl(oStar);
                });

                oRm.close("div");
            }
        },

        onAfterRendering: function() {
            var that = this;
            var aStars = this.getAggregation("_stars") || [];
            aStars.forEach(function(oStar, i) {
                oStar.attachPress(function() {
                    that._onStarClick(i);
                });
            });
        }
    });
});

(B) 기존 컨트롤 확장

완전히 새로 만들 필요 없이, 기존 SAPUI5 컨트롤을 상속하여 동작만 추가할 수 있습니다. 아래는 sap.ui.core.Icon을 확장해서 마우스 오버 시 아이콘이 바뀌는 예제입니다.

// webapp/controls/HoverIcon.js
sap.ui.define([
    "sap/ui/core/Icon"
], function(Icon) {
    "use strict";

    return Icon.extend("myapp.controls.HoverIcon", {
        metadata: {
            properties: {
                defaultSrc: { type: "sap.ui.core.URI", defaultValue: "" },
                hoverSrc:   { type: "sap.ui.core.URI", defaultValue: "" }
            }
        },

        onmouseover: function() {
            if (this.getHoverSrc()) {
                this.setSrc(this.getHoverSrc());
            }
        },

        onmouseout: function() {
            if (this.getDefaultSrc()) {
                this.setSrc(this.getDefaultSrc());
            }
        }

        // renderer는 상속받으므로 별도 정의 불필요
    });
});

(C) 재렌더링 억제

setter가 호출될 때마다 SAPUI5는 기본적으로 전체 컨트롤을 다시 렌더링합니다. 성능이 중요한 경우 setProperty의 세 번째 인자를 true로 전달하여 재렌더링을 억제하고, DOM을 직접 업데이트할 수 있습니다.

Book.prototype.setTitle = function(sVal) {
    this.setProperty("title", sVal, true); // 세 번째 인자 true = 재렌더링 억제
    // DOM을 직접 업데이트
    var oDomRef = this.getDomRef();
    if (oDomRef) {
        oDomRef.querySelector("h4").textContent = sVal;
    }
    return this;
};

3단계: 프로덕션 수준

(A) 외부 라이브러리 래핑

SAPUI5에 내장되지 않은 기능(예: PDF 뷰어, 차트 라이브러리)을 통합할 때 외부 라이브러리를 커스텀 컨트롤로 감싸는 패턴을 사용합니다.

// webapp/controls/PdfViewer.js
sap.ui.define([
    "sap/ui/core/Control"
], function(Control) {
    "use strict";

    return Control.extend("myapp.controls.PdfViewer", {
        metadata: {
            properties: {
                src:    { type: "string", defaultValue: "" },
                width:  { type: "sap.ui.core.CSSSize", defaultValue: "100%" },
                height: { type: "sap.ui.core.CSSSize", defaultValue: "600px" }
            },
            events: {
                documentLoaded: {}
            }
        },

        renderer: {
            apiVersion: 2,
            render: function(oRm, oControl) {
                oRm.openStart("div", oControl);
                oRm.class("pdf-viewer-container");
                oRm.style("width", oControl.getWidth());
                oRm.style("height", oControl.getHeight());
                oRm.openEnd();
                oRm.close("div");
            }
        },

        onAfterRendering: function() {
            // 외부 라이브러리는 DOM이 준비된 후 초기화
            var oDomRef = this.getDomRef();
            if (oDomRef && this.getSrc()) {
                this._initExternalLib(oDomRef);
            }
        },

        _initExternalLib: function(oDomRef) {
            // 외부 PDF 라이브러리 초기화 로직
            // 예: WebViewer({ path: "...", initialDoc: this.getSrc() }, oDomRef)
            this.fireEvent("documentLoaded");
        },

        exit: function() {
            // 외부 라이브러리 인스턴스 정리
            if (this._externalInstance) {
                this._externalInstance.dispose();
                this._externalInstance = null;
            }
        }
    });
});

(B) 재사용 라이브러리 구조

여러 프로젝트에서 공유할 컨트롤은 독립 라이브러리로 구조화하는 것이 일반적입니다.

// 디렉터리 구조
src/
  mylib/
    .library          // 라이브러리 메타데이터, 의존성 선언
    library.js        // sap.ui.getCore().initLibrary() 호출
    themes/
      base/
        library.source.less
    controls/
      Book.js
      BookRenderer.js
    messagebundle.properties    // i18n 다국어 리소스
    messagebundle_ko.properties
// src/mylib/library.js
sap.ui.define([
    "sap/ui/core/Lib"
], function(Lib) {
    "use strict";

    return Lib.init({
        name: "mylib",
        version: "1.0.0",
        dependencies: ["sap.ui.core", "sap.m"],
        types: [],
        interfaces: [],
        controls: [
            "mylib.controls.Book",
            "mylib.controls.PdfViewer"
        ],
        elements: []
    });
});

(C) XMLView에서 커스텀 컨트롤 사용

<mvc:View
    xmlns:mvc="sap.ui.core.mvc"
    xmlns="sap.m"
    xmlns:custom="myapp.controls">

    <Page title="도서 목록">
        <content>
            <custom:Book
                title="{/bookTitle}"
                author="{/bookAuthor}"
                price="{/bookPrice}" />

            <custom:RatingBox
                maxRating="5"
                value="{/userRating}"
                ratingChanged="onRatingChanged" />
        </content>
    </Page>
</mvc:View>

XMLView에서 커스텀 컨트롤을 사용하려면 xmlns 속성으로 네임스페이스를 선언하고, 해당 접두사(위 예제에서 custom)를 태그 앞에 붙이면 됩니다. 데이터 바인딩도 내장 컨트롤과 동일하게 작동합니다.

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

Q1. 컨트롤이 화면에 나타나지 않습니다.

renderer에서 openStart("div", oControl)처럼 두 번째 인자로 컨트롤 참조를 반드시 전달해야 합니다. 이를 빠뜨리면 SAPUI5가 DOM에 컨트롤 ID를 기록하지 못해 렌더링 시점을 추적할 수 없습니다. 레거시 API에서는 oRm.writeControlData(oControl)을 누락하는 경우가 많습니다.

Q2. setter를 호출했는데 화면이 갱신되지 않습니다.

setProperty("prop", val, true)의 세 번째 인자를 true로 설정하면 재렌더링이 억제됩니다. 의도적으로 억제한 경우 DOM을 직접 업데이트하는 코드를 반드시 함께 작성해야 합니다. 의도하지 않았다면 세 번째 인자를 제거하거나 false로 변경하세요.

Q3. onAfterRendering에서 등록한 이벤트가 중복 실행됩니다.

재렌더링이 발생할 때마다 onAfterRendering이 다시 호출됩니다. 이벤트 리스너를 반복 등록하면 핸들러가 누적됩니다. 기존 리스너를 해제한 뒤 다시 등록하거나, 플래그 변수로 중복 등록을 방지하세요.

Q4. aggregation에 추가한 자식 컨트롤이 다른 곳에서도 보입니다.

하나의 컨트롤 인스턴스는 단 하나의 aggregation에만 속할 수 있습니다. 같은 인스턴스를 여러 aggregation에 추가하면 이전 부모에서 자동으로 제거됩니다. 여러 곳에 표시해야 한다면 별도 인스턴스를 생성하거나, association을 사용하세요.

Q5. apiVersion 2로 변경했더니 기존 렌더러 코드에서 오류가 발생합니다.

apiVersion 2에서는 oRm.write(), oRm.writeControlData(), oRm.addClass() 같은 레거시 메서드를 사용할 수 없습니다. openStart/openEnd, class(), attr() 등 새 API로 전환해야 합니다. 마이그레이션 시 한 컨트롤씩 단계적으로 변경하는 것이 일반적입니다.

7. 다음 단계 / 관련 주제

8. 참고 자료


📌 본 게시물은 AI(Claude)가 공개된 자료를 기반으로 자동 생성한 콘텐츠입니다. 기술 내용의 정확성은 SAP 공식 문서 와 교차 확인하시기 바랍니다.

™ SAP, S/4HANA, ABAP, Fiori, SAP BTP 등은 SAP SE 또는 그 계열사의 등록 상표입니다. 본 사이트는 SAP SE 와 공식적인 관련이 없는 비공식 학습 자료 입니다.

📧 저작권 침해 / 오류 / 콘텐츠 신고: btpstacks.com 의 "문의" 메뉴를 이용해주세요.