UI5

타임라인 직접 구현 그만 — UI5 FeedListItem #shorts #SAP #UI5

▶ YouTube에서 보기

개요 및 이 글에서 다루는 것

sap.m.FeedListItem은 SAPUI5 모바일 라이브러리에서 제공하는 피드/타임라인 전용 리스트 아이템으로, 일반 StandardListItem으로는 표현하기 까다로운 "누가, 언제, 무엇을 말했는가"의 3요소를 한 줄에 자연스럽게 담아냅니다. 이 글에서는 단순 속성 나열을 넘어, 실무에서 자주 마주치는 "구매발주 승인 이력 타임라인"을 예제로 잡고 데이터 모델 설계부터 액션 버튼, 페이지네이션까지 단계적으로 풀어 봅니다.

  • FeedListItem의 핵심 속성(sender, timestamp, text, iconSrc, info)의 동작 원리 이해
  • JSONModel 기반 동적 피드 렌더링 패턴 적용
  • actions 어그리게이션으로 인라인 액션(승인 회수, 코멘트) 구성
  • senderPress/iconPress 이벤트로 사용자 프로필 팝오버 연동
  • growing 속성과 서버 측 페이징을 결합한 대용량 피드 처리 전략

먼저 알아두면 좋은 것

이 글은 SAPUI5 기본 MVC 패턴(View, Controller, JSONModel)과 XML View 문법에 익숙한 독자를 가정합니다. sap.m.List와 일반 아이템 어그리게이션의 차이를 한 번이라도 경험했다면 무리 없이 따라올 수 있습니다. CSS 클래스 커스터마이징은 추가 설명을 곁들이지만, Fiori Design Guideline의 Feed 패턴을 사전에 한 번 훑어보면 디자인 의도가 더 명확하게 보입니다.

환경 / 버전 / 준비물

이 글의 코드는 다음 환경에서 일반적으로 동작이 확인됩니다. 버전이 다르면 일부 속성(예: iconInitials, showIcon)의 기본값이 다르게 동작할 수 있으니 주의하세요.

  • SAPUI5: 1.108 LTS 이상 (1.120 권장, sap.m.FeedListItem 자체는 1.12부터 존재)
  • 런타임: SAP Business Technology Platform, ABAP Platform 2022, 또는 로컬 npm i @sap/ux-ui5-tooling 기반 개발 서버
  • 라이브러리: sap.m, sap.ui.core (Avatar 사용 시 1.73+ 권장)
  • IDE: SAP Business Application Studio 또는 VS Code + UI5 Language Assistant
  • 샘플 데이터: /model/purchaseApproval.json 파일(아래 예제 참조)

구버전 UI5(1.60 미만)에서는 icon 어그리게이션이 sap.ui.core.Icon만 허용하므로, Avatar를 쓰려면 1.73 이상으로 업그레이드하는 편이 일반적으로 유지보수 비용을 낮춥니다.

핵심 개념 — FeedListItem의 해부학

FeedListItem을 처음 접하면 "List 안에 들어가는 또 하나의 ListItem" 정도로 보이지만, 내부 구조는 일반 ListItem과 꽤 다릅니다. 머릿속에 다음과 같은 카드 레이아웃을 그려 보면 이해가 빠릅니다.

[아바타 아이콘] | [발신자(굵게)] · [타임스탬프(작은 회색)]
          | [본문 텍스트 — 자동 줄바꿈/More 링크]
          | [info 라벨(상태)]  [액션 버튼들]

핵심 속성을 역할별로 분류하면 다음과 같습니다.

  • 식별 영역: sender, senderActive, iconSrc, iconActive, iconInitials
  • 시간 표시: timestamp (포맷된 문자열 권장 — 컨트롤은 별도 i18n 처리 안 함)
  • 본문: text, maxCharacters, convertLinksToAnchorTags, convertedLinksDefaultTarget
  • 상태/액션: info, actions 어그리게이션 (sap.m.FeedListItemAction)
  • 이벤트: press, senderPress, iconPress

왜 직접 만들지 않고 FeedListItem을 쓸까?

