RAP

SELECT 없이 외부 API OData 연결 — 핵심 3가지 #shorts #SAP #RAP

이 글에서 다루는 내용과 도달 지점

RAP(RESTful ABAP Programming Model)에서 대부분의 서비스는 데이터베이스 테이블 또는 CDS View를 기반으로 만들어집니다. 그러나 실무에서는 SAP 시스템 외부의 REST API, 파일, 메시지 큐 같은 비관계형 소스를 OData로 노출해야 하는 경우가 자주 생깁니다. 이 글에서는 Custom CDS EntityCustom Query Provider 클래스를 조합해 SELECT 문 없이 외부 데이터를 Fiori/OData 클라이언트에 그대로 흘려보내는 구성을 30초 안에 이해할 수 있도록 정리합니다.

  • Custom Entity와 Abstract Entity의 차이 및 선택 기준 이해
  • IF_RAP_QUERY_PROVIDER 인터페이스 구현 흐름 파악
  • io_request에서 필터/정렬/페이징 정보를 꺼내 외부 호출로 매핑
  • 외부 물류 API 응답을 OData 결과로 변환하는 실전 예제 완성
  • 성능·에러 처리·인증 관점의 운영 고려사항 습득

먼저 알고 있어야 할 배경

ABAP CDS의 기본 문법과 @ObjectModel / @UI 어노테이션 사용 경험, RAP Service Definition/Binding 개념, 그리고 ABAP 클래스 작성과 인터페이스 구현에 익숙해야 합니다. 추가로 HTTP Client(if_web_http_client) 또는 Communication Arrangement를 통한 외부 호출 경험이 있으면 이해가 빠릅니다. OData V4의 $filter, $top, $skip, $orderby 구조도 알아두는 것이 좋습니다.

사용 환경과 준비물

이 글의 예제는 다음 환경을 전제로 합니다. 온프레미스 S/4HANA에서도 유사하게 동작하지만, Custom Entity + Query Provider 조합은 Cloud 계열 스택에서 더 안정적으로 지원됩니다.

  • SAP BTP ABAP Environment (Steampunk) 또는 S/4HANA Cloud, Private Edition 2022 이상
  • ABAP Development Tools (ADT) for Eclipse 2024-03 이상
  • Communication Arrangement / Destination Service를 통한 외부 REST 엔드포인트 접근 권한
  • 테스트용 외부 API: 가상의 ShipmentTracker REST 서비스 (JSON 응답)
  • Fiori Elements Preview 또는 Service Binding preview (BTP 환경)

사전에 Outbound Communication Arrangement를 하나 만들어 외부 URL과 인증(OAuth2 Client Credentials 또는 Basic)을 등록해 두면 실습이 매끄럽습니다.

핵심 개념 — Custom Entity와 Query Provider의 관계

일반 CDS View는 데이터베이스 테이블을 SELECT로 조회해 결과를 반환합니다. 반면 Custom Entity는 "필드 구조만 CDS로 정의하고, 실제 데이터 공급은 ABAP 클래스에 위임"하는 특수한 아티팩트입니다. 즉, CDS는 스키마 계약(Contract)이 되고, ABAP 클래스는 데이터 소스 어댑터(Adapter)가 됩니다.

비유하자면 Custom Entity는 메뉴판이고, Query Provider는 주방입니다. 손님(OData 클라이언트)이 어떤 요리를 얼마나 시키든, 주방은 재료를 어디서 가져오든 자유롭게 결정할 수 있습니다.

여기서 자주 혼동되는 것이 Abstract Entity입니다. Abstract Entity 역시 데이터 소스가 없지만, 이는 파라미터 구조 정의나 액션 반환 타입 같은 타입 선언 용도로 쓰입니다. 반면 Custom Entity는 @ObjectModel.query.implementedBy 어노테이션으로 실제 조회 로직을 연결할 수 있는 점이 결정적 차이입니다. 따라서 "외부 API를 OData로 노출"이라는 요구에는 Custom Entity가 적합합니다.

