UI5

직접 변환 금지 — Formatter 패턴 3가지 #shorts #SAP #UI5

▶ YouTube에서 보기

개요와 이 글에서 얻어갈 것

SAPUI5 화면에서 백엔드가 내려주는 값은 대부분 "가공되지 않은 원본"입니다. OData 서비스가 DeliveryDate/Date(1751846400000)/ 또는 ISO 문자열로 내려주고, NetAmount1234567.89라는 숫자로, StatusA/B/C 같은 코드값으로 내려옵니다. 사용자에게는 "2026년 7월 7일", "1,234,567.89 KRW", "승인 완료" 같은 표현이 필요합니다. 이 변환을 담당하는 것이 UI5의 Formatter입니다. 이 글에서는 SalesOrder 납기일, Invoice 금액, PurchaseOrder 상태라는 실무 시나리오를 통해 Formatter의 원리부터 재사용 패턴까지 정리합니다.

  • UI5 Binding 파이프라인에서 Formatter가 실행되는 시점 이해
  • sap.ui.core.format.DateFormat 로 날짜·시각 표현하기
  • sap.ui.core.format.NumberFormat 로 통화·백분율 표현하기
  • 코드값을 라벨·아이콘으로 매핑하는 상태 Formatter 작성
  • View XML에서 Formatter 파일을 참조하는 네이밍 규칙
  • 재사용성을 높이는 폴더 구조와 테스트 전략

이 글을 읽기 전에 알고 있으면 좋은 것

SAPUI5 프로젝트 구조(Component.js, manifest.json, View/Controller 분리)와 XML View에서의 Property Binding { /path } 문법을 알고 있으면 이해가 빠릅니다. JavaScript ES5 문법과 sap.ui.define 모듈 정의 방식도 익숙해야 합니다. OData V2/V4 중 어느 쪽이든 무방하며, 예제는 V2 기준으로 작성했습니다.

실습 환경과 준비물

이 글의 코드는 SAPUI5 1.108 LTS 이상, SAP Business Application Studio 또는 VS Code + @ui5/cli 3.x 환경에서 검증했습니다. Node.js 18 LTS를 권장하며, 로컬 개발 서버는 ui5 serve로 기동합니다. 예제 프로젝트 네임스페이스는 com.acme.sales로 두고, Formatter 파일은 webapp/model/formatter.js에 배치합니다.

  • SAPUI5 런타임: 1.108 이상 (Evergreen 정책상 최신 LTS 권장)
  • 빌드 도구: UI5 Tooling 3.x
  • 테스트: QUnit + OPA5 (UI5에 기본 포함)
  • 브라우저: Chromium 계열 최신 버전
  • 백엔드 목데이터: webapp/localService/mockdata JSON

Formatter의 동작 원리와 Binding 파이프라인

UI5의 데이터 바인딩은 크게 세 단계로 흐릅니다. 첫째, Model에서 원본 값을 읽어옵니다. 둘째, Type이 지정되어 있다면 parseValue·formatValue가 호출되어 문자열↔원시 타입 변환이 일어납니다. 셋째, Formatter 함수가 지정되어 있다면 마지막에 실행되어 화면에 표시할 문자열을 만듭니다. 즉 Model → Type → Formatter → UI 순서로 데이터가 흐르며, Formatter는 표현 계층에서 값을 가공하는 마지막 훅입니다.

Formatter는 순수 함수(pure function)로 작성하는 것이 원칙입니다. 입력이 같으면 출력이 항상 같아야 하며, 컨트롤 상태·전역 변수·비동기 호출에 의존하면 렌더링 순서에 따라 값이 뒤바뀔 수 있습니다. UI5는 바인딩 값이 바뀔 때마다 Formatter를 재실행하므로, 내부에서 무거운 연산을 하면 성능 병목이 됩니다. 자주 쓰이는 DateFormat·NumberFormat 인스턴스는 모듈 스코프에 캐싱해 두는 것이 일반적입니다.

