RAP

헤더-아이템 따로 조회 그만 — $expand 3단계 #shorts #SAP #RAP

▶ YouTube에서 보기

개요와 이 글에서 얻어갈 것

OData 서비스를 개발하면서 헤더와 아이템을 두 번씩 나눠 호출하고 있다면, 이는 네트워크 왕복(round-trip)을 두 배로 소비하는 안티패턴입니다. 실제로 판매 주문 500건을 화면에 뿌리려고 헤더 GET → 아이템 GET을 순차 호출하면 500 + 1 = 501회 요청이 발생합니다. RAP(RESTful ABAP Programming Model)에서 Navigation Property$expand를 올바르게 선언하면, 한 번의 호출로 헤더-아이템 트리를 통째로 받아올 수 있습니다.

이 글에서는 판매 주문(SalesOrder) - 주문 아이템(SalesOrderItem) 구조를 예로 삼아, 다음 3단계로 확장 조회를 구성합니다.

  • Association과 Composition의 차이 이해, Behavior Definition의 with draft 여부에 따른 동작 파악
  • Service Definition에서 expose 대상과 노출 범위(root vs child) 지정
  • $expand, 중첩 $expand, $expand 내부의 $filter/$select/$top 조합
  • Production 환경 성능 튜닝(SADL 최적화, N+1 회피, 필드 셀렉션 제한)

먼저 알아둘 것

ABAP 클래스와 CDS View 기본 문법, RAP의 Managed/Unmanaged 시나리오 개념, OData V4 URL 규약($filter, $select, $top)에 대한 기초 이해가 필요합니다. ADT(ABAP Development Tools) 3.30 이상에서 CDS Behavior Definition을 작성해 본 경험이 있으면 실습을 바로 따라올 수 있습니다.

환경, 릴리스, 준비물

실습은 SAP BTP ABAP Environment 2308 릴리스 또는 S/4HANA 2022 On-Premise(ABAP Platform 2022) 이상에서 검증되었습니다. OData V4 프로토콜을 사용하며, Fiori Elements 프리뷰가 아닌 /sap/opu/odata4/ 엔드포인트를 직접 호출해 응답 구조를 확인합니다.

  • ABAP Platform 2022 (또는 BTP ABAP Environment 2308+)
  • ADT(Eclipse) 2023-03 이상
  • 테스트 도구: SAP Gateway Client(/n/IWFND/GW_CLIENT) 또는 Postman, Bruno
  • 실습용 두 개의 데이터베이스 테이블 zsim_so_head(주문 헤더), zsim_so_item(주문 아이템). 두 테이블은 SalesOrderId 필드로 1:N 관계

모든 개발 오브젝트는 로컬 패키지($TMP)에 생성해도 무방하지만, 서비스 바인딩을 테스트하려면 전송 가능한 패키지가 권장됩니다. 예제 접두어는 ZSIM_로 통일합니다.

핵심 개념 — Association, Composition, Navigation Property

OData의 $expand는 그냥 얹어지는 옵션이 아니라, 모델(Metadata)에 선언된 Navigation Property가 있어야 동작합니다. RAP에서 이 Navigation Property는 CDS의 두 가지 관계 선언 중 하나에서 태어납니다.

  • Association: 단순 참조 관계. 예를 들어 판매 주문이 고객 마스터를 참조하는 관계. 삭제/생성 라이프사이클이 독립적임.
  • Composition: 부모-자식 강한 소유 관계. 부모가 지워지면 자식도 함께 지워짐. 판매 주문(부모)과 주문 아이템(자식)이 대표적인 예.

비유하자면 Association은 이웃집을 손가락으로 가리키는 화살표이고, Composition은 내 지갑 안의 신용카드입니다. 지갑이 사라지면 안의 카드도 사라져야 자연스럽죠. RAP Behavior Definition에서 헤더-아이템처럼 트랜잭션이 함께 커밋되어야 하는 경우에는 반드시 Composition으로 선언해야 Create by Association이 활성화됩니다.