Query Provider는 IF_RAP_QUERY_PROVIDER 인터페이스를 구현한 글로벌 클래스로, 단 하나의 메서드 SELECT를 가집니다. 이 메서드는 두 개의 파라미터를 받습니다.

  • io_request: OData 요청 컨텍스트. 요청된 필드, 필터 조건, 정렬 키, 페이징(top/skip), $search, 집계 요청 등을 표준화된 형태로 조회할 수 있습니다.
  • io_response: 응답 컨테이너. 최종 결과 내부 테이블과 총 건수(set_total_number_of_records)를 세팅하는 채널입니다.

흐름은 다음과 같습니다. 클라이언트가 GET /ShipmentTracking?$filter=Status eq 'IN_TRANSIT'&$top=20을 호출하면, RAP 프레임워크는 이 요청을 io_request 객체에 채워 Query Provider의 SELECT를 호출합니다. 개발자는 필터를 파싱해 외부 REST API 호출 파라미터로 변환하고, JSON 응답을 파싱해 io_response->set_data( ... )로 돌려주면 끝입니다. 데이터베이스 SELECT는 단 한 번도 실행되지 않습니다.

실전 예제 1단계 — 최소 골격으로 감 잡기

먼저 배송 상태 하나만 정적으로 반환하는 최소 예제부터 만들어 봅니다. 외부 API 호출은 잠시 미루고, 인터페이스 구조가 어떻게 동작하는지 확인하는 것이 목적입니다.

@EndUserText.label: 'Shipment Tracking Custom Entity'
@ObjectModel.query.implementedBy: 'ABAP:ZCL_SHIPMENT_QUERY'
@UI.headerInfo.typeName: 'Shipment'
define custom entity ZC_ShipmentTracking
{
  key TrackingNumber : abap.char(20);
      CarrierCode    : abap.char(10);
      Status         : abap.char(15);
      LastLocation   : abap.char(60);
      LastUpdatedAt  : abap.dats;
      DeliveryEta    : abap.dats;
}

@ObjectModel.query.implementedBy가 핵심입니다. 이 어노테이션이 Custom Entity와 ABAP 클래스를 묶어주는 다리 역할을 합니다. 이어서 뼈대만 있는 Query Provider 클래스를 작성합니다.

CLASS zcl_shipment_query DEFINITION
  PUBLIC FINAL CREATE PUBLIC.
  PUBLIC SECTION.
    INTERFACES if_rap_query_provider.
ENDCLASS.

CLASS zcl_shipment_query IMPLEMENTATION.
  METHOD if_rap_query_provider~select.
    DATA lt_result TYPE STANDARD TABLE OF zc_shipmenttracking.

    lt_result = VALUE #(
      ( trackingnumber = 'KR2026070800001'
        carriercode    = 'CJ'
        status         = 'IN_TRANSIT'
        lastlocation   = 'Incheon Hub'
        lastupdatedat  = sy-datum
        deliveryeta    = sy-datum + 2 ) ).

    io_response->set_total_number_of_records( lines( lt_result ) ).
    io_response->set_data( lt_result ).
  ENDMETHOD.
ENDCLASS.

이 상태에서 Service Definition에 expose ZC_ShipmentTracking;를 추가하고 Service Binding을 활성화하면, OData 엔드포인트에 접속했을 때 하드코딩된 한 건의 데이터가 반환됩니다. 아직 필터도, 외부 호출도 없지만 뼈대는 완성입니다.

실전 예제 2단계 — 필터 파싱과 외부 REST 호출

이제 실제 ShipmentTracker API를 호출해 봅니다. 요구사항은 다음과 같습니다.

  1. $filter=CarrierCode eq 'CJ' 처럼 들어온 조건을 외부 API의 쿼리 파라미터로 변환
  2. $top/$skip은 외부 API의 페이지 크기/오프셋으로 매핑
  3. 외부 API 호출 실패 시 로그를 남기고 cx_rap_query_provider로 예외 전파