비유하자면 Model은 창고 원자재, Type은 규격 검수, Formatter는 매장 진열 직전의 포장·라벨링 공정에 해당합니다. 규격이 맞지 않은 값은 Type 단계에서 거부되고, 진열장에 예쁘게 놓는 일은 Formatter가 맡습니다. 그래서 Formatter는 "화면에 어떻게 보일지"만 결정하도록 책임을 좁게 유지해야 유지보수가 쉬워집니다.

1단계 예제 — 날짜 Formatter로 SalesOrder 납기일 표시

먼저 가장 흔한 케이스인 날짜 포맷팅부터 시작합니다. OData가 DeliveryDate를 JS Date 객체 또는 ISO 문자열로 내려준다고 가정합니다. webapp/model/formatter.js를 다음과 같이 만듭니다.

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

    // 인스턴스 캐싱: 매번 getDateInstance를 호출하지 않도록
    var oDateFmt = DateFormat.getDateInstance({
        style: "medium",
        calendarType: "Gregorian"
    });

    return {
        /**
         * SalesOrder 납기일 표시용 Formatter
         * @param {Date|string} vDate OData가 내려준 원본 값
         * @returns {string} "2026. 7. 7." 형태의 로케일 문자열
         */
        formatDeliveryDate: function (vDate) {
            if (!vDate) {
                return "";
            }
            var oDate = vDate instanceof Date ? vDate : new Date(vDate);
            return oDateFmt.format(oDate);
        }
    };
});

View XML에서는 Formatter를 다음처럼 연결합니다. 컨트롤러가 아니라 별도 모듈이므로 xmlns:core와 함께 모듈 경로를 명시합니다.

<mvc:View
    controllerName="com.acme.sales.controller.SalesOrderList"
    xmlns="sap.m"
    xmlns:mvc="sap.ui.core.mvc"
    xmlns:core="sap.ui.core"
    core:require="{ formatter: 'com/acme/sales/model/formatter' }">
    <Table items="{/SalesOrderSet}">
        <columns>
            <Column><Text text="주문번호"/></Column>
            <Column><Text text="납기일"/></Column>
        </columns>
        <ColumnListItem>
            <Text text="{SalesOrderId}"/>
            <Text text="{
                path: 'DeliveryDate',
                formatter: 'formatter.formatDeliveryDate'
            }"/>
        </ColumnListItem>
    </Table>
</mvc:View>

core:require 구문은 UI5 1.56 이상에서 지원되며, 컨트롤러에 별도로 require하지 않아도 XML에서 바로 Formatter 모듈을 참조할 수 있습니다.

2단계 예제 — 금액 Formatter와 로케일·통화 처리

실무에서 금액 표시는 통화 코드에 따라 소수점 자릿수와 기호가 달라집니다. KRW는 소수점이 없고, USD는 두 자리, JPY는 없음 — 이 규칙을 손으로 다루면 필연적으로 버그가 납니다. NumberFormat.getCurrencyInstance가 로케일과 CLDR 데이터를 기반으로 처리하도록 위임하는 것이 안전합니다.

sap.ui.define([
    "sap/ui/core/format/NumberFormat",
    "sap/base/Log"
], function (NumberFormat, Log) {
    "use strict";

    var oCurrencyFmt = NumberFormat.getCurrencyInstance({
        showMeasure: true,
        currencyCode: true // "KRW" 문자열로 표시, 심볼(₩)이 필요하면 false
    });

    return {
        /**
         * Invoice 금액 Formatter (복합 바인딩)
         * @param {number|string} vAmount 원본 금액
         * @param {string} sCurrency 통화 코드 (KRW/USD/JPY 등)
         * @returns {string} "1,234,567 KRW"
         */
        formatInvoiceAmount: function (vAmount, sCurrency) {
            if (vAmount === null || vAmount === undefined || vAmount === "") {
                return "";
            }
            var fAmount = typeof vAmount === "string" ? parseFloat(vAmount) : vAmount;
            if (isNaN(fAmount)) {
                Log.warning("Invalid amount for formatter", vAmount, "com.acme.sales.formatter");
                return String(vAmount);
            }
            return oCurrencyFmt.format(fAmount, sCurrency || "KRW");
        }
    };
});