선언된 관계는 Service Definition에서 expose되어야 OData Metadata의 <NavigationProperty>로 최종 등장합니다. 이때 부모만 expose하고 자식을 빼먹으면 $expand=_Items 요청이 400 Bad Request — Property '_Items' is not a valid navigation property로 튕겨나옵니다. 즉 관계 선언 → Behavior 등록 → Service Definition 노출, 이 세 단계가 모두 맞아야 $expand가 살아납니다.

정리: $expand는 URL 트릭이 아니라 메타데이터 계약입니다. Composition/Association이 그 계약의 뼈대이고, Service Definition의 expose가 도장입니다.

실전 코드 1단계 — 기본 헤더/아이템 관계와 첫 $expand

먼저 두 개의 데이터 소스 CDS View를 만들고, 부모 뷰에서 자식 뷰를 Composition으로 선언합니다.

// 자식(아이템) 뷰 — ZSIM_R_SalesOrderItem
@AccessControl.authorizationCheck: #NOT_REQUIRED
define view entity ZSIM_R_SalesOrderItem
  as select from zsim_so_item
  association to parent ZSIM_R_SalesOrder
    on $projection.SalesOrderId = _Parent.SalesOrderId
{
  key sales_order_id  as SalesOrderId,
  key item_pos        as ItemPosition,
      product_id      as ProductId,
      quantity        as Quantity,
      net_amount      as NetAmount,
      currency_code   as CurrencyCode,
      _Parent
}
// 부모(헤더) 뷰 — ZSIM_R_SalesOrder
@AccessControl.authorizationCheck: #NOT_REQUIRED
define root view entity ZSIM_R_SalesOrder
  as select from zsim_so_head
  composition [0..*] of ZSIM_R_SalesOrderItem as _Items
{
  key sales_order_id  as SalesOrderId,
      customer_id     as CustomerId,
      order_date      as OrderDate,
      overall_status  as OverallStatus,
      total_amount    as TotalAmount,
      currency_code   as CurrencyCode,
      _Items
}

Projection View, Behavior Definition, Service Definition, Service Binding을 순서대로 생성한 뒤 서비스 활성화까지 마치면 다음과 같이 호출할 수 있습니다.

GET /sap/opu/odata4/sap/zsim_so_srv/srvd/sap/zsim_so_srv/0001/SalesOrder?$expand=_Items&$top=3

응답은 헤더 배열 안에 _Items 키가 중첩되어 내려옵니다. 이때 $metadata를 열어보면 <NavigationProperty Name="_Items" Type="Collection(...)" ContainsTarget="true"/>가 노출되어 있어야 정상입니다. ContainsTargettrue인 이유가 바로 Composition으로 선언했기 때문입니다.

실전 코드 2단계 — 필드 셀렉션·필터 결합과 에러 처리

실무에서는 전체 컬럼을 다 받아오면 트래픽이 커지므로, $expand 내부에 $select$filter를 함께 사용하는 것이 표준입니다. 예를 들어 상태가 OPEN인 주문 10건에 한해, 각 주문의 아이템 중 수량이 5개 이상인 것만 조회하고 싶다면 아래처럼 요청합니다.

GET SalesOrder?
  $filter=OverallStatus eq 'OPEN'
  &$top=10
  &$select=SalesOrderId,CustomerId,TotalAmount
  &$expand=_Items(
      $select=ItemPosition,ProductId,Quantity;
      $filter=Quantity ge 5;
      $orderby=ItemPosition
   )

OData V4는 $expand 안에 세미콜론으로 여러 옵션을 붙일 수 있습니다. 이때 서비스가 $filter within $expand를 지원하려면 Projection View 계층이 SADL(Service Adaptation Description Language) 엔진에 위임되어 있어야 합니다. Managed RAP에서는 기본적으로 지원되며, Unmanaged 시나리오에서 read 구현을 직접 하는 경우에는 io_pagingit_filter_cond를 파싱해서 SQL로 반영해야 합니다.

Behavior Implementation에서 아이템 읽기 로직을 명시적으로 구현할 때의 스켈레톤은 다음과 같습니다. 로깅과 예외 처리를 함께 넣었습니다.