METHOD if_rap_query_provider~select.
  DATA: lt_filter_cond TYPE if_rap_query_filter=>tt_name_range_pairs,
        lv_top         TYPE i,
        lv_skip        TYPE i,
        lv_carrier     TYPE string,
        lt_result      TYPE STANDARD TABLE OF zc_shipmenttracking.

  TRY.
      lv_top  = io_request->get_paging( )->get_page_size( ).
      lv_skip = io_request->get_paging( )->get_offset( ).

      lt_filter_cond = io_request->get_filter( )->get_as_ranges( ).
      LOOP AT lt_filter_cond ASSIGNING FIELD-SYMBOL(<fs_cond>)
           WHERE name = 'CARRIERCODE'.
        READ TABLE <fs_cond>-range INTO DATA(ls_range) INDEX 1.
        lv_carrier = ls_range-low.
      ENDLOOP.

      DATA(lo_client) = cl_web_http_client_manager=>create_by_http_destination(
        cl_http_destination_provider=>create_by_comm_arrangement(
          comm_scenario  = 'ZSHIPMENT_TRACKER'
          service_id     = 'ZSHIPMENT_TRACKER_REST'
          comm_system_id = 'SHIPMENT_PROD' ) ).

      DATA(lo_request) = lo_client->get_http_request( ).
      lo_request->set_uri_query( |carrier={ lv_carrier }| &&
                                 |&limit={ lv_top }| &&
                                 |&offset={ lv_skip }| ).

      DATA(lv_body) = lo_client->execute( if_web_http_client=>get )->get_text( ).

      /ui2/cl_json=>deserialize(
        EXPORTING json = lv_body
        CHANGING  data = lt_result ).

      io_response->set_total_number_of_records( lines( lt_result ) ).
      io_response->set_data( lt_result ).

    CATCH cx_root INTO DATA(lx_err).
      RAISE EXCEPTION TYPE cx_rap_query_provider
        EXPORTING
          textid   = cx_rap_query_provider=>query_execution_failed
          previous = lx_err.
  ENDTRY.
ENDMETHOD.

여기서 짚고 갈 포인트가 세 가지 있습니다. 첫째, get_as_ranges( )는 OData 필터를 ABAP RANGE 테이블 형태로 정규화해 주기 때문에 복잡한 eq/ne/le/ge 조합을 sign/option/low/high로 다루기 편리합니다. 둘째, 페이징은 get_paging( )을 통해 얻습니다. 셋째, 외부 호출 실패는 반드시 cx_rap_query_provider로 감싸야 프레임워크가 표준 OData 에러 응답으로 변환합니다.

실전 예제 3단계 — 운영 품질을 위한 확장

프로덕션에서는 다음 요소를 추가로 고려해야 합니다.

  • 필드 선택 최적화: io_request->get_requested_elements( )로 요청된 컬럼만 파악해 외부 API에 fields= 파라미터를 전달하면 페이로드 크기를 줄일 수 있습니다.
  • 정렬 매핑: io_request->get_sort_elements( )$orderby를 얻고, 외부 API가 지원하는 정렬 키로 변환합니다.
  • 총 건수 처리: 외부 API가 total-count 헤더를 제공하면 그 값을 set_total_number_of_records에 넣어 클라이언트의 페이지네이션을 정확히 만듭니다.
  • 캐싱: 동일 조건 반복 호출을 줄이기 위해 SHM(Shared Memory) 또는 Application Log 기반 단기 캐시를 고려합니다.
  • Application Log: cl_bali_log를 사용해 요청 파라미터/응답 코드/응답 시간을 남기면 운영 문제 진단이 쉬워집니다.
DATA(lt_fields) = io_request->get_requested_elements( ).
DATA(lv_field_list) = REDUCE string(
  INIT r = ``
  FOR <f> IN lt_fields
  NEXT r = COND #( WHEN r IS INITIAL THEN to_lower( <f> )
                   ELSE r && ',' && to_lower( <f> ) ) ).

