UI5

ObjectStatus vs ObjectIdentifier — UI5 상태 표시 #shorts #SAP #UI5

▶ YouTube에서 보기

개요 및 이 글에서 다루는 것

SAPUI5/OpenUI5의 sap.m 라이브러리에는 비즈니스 데이터를 시각적으로 표현하기 위한 두 가지 핵심 컨트롤이 있습니다. ObjectStatus는 항목의 상태(승인/거절/대기)를 색상과 아이콘으로 직관적으로 표현하고, ObjectIdentifier는 비즈니스 객체의 식별자(문서번호, 자재번호)를 제목과 부가설명으로 표시합니다. 이 글은 두 컨트롤의 동작 원리와 차이점, 그리고 실무에서 가장 흔히 마주치는 "목록 화면에서 엔티티를 식별하고 상태를 표시"하는 패턴을 깊이 있게 다룹니다.

  • ObjectStatus의 state 열거형과 색상 매핑 방식 이해
  • ObjectIdentifier의 titleActive와 titlePress 이벤트 활용
  • Formatter 함수로 백엔드 상태 코드를 UI5 ValueState로 변환
  • ObjectListItem 내부 attributes/firstStatus/secondStatus 배치 전략
  • 접근성(ARIA) 속성과 스크린 리더 호환성 고려

사전에 알아두면 좋은 것

이 글은 SAPUI5 XML View 기반 개발 경험이 있는 입문~중급 개발자를 대상으로 합니다. JSONModel이나 ODataModel을 통한 데이터 바인딩 문법({}, formatter, parts)을 알고 있고, Controller에서 onInit 라이프사이클과 이벤트 핸들러를 작성해본 경험이 필요합니다. Fiori Elements가 아닌 Freestyle UI5 개발 환경을 가정합니다.

환경 및 버전 정보

이 글의 코드는 다음 환경을 기준으로 작성되었습니다.

  • SAPUI5 1.120 LTS (2024년 릴리스, sap.m 라이브러리)
  • BTP Business Application Studio 또는 VS Code + SAP Fiori Tools
  • Node.js 18 LTS, ui5-cli 3.x
  • 브라우저: Chrome 120+ / Edge 120+ (sap_horizon 테마 권장)
  • 백엔드: 모의 JSONModel 또는 OData V2/V4 서비스

SAPUI5 1.96 이상부터 ObjectStatusinverted, active 속성이 정식 지원되며, 1.110 이후로는 stateAnnouncementText를 통한 접근성 보강이 가능합니다. 구버전(1.71 이하)에서는 일부 속성이 동작하지 않을 수 있으니 SDK 문서의 Since 표기를 확인하는 것이 일반적으로 권장됩니다.

핵심 개념 — 두 컨트롤의 역할 분리

UI5에서 비즈니스 데이터를 표현하는 컨트롤은 "무엇인가(Identity)"와 "어떤 상태인가(State)"를 분리해서 다룹니다. 이 두 가지를 한 컨트롤에 욱여넣으면 화면이 복잡해지고 의미도 흐려지기 때문입니다. 이 분리 원칙이 ObjectIdentifierObjectStatus의 존재 이유입니다.

ObjectIdentifier는 "이 행이 무엇을 가리키는가"를 보여줍니다. 예를 들어 "PO-4500001234 / 삼성전자 부품 발주"처럼 식별자(title)와 부가 설명(text)을 한 쌍으로 표시합니다. titleActive="true"로 설정하면 title 부분이 링크처럼 활성화되어 클릭 가능해지고, titlePress 이벤트로 상세 화면 네비게이션을 트리거할 수 있습니다. 마치 종이 서류의 "문서번호 도장" 같은 역할입니다.

ObjectStatus는 "이 행이 지금 어떤 상태인가"를 색상과 아이콘으로 보여줍니다. state 속성은 sap.ui.core.ValueState 열거형(None/Success/Warning/Error/Information)을 받으며, 각각 회색/초록/주황/빨강/파랑으로 렌더링됩니다. 1.66 이후로는 state="Indication01"부터 "Indication08"까지 8가지 사용자 정의 의미색을 지원해 단순 Success/Error만으로 표현하기 어려운 비즈니스 상태(부분승인, 보류 등)도 다룰 수 있습니다.

