OData 전체 컬럼 요청의 문제점
SAPUI5 애플리케이션을 운영하다 보면 "왜 이렇게 느리지?"라는 질문을 자주 듣게 됩니다. 네트워크 탭을 열어보면 답이 보이는 경우가 많습니다. 판매주문(SalesOrder) 목록을 단순히 ID, 고객명, 금액 세 컬럼만 화면에 표시하는데도 서버는 50개가 넘는 필드(생성자, 변경시각, 통화 상세, 결제조건, 주소 라인 1~5 등)를 통째로 응답합니다. 한 건당 5KB라면 100건 조회 시 500KB가 전송되며, 이 중 실제로 사용자가 보는 데이터는 5% 미만인 경우가 흔합니다.
이 글에서는 OData V2/V4에서 제공하는 $select 시스템 쿼리 파라미터를 활용하여 페이로드를 최소화하고, UI5 바인딩 레이어에서 이를 안전하게 적용하는 실전 패턴을 다룹니다. 이 글을 마치면 다음을 수행할 수 있게 됩니다.
- OData
$select의 동작 원리와 백엔드 부하 감소 메커니즘을 이해 urlParameters옵션을 통한 매니페스트/JS 레벨 적용$expand와 결합한 연관 엔티티 필드 정밀 제어- XML View의
bindItems에서parameters/select선언 - 컬럼 표시 설정에 연동되는 동적
$select구성
이 글을 읽기 전에 알아두면 좋은 것들
이 글은 SAPUI5의 모델/바인딩 개념(sap.ui.model.odata.v2.ODataModel 또는 v4 ODataModel)에 대한 기초 이해를 전제로 합니다. XML View에서 Table/List 컨트롤에 items="{path: ...}" 형태로 aggregation 바인딩을 해본 경험, 그리고 manifest.json의 dataSources와 models 섹션을 수정해본 경험이 있다면 충분합니다. OData 프로토콜의 EntitySet, Navigation Property 용어에 익숙하면 4번 섹션을 빠르게 따라올 수 있습니다.
실습 환경과 준비물
이 글의 예제는 다음 환경을 기준으로 작성되었습니다.
- SAPUI5 1.120 LTS (1.108 이상이면 동일하게 동작)
- OData V2 백엔드: SAP S/4HANA 2023 또는 SAP Gateway 7.5 이상
- OData V4 예시 일부: SAP Cloud Application Programming Model(CAP) 7.x
- 개발 도구: SAP Business Application Studio 또는 VS Code + ui5-tooling
- 브라우저: Chromium 기반(네트워크 페이로드 비교용)
백엔드가 $select를 지원하는지 먼저 확인이 필요합니다. SAP Gateway 서비스의 경우 $metadata에 정의된 EntitySet은 일반적으로 $select를 지원하지만, 일부 커스텀 CDS View나 Function Import는 무시되거나 부분 지원될 수 있습니다. 실 운영 적용 전 Postman으로 직접 호출하여 응답 필드가 줄어드는지 검증하는 것을 권장합니다.
$select 시스템 쿼리 파라미터의 동작 원리
OData 사양에서 $select는 서버가 응답에 포함할 프로퍼티를 클라이언트가 명시적으로 선택할 수 있게 해주는 시스템 쿼리 옵션입니다. URL 예시는 다음과 같습니다.
GET /sap/opu/odata/sap/ZSALES_SRV/SalesOrderSet?$select=SalesOrderID,CustomerName,GrossAmount&$top=50
비유하자면, 백엔드는 거대한 뷔페 테이블입니다. 기본 호출은 "1번 손님 전체 테이블 한 번에 주세요"라고 외치는 것과 같고, $select는 "샐러드, 스테이크, 와인만 주세요"라고 정확히 주문하는 것과 같습니다. 주방(DB) → 서빙(네트워크) → 식탁(클라이언트 메모리) 모든 단계에서 부하가 줄어듭니다.
특히 SAP Gateway의 경우 $select가 DB 레벨까지 전파되는지는 DPC_EXT 클래스의 GET_ENTITYSET 구현에 달려 있습니다. CDS View 기반 서비스는 보통 SELECT 절 최적화가 자동으로 이루어지며, 컬럼 수가 줄어들면 인덱스 활용도 향상으로 응답 시간이 단축됩니다. UI5 클라이언트 측에서는 모델 캐시 메모리 점유율 감소, 데이터 바인딩 디퍼링 시간 단축, JSON 파싱 비용 절감의 세 가지 이득이 있습니다.
주의할 점은 $select로 제외된 필드는 모델에 존재하지 않는다는 사실입니다. 이후 컨트롤러에서 oContext.getProperty("DeliveryDate")처럼 호출하면 undefined가 반환됩니다. 즉, 필드 사용처를 코드 전체에서 추적해야 안전하게 적용할 수 있습니다.
1단계 — urlParameters로 기본 적용하기
가장 단순한 형태는 모델 인스턴스 생성 시 또는 read 호출 시 urlParameters에 $select를 넣는 방식입니다. 구매주문(PurchaseOrder) 목록을 조회하는 컨트롤러 예제를 보겠습니다.
sap.ui.define([
"sap/ui/core/mvc/Controller"
], function (Controller) {
"use strict";
return Controller.extend("com.acme.po.controller.PoList", {
onInit: function () {
const oModel = this.getOwnerComponent().getModel();
oModel.read("/PurchaseOrderSet", {
urlParameters: {
"$select": "PurchaseOrderID,SupplierName,OrderDate,NetAmount,Status",
"$top": "100",
"$orderby": "OrderDate desc"
},
success: (oData) => {
this.getView().setModel(
new sap.ui.model.json.JSONModel(oData.results),
"poList"
);
},
error: (oError) => {
sap.ui.require(["sap/m/MessageToast"], (MessageToast) => {
MessageToast.show("주문 조회 실패: " + oError.message);
});
}
});
}
});
});
위 코드는 5개 필드만 요청합니다. 동일한 EntitySet이 백엔드에서 30개 필드를 가진다면 페이로드는 약 1/6 수준으로 줄어듭니다. 적용 후 반드시 Chrome DevTools의 Network 탭에서 응답 본문 크기를 측정하여 효과를 확인하세요.
2단계 — $expand와 결합한 정밀 제어 + 로깅
실무에서는 단일 엔티티만 조회하는 경우가 드뭅니다. 판매주문과 그 하위 아이템(SalesOrderItem), 그리고 고객 정보(Customer)까지 한 번에 가져오는 시나리오를 보겠습니다. $expand로 연관 엔티티를 펼치면서, $select로 각 엔티티의 필드도 동시에 제한해야 진짜 최적화가 됩니다.
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/base/Log"
], function (Controller, Log) {
"use strict";
return Controller.extend("com.acme.so.controller.SoDetail", {
_loadSalesOrder: function (sSalesOrderId) {
const oModel = this.getOwnerComponent().getModel();
const sPath = `/SalesOrderSet('${sSalesOrderId}')`;
const aSelectFields = [
"SalesOrderID",
"GrossAmount",
"CurrencyCode",
"ToCustomer/CustomerID",
"ToCustomer/CompanyName",
"ToCustomer/CountryCode",
"ToItems/ItemPosition",
"ToItems/ProductID",
"ToItems/Quantity",
"ToItems/NetAmount"
];
const tStart = performance.now();
oModel.read(sPath, {
urlParameters: {
"$expand": "ToCustomer,ToItems",
"$select": aSelectFields.join(",")
},
success: (oData) => {
const tElapsed = (performance.now() - tStart).toFixed(1);
Log.info(
`[SoDetail] ${sSalesOrderId} 로드 완료 (${tElapsed}ms, items=${oData.ToItems.results.length})`,
null,
"com.acme.so"
);
this.getView().setModel(
new sap.ui.model.json.JSONModel(oData),
"soDetail"
);
},
error: (oError) => {
Log.error(
`[SoDetail] 조회 실패: ${oError.statusCode} ${oError.message}`,
oError.responseText,
"com.acme.so"
);
}
});
}
});
});
핵심은 ToCustomer/CustomerID처럼 슬래시 표기로 Navigation Property의 필드까지 명시한다는 점입니다. $expand=ToCustomer만 쓰고 $select에 고객 필드를 명시하지 않으면 고객 엔티티는 여전히 전체 필드가 반환되어 최적화 효과가 반감됩니다. performance.now()로 시간 측정 로그를 남겨두면, 운영 환경에서 회귀 발생 시 디버깅이 빠릅니다.
3단계 — bindItems와 동적 select(프로덕션 패턴)
XML View에서 List/Table을 선언적으로 바인딩할 때는 parameters 블록에 select를 넣습니다. 더 나아가 사용자 개인화 설정(p13n)에 따라 표시 컬럼이 동적으로 바뀌는 경우, JS에서 컬럼 정의를 읽어 $select를 재구성하는 패턴이 필요합니다.
<mvc:View
xmlns:mvc="sap.ui.core.mvc"
xmlns="sap.m"
controllerName="com.acme.so.controller.SoList">
<Table id="soTable"
items="{
path: '/SalesOrderSet',
parameters: {
select: 'SalesOrderID,CustomerName,GrossAmount,Status',
expand: 'ToCustomer',
$count: true
},
length: 50
}">
<columns>
<Column><Text text="주문번호"/></Column>
<Column><Text text="고객"/></Column>
<Column><Text text="금액"/></Column>
<Column><Text text="상태"/></Column>
</columns>
<items>
<ColumnListItem>
<cells>
<Text text="{SalesOrderID}"/>
<Text text="{CustomerName}"/>
<ObjectNumber number="{GrossAmount}" unit="{CurrencyCode}"/>
<ObjectStatus text="{Status}"/>
</cells>
</ColumnListItem>
</items>
</Table>
</mvc:View>
이어서 사용자가 컬럼 표시 설정을 바꾸면 동적으로 바인딩을 재구성합니다. 다음은 보안과 성능을 함께 고려한 패턴입니다.
sap.ui.define([
"sap/ui/core/mvc/Controller",
"sap/ui/model/Sorter"
], function (Controller, Sorter) {
"use strict";
// 화이트리스트 — 임의 컬럼명 주입 방지
const ALLOWED_FIELDS = new Set([
"SalesOrderID", "CustomerName", "GrossAmount",
"CurrencyCode", "Status", "OrderDate", "DeliveryStatus"
]);
const MANDATORY_FIELDS = ["SalesOrderID"]; // 키 필드는 항상 포함
return Controller.extend("com.acme.so.controller.SoList", {
applyVisibleColumns: function (aUserColumns) {
const aSafeFields = aUserColumns.filter(f => ALLOWED_FIELDS.has(f));
const aFinal = Array.from(new Set([...MANDATORY_FIELDS, ...aSafeFields]));
const oTable = this.byId("soTable");
const oBinding = oTable.getBinding("items");
oBinding.changeParameters({
select: aFinal.join(","),
expand: "ToCustomer"
});
}
});
});
핵심 포인트는 세 가지입니다. 첫째, changeParameters는 기존 바인딩을 파괴하지 않고 쿼리만 갱신하므로 스크롤 위치 등 UI 상태가 유지됩니다. 둘째, ALLOWED_FIELDS 화이트리스트로 사용자 입력이 그대로 OData URL에 흘러들어가는 것을 차단합니다. 셋째, 키 필드(SalesOrderID)는 강제로 포함시켜야 OData 모델이 엔티티를 식별할 수 있습니다 — 이를 누락하면 "Cannot find entity" 류의 오류가 발생합니다.
실제로 부딪히는 문제와 해결
Q1. $select를 적용했는데 응답 크기가 그대로입니다. 백엔드가 무시한 경우입니다. CDS View가 아닌 클래식 BAPI 래퍼 서비스이거나, DPC 클래스가 사양을 구현하지 않은 경우 발생합니다. Postman으로 직접 ?$select=Field1을 호출해 검증하세요. 일부 SAP Gateway 서비스는 $select 무시가 명시되어 있을 수 있습니다.
Q2. 컨트롤러에서 oContext.getProperty("DeliveryDate")가 undefined를 반환합니다. 해당 필드가 $select에서 누락된 것입니다. 모든 사용처를 검색해 select 목록을 보정하거나, 디테일 화면에서 다시 read하는 방식으로 분리하세요. 목록 화면은 가볍게, 상세 화면에서 풀 페치 패턴이 일반적으로 권장됩니다.
Q3. $expand를 추가했더니 timeout이 발생합니다. 펼치는 연관이 많으면 백엔드 JOIN 비용이 폭증합니다. $expand는 꼭 필요한 경우에만 쓰고, 1:N 관계에서 N이 큰 경우 별도 호출이 더 빠를 수 있습니다. 백엔드 ST05 트레이스로 SQL을 확인하는 것을 권장합니다.
Q4. v4 모델에서 동일하게 동작하나요? OData V4에서는 $expand 내부에 $select를 중첩하는 문법이 정식입니다 — 예: $expand=ToItems($select=ItemPosition,Quantity). UI5 v4 ODataModel은 자동 처리해주는 부분이 있으므로 직접 URL을 구성하기보다 바인딩 파라미터를 사용하는 편이 안전합니다.
여기서 더 깊이 들어가려면
이 글을 마쳤다면 다음 주제로 확장해보길 권합니다. (1) $filter와 결합한 서버 사이드 필터링 전략, (2) $count=true와 $inlinecount를 활용한 페이지네이션 정확도 개선, (3) sap-value-list 어노테이션과 ValueHelp의 select 튜닝, (4) sap.ui.model.odata.v4의 그룹 요청(Batch + GroupId)으로 다중 호출 묶기. 특히 그룹 요청은 select 최적화와 시너지가 매우 큽니다 — 작은 요청 여러 개를 묶어 하나의 HTTP 왕복으로 처리하면 select가 줄여준 페이로드 효과가 배가됩니다.
더 읽어볼 만한 자료
댓글 0
아직 댓글이 없습니다.