DATA(lt_sort) = io_request->get_sort_elements( ).
DATA(lv_orderby) = COND string(
  WHEN lt_sort IS NOT INITIAL
  THEN |&sort={ to_lower( lt_sort[ 1 ]-element_name ) }:| &&
       COND #( WHEN lt_sort[ 1 ]-descending = abap_true THEN 'desc' ELSE 'asc' ) ).

lo_request->set_uri_query( |carrier={ lv_carrier }&fields={ lv_field_list }| &&
                           |&limit={ lv_top }&offset={ lv_skip }{ lv_orderby }| ).

보안 측면에서는 인증 토큰을 클래스 속성에 캐싱하지 말고 Destination Service에서 매 요청 시 획득하는 방식을 권장합니다. 또한 외부 URL을 하드코딩하지 말고 Communication Arrangement로 관리해야 이전(Transport) 시 환경별 분기가 쉬워집니다. 테스트는 if_rap_query_requestif_rap_query_response의 목(Mock) 구현체를 만들어 ABAP Unit으로 필터 파싱 로직을 검증합니다.

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

Q1. Custom Entity에서 @ObjectModel.query.implementedBy를 지정했는데 프리뷰에서 빈 결과만 나옵니다.
클래스명 앞의 ABAP: 접두사를 빠뜨렸거나, 클래스가 활성화되지 않은 경우가 대부분입니다. 또한 반환하는 내부 테이블의 컴포넌트명이 Custom Entity의 필드명과 대소문자·이름까지 정확히 일치해야 합니다.

Q2. $filter가 복잡해지면 파싱이 어렵습니다. 모든 조건을 다 지원해야 하나요?
아니오. 외부 API가 지원하지 않는 조건은 ABAP 측 후처리(DELETE lt_result WHERE ...)로 필터링하거나, cx_rap_query_provider로 "지원하지 않는 필터"라는 명시적 에러를 반환하는 전략을 씁니다. 초기에는 지원 범위를 문서화하고 좁게 시작하는 것이 안전합니다.

Q3. 페이징이 어긋나 클라이언트에서 무한 스크롤이 이상하게 동작합니다.
set_total_number_of_records에 정확한 총 건수를 넣지 않으면 Fiori Elements는 페이징을 잘못 계산합니다. 외부 API가 총 건수를 주지 않으면, 요청된 $top보다 한 건 더 조회해 "다음 페이지 존재 여부"만 판단하거나, $count를 별도 지원하도록 구현해야 합니다.

Q4. 성능이 느립니다.
외부 호출이 병목인 경우가 많습니다. 컬럼 프로젝션(get_requested_elements)과 조건 위임(Pushdown)을 최대한 활용하고, 대량 조회가 예상되면 서버측 페이지 크기 상한을 강제하는 것이 좋습니다.

여기서 더 나아가고 싶다면

Custom Entity + Query Provider 패턴이 익숙해지면, 다음 주제로 확장해 보길 권장합니다. Managed/Unmanaged RAP BO의 save_modified 단계에서 외부 API를 호출해 쓰기(Write) 시나리오까지 감싸는 Virtual Data Model 구성, @Search.searchable$search 처리, Value Help용 Custom Entity, 그리고 여러 외부 소스를 조합해 하나의 CDS로 노출하는 Composite Query Provider가 자연스러운 다음 목적지입니다. 더불어 SAP Gateway의 CPI/Integration Suite를 프록시로 두어 인증·재시도·서킷브레이커를 위임하는 아키텍처도 실무에서 자주 쓰이는 패턴입니다.

더 깊이 파고들 때 도움이 되는 문서

  • SAP Help — Custom Entities (ABAP CDS)
  • SAP Help — Implementing a Custom Query (IF_RAP_QUERY_PROVIDER)
  • SAP Help — Communication Arrangements (BTP ABAP Environment)
  • SAP Developers — Build a Custom Entity in ABAP Environment
  • SAP Community — RAP Custom Entity Query Implementation Patterns
  • SAP Help — RESTful ABAP Programming Model Overview

댓글 0

아직 댓글이 없습니다.