HBox + VBox + Avatar + Text 조합으로도 비슷한 화면은 만들 수 있습니다. 그러나 FeedListItem은 (1) 본문이 maxCharacters를 초과하면 자동으로 "MORE/LESS" 토글을 생성하고, (2) URL과 이메일을 자동으로 앵커 태그로 변환하며, (3) 접근성(ARIA) 속성을 컨트롤 단에서 부착하고, (4) Fiori 테마(Quartz, Horizon)의 간격·폰트 규약을 자동으로 따릅니다. 즉 비주얼은 같아도 유지보수 비용이 완전히 다릅니다. "Reinvent the wheel"을 피하는 가장 명확한 사례 중 하나입니다.

실전 코드 1단계 — 정적 데이터로 감 잡기

먼저 모델 없이 인라인 속성만으로 FeedListItem 두 개를 그려 봅니다. View와 Controller의 최소 골격입니다.

<mvc:View
    controllerName="kr.demo.po.controller.ApprovalTimeline"
    xmlns="sap.m"
    xmlns:mvc="sap.ui.core.mvc">
    <Page title="구매발주 PO-2026-0431 승인 이력">
        <content>
            <List id="timelineList" showSeparators="Inner">
                <FeedListItem
                    sender="박서준 (구매팀장)"
                    senderActive="true"
                    iconSrc="sap-icon://employee"
                    timestamp="2026-05-28 09:14"
                    info="승인"
                    text="공급사 단가 인하 협상 결과 반영 확인 완료. 다음 단계로 진행 바랍니다." />
                <FeedListItem
                    sender="정하늘 (재무팀)"
                    iconSrc="sap-icon://customer"
                    timestamp="2026-05-28 11:02"
                    info="검토중"
                    text="예산 코드 4410-22 잔여액 충분. 회계연도 마감 전 집행 가능." />
            </List>
        </content>
    </Page>
</mvc:View>

이 단계만으로도 타임라인 형태의 카드가 렌더링됩니다. senderActive="true"로 설정하면 발신자 이름이 하이퍼링크 스타일로 바뀌고 클릭 가능한 상태가 됩니다. info는 우측 상단에 작은 라벨로 표시되며, 컬러는 별도 속성이 없어 일반적으로 ObjectStatus 패턴과 함께 쓰지는 않고 단순 상태 텍스트로 활용합니다.

실전 코드 2단계 — JSONModel 바인딩과 액션, 이벤트

실제 업무에서는 데이터를 백엔드에서 받아 옵니다. 승인 이력 데이터 구조를 정의하고 items 어그리게이션을 바인딩합니다.

{
  "approvalHistory": [
    {
      "approverId": "U10293",
      "approverName": "박서준 (구매팀장)",
      "avatar": "sap-icon://employee",
      "occurredAt": "2026-05-28 09:14",
      "status": "승인",
      "comment": "단가 인하 협상 결과 반영 확인 완료. 회계 검토로 이관합니다."
    },
    {
      "approverId": "U20881",
      "approverName": "정하늘 (재무팀)",
      "avatar": "sap-icon://customer",
      "occurredAt": "2026-05-28 11:02",
      "status": "검토중",
      "comment": "예산 코드 4410-22 잔여 충분, http://intra.example.com/budget/4410 참조."
    }
  ]
}

View에서는 items를 바인딩하고 액션 어그리게이션을 추가합니다.

<List
    id="timelineList"
    items="{/approvalHistory}"
    growing="true"
    growingThreshold="20"
    noDataText="아직 승인 이력이 없습니다.">
    <FeedListItem
        sender="{approverName}"
        senderActive="true"
        iconSrc="{avatar}"
        timestamp="{occurredAt}"
        info="{status}"
        text="{comment}"
        maxCharacters="140"
        convertLinksToAnchorTags="All"
        convertedLinksDefaultTarget="_blank"
        senderPress="onApproverProfileOpen"
        iconPress="onApproverProfileOpen"
        press="onTimelineItemPress">
        <actions>
            <FeedListItemAction
                text="회수 요청"
                icon="sap-icon://undo"
                key="REVOKE"
                press="onApprovalAction" />
            <FeedListItemAction
                text="코멘트 추가"
                icon="sap-icon://discussion"
                key="COMMENT"
                press="onApprovalAction" />
        </actions>
    </FeedListItem>