METHOD read.

  DATA lt_items TYPE TABLE OF zsim_so_item.

  TRY.
      SELECT FROM zsim_so_item
        FIELDS sales_order_id,
               item_pos,
               product_id,
               quantity,
               net_amount,
               currency_code
        FOR ALL ENTRIES IN @keys
        WHERE sales_order_id = @keys-SalesOrderId
        INTO CORRESPONDING FIELDS OF TABLE @lt_items.

      result = VALUE #( FOR ls IN lt_items
        ( %tky-SalesOrderId = ls-sales_order_id
          %tky-ItemPosition = ls-item_pos
          ProductId         = ls-product_id
          Quantity          = ls-quantity
          NetAmount         = ls-net_amount
          CurrencyCode      = ls-currency_code ) ).

    CATCH cx_sy_open_sql_db INTO DATA(lx_sql).
      " 개발자 로그에 원인 남기고, 클라이언트에는 일반화된 메시지
      cl_bali_log_writer=>write(
        object    = 'ZSIM_SO'
        subobject = 'READ_ITEMS'
        text      = lx_sql->get_text( ) ).

      APPEND VALUE #(
        %msg = new_message(
                 id       = 'ZSIM_SO'
                 number   = '001'
                 severity = if_abap_behv_message=>severity-error
                 v1       = 'Item read failed' ) )
        TO reported-salesorderitem.
  ENDTRY.

ENDMETHOD.

포인트는 세 가지입니다. 첫째, FOR ALL ENTRIES로 부모 키를 한 번에 묶어서 조회해 N+1 문제를 원천 차단합니다. 둘째, DB 예외를 잡되 원문 메시지를 그대로 클라이언트에 반환하지 않고 reported 테이블에 안전 메시지를 넣습니다. 셋째, 원인 추적은 별도 로그(cl_bali_log_writer)로 남겨 감사 추적을 확보합니다.

실전 코드 3단계 — 중첩 $expand와 프로덕션 최적화

실무에서 자주 만나는 시나리오는 주문 → 아이템 → 배송 스케줄처럼 3단 이상의 트리를 한 번에 내려받는 경우입니다. 아이템 아래에 _Schedule Composition을 하나 더 두었다면 중첩 $expand가 가능합니다.

GET SalesOrder?
  $select=SalesOrderId,CustomerId,TotalAmount
  &$expand=_Items(
      $select=ItemPosition,ProductId,Quantity;
      $expand=_Schedule(
          $select=ScheduleLine,DeliveryDate,ConfirmedQty;
          $orderby=DeliveryDate
      )
   )
  &$top=20

이 요청 하나로 최대 20 × N × M 건의 데이터를 트리 구조로 받게 되므로, 세 가지 최적화를 반드시 걸어야 합니다.

  • 필드 프로젝션 최소화 — Projection View 단계에서 실제로 쓰지 않는 필드를 노출하지 않기. @UI.hidden이 아니라 아예 select 절에서 제외.
  • 인덱스와 정렬 키 정렬 — 자식 테이블은 반드시 부모 키(sales_order_id) 기준 인덱스를 확보. HANA에서는 CREATE INDEX ... ON zsim_so_item(sales_order_id)로 강제.
  • 페이징 강제$top을 넣지 않은 요청에 대해 서비스 측 default page size를 지정. Service Binding에서 /IWBEP/CL_MGW_PUSH_ABS_DATA 대신 SADL 기반 기본값을 사용하되, Behavior Implementation에서 io_paging->get_page_size( )를 확인해 상한선(예: 500)을 넘지 못하게 방어.

단위 테스트는 CL_CDS_TEST_ENVIRONMENT로 CDS View를 격리해 검증하고, RAP Behavior는 CL_ABAP_BEHAVIOR_TEST_ENVIRONMENT를 활용합니다. 아래는 헤더-아이템 함께 읽기가 되는지를 확인하는 최소 골격입니다.

CLASS ltc_expand_read DEFINITION FOR TESTING
  RISK LEVEL HARMLESS DURATION SHORT.
  PRIVATE SECTION.
    CLASS-DATA env TYPE REF TO if_cds_test_environment.
    CLASS-METHODS class_setup.
    METHODS expand_returns_items FOR TESTING.