비유하자면 ObjectIdentifier는 이력서의 "이름·생년월일"이고, ObjectStatus는 우측 상단에 찍힌 "합격/불합격" 도장입니다. 둘이 같은 행에 있을 때 사용자는 한 눈에 "누가, 어떻게 됐는지" 파악합니다.

두 컨트롤은 보통 단독으로 쓰이지 않고 ObjectListItemattributes, firstStatus, secondStatus aggregation에 배치되거나, Table의 셀에 들어갑니다. ObjectListItem은 모바일 친화적 카드 형태로 식별자를 헤더에, 상태를 우측 상단에 자동 배치하므로 두 컨트롤의 의미적 분리가 그대로 시각적 배치로 이어집니다.

실전 코드 1단계 — 기본 ObjectStatus와 ObjectIdentifier 배치

먼저 단순 카드 형태로 두 컨트롤을 배치해 봅니다. 시나리오는 자재 마스터(Material) 단건 조회 화면입니다.

<mvc:View
    controllerName="kr.acme.mm.controller.MaterialDetail"
    xmlns:mvc="sap.ui.core.mvc"
    xmlns="sap.m">
    <Page title="자재 상세">
        <content>
            <VBox class="sapUiMediumMargin">
                <ObjectIdentifier
                    title="{materialModel>/materialNo}"
                    text="{materialModel>/description}" />
                <ObjectStatus
                    text="{materialModel>/stockStatusText}"
                    state="Success"
                    icon="sap-icon://accept"
                    class="sapUiSmallMarginTop" />
            </VBox>
        </content>
    </Page>
</mvc:View>

Controller의 onInit에서 모델을 주입합니다.

sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/ui/model/json/JSONModel"
], function (Controller, JSONModel) {
    "use strict";
    return Controller.extend("kr.acme.mm.controller.MaterialDetail", {
        onInit: function () {
            var oMaterialData = {
                materialNo: "MAT-100023",
                description: "스테인리스 볼트 M8 x 30mm",
                stockStatusText: "재고 충분 (1,240 EA)"
            };
            this.getView().setModel(new JSONModel(oMaterialData), "materialModel");
        }
    });
});

이 코드는 동작은 하지만 state가 하드코딩되어 있어 실무에서는 쓸 수 없습니다. 다음 단계에서 데이터 기반으로 바꿉니다.

실전 코드 2단계 — Formatter로 상태 바인딩 및 클릭 네비게이션

구매발주(Purchase Order) 목록 화면을 구현합니다. 백엔드는 승인상태를 "A"(Approved), "P"(Pending), "R"(Rejected), "D"(Draft) 코드로 보내고, UI에서는 이를 ValueState로 변환해야 합니다.

// model/StatusFormatter.js
sap.ui.define([], function () {
    "use strict";
    return {
        toValueState: function (sCode) {
            switch (sCode) {
                case "A": return "Success";
                case "P": return "Warning";
                case "R": return "Error";
                case "D": return "None";
                default:  return "None";
            }
        },
        toStatusIcon: function (sCode) {
            switch (sCode) {
                case "A": return "sap-icon://accept";
                case "P": return "sap-icon://pending";
                case "R": return "sap-icon://decline";
                case "D": return "sap-icon://edit";
                default:  return "";
            }
        },
        toStatusText: function (sCode) {
            var oBundle = this.getView().getModel("i18n").getResourceBundle();
            return oBundle.getText("poStatus." + (sCode || "UNKNOWN"));
        }
    };
});

View에서는 ObjectListItemattributesfirstStatus aggregation에 두 컨트롤을 배치합니다.

<mvc:View
    controllerName="kr.acme.mm.controller.PoList"
    xmlns:mvc="sap.ui.core.mvc"
    xmlns="sap.m">
    <Page title="구매발주 목록">
        <List
            id="poList"
            items="{poModel>/orders}"
            growing="true"
            growingThreshold="20">
            <ObjectListItem
                title="{poModel>poNumber}"
                type="Active"
                press=".onPoItemPress">
                <attributes>
                    <ObjectAttribute text="공급사: {poModel>vendorName}" />
                    <ObjectAttribute text="요청일: {
                        path: 'poModel>requestDate',
                        formatter: '.formatter.toLocalDate'
                    }" />
                </attributes>
                <firstStatus>
                    <ObjectStatus
                        text="{
                            path: 'poModel>approvalCode',
                            formatter: '.formatter.toStatusText'
                        }"
                        state="{
                            path: 'poModel>approvalCode',
                            formatter: '.formatter.toValueState'
                        }"
                        icon="{
                            path: 'poModel>approvalCode',
                            formatter: '.formatter.toStatusIcon'
                        }" />
                </firstStatus>
            </ObjectListItem>
        </List>
    </Page>