</List>

Controller 측에서는 이벤트 핸들러와 모델 초기화를 담당합니다.

sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/ui/model/json/JSONModel",
    "sap/m/MessageToast",
    "sap/m/ResponsivePopover",
    "sap/m/Text"
], function (Controller, JSONModel, MessageToast, ResponsivePopover, Text) {
    "use strict";

    return Controller.extend("kr.demo.po.controller.ApprovalTimeline", {

        onInit: function () {
            var oTimelineModel = new JSONModel(
                sap.ui.require.toUrl("kr/demo/po/model/purchaseApproval.json")
            );
            this.getView().setModel(oTimelineModel);
        },

        onApproverProfileOpen: function (oEvent) {
            var oCtx = oEvent.getSource().getBindingContext();
            var sName = oCtx.getProperty("approverName");
            var sId = oCtx.getProperty("approverId");
            MessageToast.show(sName + " (" + sId + ") 프로필 로딩...");
            // 실제로는 사용자 마스터 OData 호출 후 Popover 오픈
        },

        onApprovalAction: function (oEvent) {
            var sKey = oEvent.getSource().getKey();
            var oCtx = oEvent.getSource().getParent().getBindingContext();
            var sApprover = oCtx.getProperty("approverName");

            if (sKey === "REVOKE") {
                MessageToast.show(sApprover + " 의 승인을 회수 요청합니다.");
            } else if (sKey === "COMMENT") {
                MessageToast.show("코멘트 입력 다이얼로그 오픈");
            }
        },

        onTimelineItemPress: function (oEvent) {
            // 상세 패널 열기 — Split-Detail 패턴
            var oCtx = oEvent.getSource().getBindingContext();
            this.getOwnerComponent().getRouter().navTo("ApprovalDetail", {
                approverId: oCtx.getProperty("approverId")
            });
        }
    });
});

senderPressiconPress를 같은 핸들러에 연결한 이유는 UX 일관성 때문입니다. 사용자는 이름이든 아바타든 어디를 누르든 같은 프로필 카드를 기대합니다. actions 어그리게이션의 각 항목은 key로 식별하여 switch 처리하는 것이 일반적으로 깔끔합니다.

실전 코드 3단계 — 프로덕션 레벨: 페이지네이션·포맷터·접근성

이력이 수백 건을 넘어가면 한 번에 다 그리면 안 됩니다. growing 속성과 OData V4의 $skip/$top을 조합하거나, JSONModel이라면 updateFinished 이벤트에서 추가 페치를 트리거합니다. 또한 timestamp는 백엔드 ISO 문자열을 사용자 로케일로 변환하는 포맷터를 분리하는 것이 유지보수에 좋습니다.

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

    var oRelativeFmt = DateFormat.getDateTimeInstance({
        relative: true,
        relativeScale: "auto",
        relativeStyle: "short"
    });

    return {
        relativeTimestamp: function (sIsoDate) {
            if (!sIsoDate) { return ""; }
            var oDate = new Date(sIsoDate);
            return oRelativeFmt.format(oDate);
        },

        statusStateClass: function (sStatus) {
            switch (sStatus) {
                case "승인": return "feedItemApproved";
                case "반려": return "feedItemRejected";
                case "검토중": return "feedItemPending";
                default: return "";
            }
        }
    };
});

View에서는 포맷터를 적용하고, 페이지네이션을 위한 이벤트 훅을 답니다.

<List
    id="timelineList"
    items="{
        path: '/approvalHistory',
        parameters: { '$count': true }
    }"
    growing="true"
    growingThreshold="25"
    growingScrollToLoad="true"
    updateFinished="onTimelineUpdated">
    <FeedListItem
        sender="{approverName}"
        timestamp="{
            path: 'occurredAt',
            formatter: '.formatter.relativeTimestamp'
        }"
        info="{status}"
        text="{comment}"
        iconSrc="{avatar}"
        class="{
            path: 'status',
            formatter: '.formatter.statusStateClass'
        }" />
</List>