여러 값을 조합하는 복합 바인딩(Composite Binding)은 parts 배열로 넘깁니다.

<ObjectNumber
    number="{
        parts: [
            { path: 'NetAmount' },
            { path: 'CurrencyCode' }
        ],
        formatter: 'formatter.formatInvoiceAmount'
    }"/>

여기서 주의할 점은 복합 바인딩은 기본이 단방향(one-way)이라는 것입니다. Input에 걸어 양방향으로 쓰려면 type을 지정하거나 parseFormatter를 직접 정의해야 하고, 대부분 상황에서 표시 전용 필드는 Formatter, 입력 필드는 sap.ui.model.type.Currency Type을 쓰는 것이 권장 패턴입니다. Log.warningsap/base/Log를 이용해 카테고리(com.acme.sales.formatter)를 붙여두면 window["sap-ui-debug"]로 필터링이 쉬워집니다.

3단계 예제 — 상태 코드 매핑과 재사용 가능한 구조

PurchaseOrder의 StatusA(승인 대기), B(승인 완료), C(반려), D(취소)로 내려온다고 가정합니다. 이 코드를 화면에서는 한글 라벨, ObjectStatus 색상, 그리고 아이콘까지 세 가지 방식으로 표시해야 합니다. 각각 별도 Formatter로 나누면 View XML이 명확해집니다.

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

    var ValueState = coreLibrary.ValueState;

    // 매핑 테이블: 유지보수 시 이 상수 한 곳만 수정
    var STATUS_META = {
        A: { text: "승인 대기", state: ValueState.Warning, icon: "sap-icon://pending" },
        B: { text: "승인 완료", state: ValueState.Success, icon: "sap-icon://accept" },
        C: { text: "반려",      state: ValueState.Error,   icon: "sap-icon://decline" },
        D: { text: "취소",      state: ValueState.None,    icon: "sap-icon://cancel" }
    };

    function pick(sCode, sKey, vFallback) {
        var oMeta = STATUS_META[sCode];
        return oMeta ? oMeta[sKey] : vFallback;
    }

    return {
        formatPoStatusText:  function (s) { return pick(s, "text",  s || ""); },
        formatPoStatusState: function (s) { return pick(s, "state", ValueState.None); },
        formatPoStatusIcon:  function (s) { return pick(s, "icon",  "sap-icon://question-mark"); }
    };
});

View에서 다음처럼 세 Formatter를 조합하면 상태 표시가 완성됩니다.

<ObjectStatus
    text="{ path: 'Status', formatter: 'formatter.formatPoStatusText' }"
    state="{ path: 'Status', formatter: 'formatter.formatPoStatusState' }"
    icon="{ path: 'Status', formatter: 'formatter.formatPoStatusIcon' }"/>

테스트는 QUnit으로 코드 매핑 테이블만 검증하면 충분합니다. Formatter가 순수 함수이기 때문에 목(mock)이 필요 없습니다.

sap.ui.define([
    "com/acme/sales/model/formatter"
], function (formatter) {
    "use strict";

    QUnit.module("PO Status Formatter");

    QUnit.test("승인 완료 코드는 Success 상태를 반환", function (assert) {
        assert.strictEqual(formatter.formatPoStatusText("B"),  "승인 완료");
        assert.strictEqual(formatter.formatPoStatusState("B"), "Success");
    });

    QUnit.test("정의되지 않은 코드는 안전한 기본값 반환", function (assert) {
        assert.strictEqual(formatter.formatPoStatusText("Z"),  "Z");
        assert.strictEqual(formatter.formatPoStatusState("Z"), "None");
    });

    QUnit.test("null 입력에도 예외 없이 처리", function (assert) {
        assert.strictEqual(formatter.formatPoStatusText(null),  "");
    });
});