</mvc:View>

Controller에서는 formatter를 import하고 행 클릭 이벤트를 처리합니다.

sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/ui/model/json/JSONModel",
    "sap/base/Log",
    "kr/acme/mm/model/StatusFormatter"
], function (Controller, JSONModel, Log, StatusFormatter) {
    "use strict";
    return Controller.extend("kr.acme.mm.controller.PoList", {
        formatter: StatusFormatter,
        onInit: function () {
            var oData = {
                orders: [
                    { poNumber: "4500001234", vendorName: "(주)삼성전자", requestDate: "2026-05-20", approvalCode: "A" },
                    { poNumber: "4500001235", vendorName: "LG화학", requestDate: "2026-05-21", approvalCode: "P" },
                    { poNumber: "4500001236", vendorName: "포스코", requestDate: "2026-05-22", approvalCode: "R" },
                    { poNumber: "4500001237", vendorName: "현대모비스", requestDate: "2026-05-23", approvalCode: "D" }
                ]
            };
            this.getView().setModel(new JSONModel(oData), "poModel");
        },
        onPoItemPress: function (oEvent) {
            var oCtx = oEvent.getSource().getBindingContext("poModel");
            var sPoNo = oCtx.getProperty("poNumber");
            Log.info("PO 상세 진입: " + sPoNo, "PoList");
            this.getOwnerComponent().getRouter().navTo("poDetail", { poId: sPoNo });
        }
    });
});

여기서 주목할 점은 state, text, icon 세 속성이 모두 동일한 approvalCode 필드를 바라보지만 각기 다른 formatter를 거친다는 것입니다. 백엔드 모델 변경 시 formatter만 수정하면 되므로 유지보수성이 높습니다.

실전 코드 3단계 — titleActive, 접근성, 성능 최적화

프로덕션 환경에서는 ObjectIdentifier.title 자체를 클릭 가능하게 만들어 상세 진입을 명확히 하고, 스크린 리더 사용자도 상태를 이해할 수 있도록 stateAnnouncementText를 부여합니다.

<ColumnListItem>
    <cells>
        <ObjectIdentifier
            title="{poModel>poNumber}"
            text="{poModel>vendorName}"
            titleActive="true"
            titlePress=".onPoTitlePress"
            ariaLabelledBy="poListHeader" />
        <ObjectStatus
            text="{
                path: 'poModel>approvalCode',
                formatter: '.formatter.toStatusText'
            }"
            state="{
                path: 'poModel>approvalCode',
                formatter: '.formatter.toValueState'
            }"
            icon="{
                path: 'poModel>approvalCode',
                formatter: '.formatter.toStatusIcon'
            }"
            stateAnnouncementText="{
                path: 'poModel>approvalCode',
                formatter: '.formatter.toAnnouncementText'
            }"
            active="true"
            press=".onStatusPress" />
        <Text text="{
            path: 'poModel>totalAmount',
            formatter: '.formatter.toCurrencyKRW'
        }" />
    </cells>
</ColumnListItem>

Controller에 핸들러와 보강된 formatter를 추가합니다.

onPoTitlePress: function (oEvent) {
    // titlePress는 oEvent.getSource()가 ObjectIdentifier
    var oCtx = oEvent.getSource().getBindingContext("poModel");
    this.getOwnerComponent().getRouter().navTo("poDetail", {
        poId: oCtx.getProperty("poNumber")
    });
},
onStatusPress: function (oEvent) {
    // active=true 일 때 상태 클릭 시 승인 이력 팝업 오픈
    var oCtx = oEvent.getSource().getBindingContext("poModel");
    this._openApprovalHistory(oCtx.getProperty("poNumber"));
}
// StatusFormatter.js 보강
toAnnouncementText: function (sCode) {
    var oMap = {
        "A": "승인 완료 상태입니다",
        "P": "승인 대기 중 상태입니다",
        "R": "반려된 상태입니다",
        "D": "임시 저장 상태입니다"
    };
    return oMap[sCode] || "상태 정보 없음";
},
toCurrencyKRW: function (nAmount) {
    if (nAmount == null) { return ""; }
    return new Intl.NumberFormat("ko-KR", {
        style: "currency", currency: "KRW"
    }).format(nAmount);
}