ENDCLASS.

CLASS ltc_expand_read IMPLEMENTATION.
  METHOD class_setup.
    env = cl_cds_test_environment=>create(
            i_for_entity = 'ZSIM_R_SALESORDER' ).
  ENDMETHOD.

  METHOD expand_returns_items.
    env->insert_test_data( VALUE zsim_so_head_tab(
      ( sales_order_id = '4711' customer_id = 'C001'
        overall_status = 'OPEN' total_amount = '150.00' ) ) ).
    env->insert_test_data( VALUE zsim_so_item_tab(
      ( sales_order_id = '4711' item_pos = '10'
        product_id = 'P-A' quantity = 5 net_amount = '150.00' ) ) ).

    SELECT SINGLE FROM zsim_r_salesorder
      FIELDS sales_order_id
      WHERE sales_order_id = '4711'
      INTO @DATA(lv_id).

    cl_abap_unit_assert=>assert_equals(
      exp = '4711' act = lv_id ).
  ENDMETHOD.
ENDCLASS.

보안 관점에서는 @AccessControl.authorizationCheck: #CHECK를 켜고 DCL(Access Control) 규칙을 부모/자식 양쪽에 걸어야 합니다. 부모만 인가 체크가 걸려 있으면, 자식 컬렉션을 통해 우회 조회가 발생할 여지가 있습니다.

자주 마주치는 실수와 해결법

Q1. $expand=_Items를 붙였더니 400 Property is not a valid navigation property가 뜹니다.
A. Service Definition에 자식 엔티티가 expose되지 않았거나, Projection View의 _Items element를 redirected to composition child로 재선언하지 않았을 가능성이 높습니다. Projection에서 _Items : redirected to composition child ZSIM_C_SalesOrderItem 구문을 확인하세요.

Q2. $expand는 되지만 자식이 항상 빈 배열로 옵니다.
A. Composition의 ON 조건에서 $projection.SalesOrderId = _Parent.SalesOrderId가 실제 데이터 타입/길이와 일치하는지 확인합니다. CHAR(10) vs NUMC(10) 불일치로 조인이 항상 실패하는 경우가 흔합니다. 또한 자식 뷰에 association to parent가 빠져 있으면 SADL이 트리를 인식하지 못합니다.

Q3. 대량 조회에서 응답 시간이 급격히 느려집니다.
A. 세 가지를 순서대로 점검하세요. (1) ST05 SQL Trace로 자식 조회가 부모 건별로 반복되는지 확인, (2) 자식 테이블에 부모 키 인덱스가 있는지 확인, (3) Projection View에 @ObjectModel.$expand.defaults로 자식 조회 상한이 제한되어 있는지 확인. 특히 Unmanaged 구현에서 read 메서드가 부모 키 하나씩 SELECT를 돌리고 있다면 반드시 FOR ALL ENTRIES 또는 RANGE로 묶어야 합니다.

추가로 자주 놓치는 점 하나. $expand 내부의 $top부모 각각에 대해 적용됩니다. 즉 $expand=_Items($top=5)는 부모 1건당 아이템 5개씩이라는 뜻이지, 전체 아이템 5개가 아닙니다. 클라이언트 페이지네이션 설계 시 착각하기 쉬운 부분이니 주의합니다.

더 파볼 만한 인접 주제

이번 글에서 다룬 헤더-아이템 확장 조회가 익숙해졌다면 다음 세 방향으로 확장해 볼 수 있습니다. 첫째, Draft-Enabled RAP 시나리오에서 $expand가 활성 인스턴스와 드래프트 인스턴스 각각에 어떻게 동작하는지. 둘째, Value HelpText Association$expand로 함께 내려받아 Fiori Elements List Report의 성능을 개선하는 패턴. 셋째, OData V4 Batch($batch)와의 조합으로 여러 $expand 요청을 하나의 HTTP 트랜잭션으로 묶는 방법. 특히 Batch + Expand는 모바일 클라이언트의 초기 로딩 시간을 줄이는 핵심 기법입니다.

더 읽어볼 링크

댓글 0

아직 댓글이 없습니다.