프로덕션에서는 파일을 도메인별로 쪼개는 것이 유지보수에 유리합니다. 예를 들어 webapp/model/formatter/date.js, amount.js, status.js로 분리하고, webapp/model/formatter.js가 이들을 Object.assign으로 합쳐 노출하는 배럴(barrel) 패턴을 자주 씁니다. 그러면 View의 참조 경로는 하나로 통일되면서 파일별 책임은 좁게 유지됩니다.

자주 실수하는 지점과 트러블슈팅 FAQ

Q1. "Formatter가 호출되지 않는다"는 증상. 대부분 두 가지 원인입니다. 첫째, XML에서 formatter 앞에 별칭을 지정하지 않고 .formatDeliveryDate처럼 점만 찍는 옛 문법을 쓴 경우입니다. 이 방식은 컨트롤러에 함수가 있을 때만 동작합니다. 별도 모듈을 참조하려면 core:require로 별칭을 등록해야 합니다. 둘째, path 값이 실제 모델 프로퍼티와 대소문자·경로가 어긋난 경우입니다. Chrome DevTools의 UI5 Inspector로 바인딩 상태를 확인하는 것이 빠릅니다.

Q2. "날짜가 하루 밀린다"는 증상. OData V2가 Edm.DateTime을 UTC 기준 밀리초로 내려주는데, 로컬 타임존이 UTC+9면 자정 근처 값이 전날로 표시될 수 있습니다. 순수 날짜 필드는 Edm.DateTime 대신 Edm.Date(V4) 또는 DateFormat.getDateInstance({ UTC: true })를 사용해 UTC 그대로 표시하는 것이 안전합니다. 시각까지 필요하면 반대로 UTC 옵션을 끄고 로컬 변환을 명시적으로 처리합니다.

Q3. "Input에 Formatter를 걸었더니 값이 저장되지 않는다"는 증상. Formatter는 표시 방향 단방향이므로 사용자가 입력한 문자열을 모델 타입으로 되돌리지 못합니다. 입력 컨트롤에는 Formatter 대신 sap.ui.model.type.Date, Currency, Integer 같은 Type을 type 속성으로 지정해야 합니다. 이때 formatOptionsconstraints를 함께 넘겨 Validation까지 위임하는 것이 실무에서 가장 깔끔합니다.

이 외에도 Formatter 내부에서 this를 참조하면 컨텍스트가 컨트롤이므로 다른 함수와 헷갈리기 쉽습니다. 순수 함수 원칙을 지키면 이 문제는 발생하지 않습니다. 또 DateFormat.getDateInstance를 Formatter 내부에서 매번 호출하면 렌더링당 인스턴스가 새로 만들어져 대량 목록에서 체감 성능이 떨어집니다. 반드시 모듈 스코프에서 캐싱하세요.

여기서 더 확장해 볼 만한 주제

Formatter에 익숙해졌다면 다음 주제로 이어가는 것을 권장합니다. 첫째, sap.ui.model.type 계열 커스텀 Type을 직접 만들어 입력·표시·검증을 하나로 묶는 방법. 둘째, i18n 리소스 번들을 Formatter에서 안전하게 참조해 다국어 라벨을 만드는 패턴(대개 Component 초기화 시 리소스 번들을 주입해서 사용). 셋째, Fiori Elements 환경에서 Annotation 기반 표시 규칙(UI.DataField, Common.Text)과 커스텀 Formatter를 병행하는 전략. 넷째, OPA5로 Formatter 결과가 실제 화면에 반영되었는지 E2E 수준에서 검증하는 방법도 실무에서 유용합니다.

더 읽어볼 만한 링크

댓글 0

아직 댓글이 없습니다.