대용량 목록(1,000건 이상)에서는 다음을 권장합니다.

  • growing="true"growingThreshold로 lazy rendering 적용
  • formatter는 모듈로 분리해 캐싱(매 렌더링마다 함수 재생성 방지)
  • OData V4 사용 시 $select로 approvalCode만 가져와 페이로드 축소
  • QUnit으로 formatter 단위 테스트 작성 (입력 코드 → 기대 ValueState 매핑 검증)
// test/unit/model/StatusFormatter.qunit.js
sap.ui.define(["kr/acme/mm/model/StatusFormatter"], function (Formatter) {
    "use strict";
    QUnit.module("StatusFormatter.toValueState");
    QUnit.test("승인 코드는 Success", function (assert) {
        assert.strictEqual(Formatter.toValueState("A"), "Success");
    });
    QUnit.test("알 수 없는 코드는 None", function (assert) {
        assert.strictEqual(Formatter.toValueState("Z"), "None");
        assert.strictEqual(Formatter.toValueState(null), "None");
    });
});

흔한 실수와 트러블슈팅

Q1. state에 백엔드 코드("A", "P")를 직접 바인딩했더니 색상이 적용되지 않습니다.
state는 반드시 sap.ui.core.ValueState 열거형 문자열("Success", "Warning", "Error", "None", "Information")이어야 합니다. 백엔드 코드는 formatter를 거쳐 변환해야 하며, 변환되지 않은 임의 문자열은 무시되어 None으로 렌더링됩니다.

Q2. titleActive를 true로 했는데 titlePress 이벤트가 발생하지 않습니다.
titleActive는 1.26 이후 지원되며, ObjectListItem 안에 ObjectIdentifier를 넣은 경우 부모 리스트의 type="Active" press와 충돌할 수 있습니다. 이때는 ObjectListItem의 type을 Inactive로 두고 ObjectIdentifier의 titlePress만 사용하거나, 두 핸들러에서 oEvent.preventDefault() 처리를 일관되게 해야 합니다.

Q3. ObjectStatus에 아이콘만 표시하고 텍스트는 숨기고 싶습니다.
text 속성을 비우거나 생략하면 아이콘만 렌더링됩니다. 다만 접근성 측면에서는 시각장애 사용자가 상태를 파악할 수 없으므로 stateAnnouncementTexttooltip을 반드시 설정하는 것이 권장됩니다. 1.110 이상에서는 stateAnnouncementText가 ARIA live region을 통해 스크린 리더로 전달됩니다.

Q4. Indication 색상(Indication01~08)을 썼는데 테마마다 색이 다릅니다.
Indication 색상은 테마(sap_horizon, sap_fiori_3 등)의 LESS 변수로 정의되므로 테마별 색상 매핑이 다릅니다. 색이 비즈니스적으로 중요한 의미를 가진다면(예: 금융 라벨링) 사용자 정의 CSS로 고정하거나 표준 ValueState를 사용하는 것이 안전합니다.

관련 주제 및 확장 학습

두 컨트롤을 익혔다면 다음 주제로 확장해보길 권합니다.

  • sap.m.ObjectNumber — 금액/수량을 강조 표시하는 컨트롤 (state 속성 동일하게 지원)
  • sap.m.ObjectMarker — Favorite/Flagged/Draft/Locked 등 표준 마커 표시
  • sap.ui.table.Table vs sap.m.Table — 대용량 그리드에서 두 컨트롤 활용 차이
  • Fiori Elements의 UI.DataPoint/Criticality 어노테이션으로 동일한 상태 표시를 선언적으로 구현
  • Smart Templates와 Annotation 기반 status 자동 렌더링

레퍼런스 자료

댓글 0

아직 댓글이 없습니다.