Controller에서 updateFinished를 활용해 무한 스크롤 경계 시 추가 데이터 가공을 수행합니다.

onTimelineUpdated: function (oEvent) {
    var iTotal = oEvent.getParameter("total");
    var iActual = oEvent.getParameter("actual");
    var oList = oEvent.getSource();

    // 1. 접근성 — 스크린리더에 현재 표시 건수 알림
    oList.setHeaderText("승인 이력 " + iActual + " / " + iTotal + " 건");

    // 2. 빈 상태 처리
    if (iTotal === 0) {
        this.byId("emptyStateMessage").setVisible(true);
    }

    // 3. 성능 — 100건 이상이면 actions 어그리게이션 lazy 처리 권장
    if (iActual > 100) {
        sap.base.Log.warning("타임라인 100건 초과, 액션 lazy load 권장");
    }
}

CSS에서는 상태별 좌측 컬러 바를 추가해 가독성을 높이는 패턴을 일반적으로 많이 씁니다.

# webapp/css/style.css
.feedItemApproved { border-left: 4px solid #107e3e; }
.feedItemRejected { border-left: 4px solid #bb0000; }
.feedItemPending  { border-left: 4px solid #e9730c; }

흔한 실수 / 트러블슈팅

Q1. timestamp가 "Invalid Date"로 표시됩니다.

FeedListItem의 timestamp 속성은 단순 문자열입니다. 컨트롤이 자동으로 Date 파싱을 해주지 않습니다. 백엔드에서 /Date(1716883200000)/ 같은 OData V2 포맷을 그대로 바인딩하면 그대로 출력됩니다. 반드시 포맷터를 거치거나, OData V4의 Edm.DateTimeOffsetDateFormat으로 한 번 가공해야 합니다.

Q2. senderPress 이벤트가 발생하지 않습니다.

senderActive="true"가 누락된 경우가 가장 흔합니다. 기본값이 true이긴 하지만, 부모 컨테이너의 busy 상태나 enabled="false"로 인해 이벤트가 가로채질 수 있습니다. 또한 press 이벤트와 동시에 등록하면 버블링 우선순위에 따라 의도와 다르게 동작할 수 있어, 둘 중 하나만 쓰거나 oEvent.preventDefault()로 명시적으로 차단하는 편이 일반적으로 안전합니다.

Q3. MORE/LESS 링크가 보이지 않습니다.

maxCharacters의 기본값은 디바이스별로 다릅니다(데스크톱 300, 태블릿 300, 폰 300 정도가 권장값). 본문이 이 값을 넘지 않으면 토글이 생성되지 않습니다. 강제로 보고 싶다면 maxCharacters="50" 처럼 낮춰 테스트하세요. 또한 showIcon="false"일 때 일부 테마에서 들여쓰기가 깨져 토글이 안 보이는 것처럼 보일 수 있는데, 이때는 class="sapUiNoMarginBegin"으로 들여쓰기를 조정합니다.

Q4. actions 버튼이 모바일에서 잘립니다.

액션이 3개 이상이면 좁은 화면에서 오버플로우 메뉴(...)로 자동 묶입니다. 모바일 우선 설계라면 액션을 2개로 제한하고, 그 외는 상세 화면으로 이동시키는 것이 사용자 경험상 일반적으로 더 낫습니다.

관련 주제 및 확장 방향

FeedListItem을 마스터했다면 다음 주제들이 자연스러운 확장 경로입니다. (1) sap.m.FeedInput으로 댓글 입력 UI를 추가해 양방향 피드 완성, (2) sap.suite.ui.commons.Timeline으로 그룹화·필터링이 강력한 엔터프라이즈 타임라인 구축, (3) WebSocket·SAP Event Mesh 연동으로 실시간 피드 푸시, (4) OData V4 $expand를 활용한 첨부파일·멘션 데이터 결합, (5) Fiori Elements의 Object Page Facet으로 타임라인 임베드. 특히 Timeline 컨트롤은 FeedListItem과 API가 유사해 마이그레이션 비용이 낮으므로 요구사항이 복잡해지면 일찍 검토하는 것을 권장합니다.

참고 링크

댓글 0

아직 댓글이 없습니다.