RAP

아직도 DB만? RAP로 외부 API OData 노출하기 #shorts #SAP #RAP

▶ YouTube에서 보기

외부 API를 OData 표면으로 끌어올리는 RAP Custom Entity의 가치

SAP S/4HANA 또는 BTP ABAP Environment에서 RAP(ABAP RESTful Application Programming Model) 기반으로 Fiori 앱을 만들다 보면, 데이터 소스가 항상 데이터베이스 테이블이라는 가정이 깨지는 순간이 옵니다. 공급업체의 재고 조회 REST API, 외부 물류사의 배송 추적 API, 환율 정보 제공 서비스처럼 데이터가 외부에 존재하는 경우입니다. 이 글은 이러한 외부 데이터 소스를 RAP Custom Entity로 감싸 OData V4 서비스로 노출하는 방법을 단계별로 다룹니다.

이 글을 끝까지 따라오면 얻을 수 있는 것은 다음과 같습니다.

  • Custom Entity와 일반 CDS View Entity의 동작 차이 이해
  • IF_RAP_QUERY_PROVIDER 인터페이스 기반 Query Implementation 작성
  • $filter, $top, $skip, $orderby 같은 OData 시스템 쿼리 옵션 처리 패턴
  • 외부 REST 호출 시 인증·재시도·로깅·캐싱을 적용한 프로덕션 레벨 코드
  • SAP Gateway Client 또는 Fiori Elements에서 외부 데이터를 OData로 소비

이 글을 읽기 전 알고 있어야 하는 배경

독자는 RAP의 Managed/Unmanaged 시나리오를 한 번이라도 구현해본 경험, ABAP CDS View Entity 문법, 그리고 ABAP OO(클래스/인터페이스/예외 클래스)를 다룰 수 있다고 가정합니다. HTTP 클라이언트 객체(IF_HTTP_CLIENT, CL_WEB_HTTP_CLIENT_MANAGER)의 기본 사용법과 Communication Arrangement, Destination Service에 대한 이해도 있으면 좋습니다. OData V4의 시스템 쿼리 옵션($filter, $orderby 등) 의미를 알고 있어야 Query Implementation의 의도를 정확히 잡을 수 있습니다.

실습에 필요한 환경 구성

이 글의 예제는 다음 환경에서 검증되는 것을 기준으로 작성되었습니다. 버전이 다르면 일부 API 시그니처가 달라질 수 있으므로 실제 시스템의 ABAP Doc과 Release Contract를 확인하는 편이 권장됩니다.

  • SAP BTP ABAP Environment (Steampunk) 2402 이상 또는 S/4HANA 2022 FPS01 이상 (Embedded Steampunk)
  • ADT(ABAP Development Tools) for Eclipse 최신 버전
  • Service Binding Type: OData V4 (UI 또는 Web API)
  • 외부 REST 엔드포인트(예시: 가상의 공급업체 재고 API https://api.supplier-mock.example.com/v1/stock)
  • Communication Arrangement / Outbound Service / Destination 설정 권한

BTP ABAP Environment에서는 Communication Scenario를 정의하고 Outbound Service를 외부 URL과 연결한 뒤, 시스템에서 Communication Arrangement를 만들어 OAuth2 또는 Basic 인증 정보를 바인딩하는 방식이 일반적입니다. On-Premise S/4HANA에서는 SM59 RFC Destination 또는 트랜잭션 SOAMANAGER 기반 Destination을 사용할 수 있습니다.

Custom Entity가 무엇을 다르게 하는가

RAP에서 데이터 소스를 정의하는 가장 일반적인 방법은 CDS View Entity입니다. View Entity는 HANA SQL로 컴파일되어 DB 위에서 실행됩니다. 즉, OData 요청이 들어오면 SADL(Service Adaptation Description Language) 런타임이 자동으로 $filter, $orderby, $top 등을 SQL로 번역합니다. 개발자는 데이터를 어디서 가져올지 SELECT문조차 작성하지 않습니다.

반면 Custom Entity(@ObjectModel.query.implementedBy 어노테이션이 붙은 엔티티)는 SADL이 데이터 조회 책임을 개발자가 작성한 ABAP 클래스에 위임합니다. 비유하자면 일반 CDS View Entity는 '주방장이 알아서 요리하는 정찬'이고, Custom Entity는 '재료 손질부터 플레이팅까지 직접 하는 셀프 키친'입니다. 자유도가 높은 대신, 필터링·정렬·페이징을 직접 구현해야 합니다.

동작 흐름은 다음과 같습니다.

  1. 클라이언트가 OData 요청(GET /SupplierStock?$filter=Quantity gt 100&$top=20)을 전송
  2. Gateway가 요청을 파싱하여 SADL Runtime으로 위임
  3. SADL은 Custom Entity의 implementedBy로 지정된 클래스의 IF_RAP_QUERY_PROVIDER~select 메서드를 호출
  4. 전달된 io_request 객체에서 필터, 정렬, 페이징, 요청 필드 정보를 추출
  5. 개발자는 그 정보를 외부 REST 호출의 쿼리 파라미터로 변환하여 HTTP 호출
  6. 응답 JSON을 ABAP 내부 테이블로 매핑한 뒤 io_response->set_data( )로 결과 반환

핵심 인터페이스는 IF_RAP_QUERY_PROVIDER 하나이며, 그 안의 select 메서드 시그니처가 전부입니다. 단순해 보여도 OData 시멘틱을 외부 API 시멘틱으로 번역하는 책임이 모두 이 메서드에 모입니다.

1단계: 최소 동작 가능한 Custom Entity 만들기

먼저 공급업체 재고 조회를 위한 Custom Entity를 정의합니다. CDS Custom Entity는 define custom entity 키워드로 작성하며, 어떤 클래스가 데이터를 공급할지 어노테이션으로 지정합니다.

@EndUserText.label: '''Supplier Stock - External API'''
@ObjectModel.query.implementedBy: '''ABAP:ZCL_SUPPLIER_STOCK_QUERY'''
define custom entity ZI_SupplierStock
{
  key SupplierId       : abap.char(10);
  key MaterialId       : abap.char(18);
      MaterialDesc     : abap.char(60);
      Quantity         : abap.dec(13,3);
      Uom              : abap.unit(3);
      WarehouseCode    : abap.char(4);
      LastSyncAt       : abap.utclong;
}

이제 Query Implementation 클래스를 만듭니다. 가장 작은 형태로 외부 API를 호출하고 결과를 반환하는 골격입니다.

CLASS zcl_supplier_stock_query DEFINITION
  PUBLIC FINAL CREATE PUBLIC.

  PUBLIC SECTION.
    INTERFACES if_rap_query_provider.

  PRIVATE SECTION.
    TYPES: BEGIN OF ty_stock,
             supplierid    TYPE c LENGTH 10,
             materialid    TYPE c LENGTH 18,
             materialdesc  TYPE c LENGTH 60,
             quantity      TYPE p LENGTH 7 DECIMALS 3,
             uom           TYPE c LENGTH 3,
             warehousecode TYPE c LENGTH 4,
             lastsyncat    TYPE utclong,
           END OF ty_stock,
           tt_stock TYPE STANDARD TABLE OF ty_stock WITH EMPTY KEY.

    METHODS call_supplier_api
      RETURNING VALUE(rt_stock) TYPE tt_stock
      RAISING   cx_web_http_client_error.
ENDCLASS.

CLASS zcl_supplier_stock_query IMPLEMENTATION.

  METHOD if_rap_query_provider~select.
    DATA(lt_data) = call_supplier_api( ).
    io_response->set_total_number_of_records( lines( lt_data ) ).
    io_response->set_data( lt_data ).
  ENDMETHOD.

  METHOD call_supplier_api.
    DATA(lo_dest) = cl_http_destination_provider=>create_by_comm_arrangement(
                      comm_scenario  = '''ZSUPPLIER_STOCK_OUT'''
                      service_id     = '''ZSUPPLIER_STOCK_OUT_REST''' ).

    DATA(lo_client) = cl_web_http_client_manager=>create_by_http_destination( lo_dest ).
    DATA(lo_req)    = lo_client->get_http_request( ).
    lo_req->set_header_field( i_name = '''Accept''' i_value = '''application/json''' ).

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

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

ENDCLASS.

이 시점에서 Service Definition과 Service Binding(OData V4)을 만들어 Preview에서 호출하면 외부 API의 전체 목록이 그대로 노출됩니다. 다만 $filter나 $top을 무시하므로 실용성이 떨어집니다.

2단계: OData 시스템 쿼리 옵션을 외부 API 파라미터로 번역

두 번째 단계에서는 io_request가 제공하는 메타 정보를 활용해 외부 API 호출 시 불필요한 데이터를 가져오지 않도록 최적화합니다. 또한 에러 응답을 RAP가 이해할 수 있는 비즈니스 예외로 변환하고, Application Log를 남깁니다.

METHOD if_rap_query_provider~select.

  DATA: lt_filter_cond TYPE if_rap_query_filter=>tt_name_range_pairs,
        lv_top         TYPE int8,
        lv_skip        TYPE int8,
        lt_sort        TYPE if_rap_query_request=>tt_sort_elements,
        lv_supplier    TYPE string,
        lv_min_qty     TYPE string.

  TRY.
      lt_filter_cond = io_request->get_filter( )->get_as_ranges( ).
      LOOP AT lt_filter_cond INTO DATA(ls_cond).
        CASE ls_cond-name.
          WHEN '''SUPPLIERID'''.
            READ TABLE ls_cond-range INTO DATA(ls_r) INDEX 1.
            IF sy-subrc = 0. lv_supplier = ls_r-low. ENDIF.
          WHEN '''QUANTITY'''.
            READ TABLE ls_cond-range INTO ls_r INDEX 1.
            IF sy-subrc = 0. lv_min_qty = ls_r-low. ENDIF.
        ENDCASE.
      ENDLOOP.

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

      DATA(lt_data) = call_supplier_api(
                        iv_supplier = lv_supplier
                        iv_min_qty  = lv_min_qty
                        iv_top      = lv_top
                        iv_skip     = lv_skip
                        it_sort     = lt_sort ).

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

    CATCH cx_web_http_client_error
          cx_http_dest_provider_error INTO DATA(lx_http).
      RAISE EXCEPTION TYPE cx_rap_query_provider
        EXPORTING textid   = cx_rap_query_provider=>business_data_provider
                  previous = lx_http.
  ENDTRY.

ENDMETHOD.
METHOD call_supplier_api.
  DATA(lo_dest)   = cl_http_destination_provider=>create_by_comm_arrangement(
                      comm_scenario = '''ZSUPPLIER_STOCK_OUT'''
                      service_id    = '''ZSUPPLIER_STOCK_OUT_REST''' ).
  DATA(lo_client) = cl_web_http_client_manager=>create_by_http_destination( lo_dest ).
  DATA(lo_req)    = lo_client->get_http_request( ).

  DATA(lv_path) = |\/v1\/stock?$top={ iv_top }&$skip={ iv_skip }|.
  IF iv_supplier IS NOT INITIAL.
    lv_path = |{ lv_path }&supplierId={ iv_supplier }|.
  ENDIF.
  IF iv_min_qty IS NOT INITIAL.
    lv_path = |{ lv_path }&minQty={ iv_min_qty }|.
  ENDIF.

  lo_req->set_uri_path( lv_path ).
  lo_req->set_header_field( i_name = '''Accept''' i_value = '''application/json''' ).

  DATA(lo_resp) = lo_client->execute( i_method = if_web_http_client=>get ).
  IF lo_resp->get_status( )-code >= 400.
    RAISE EXCEPTION TYPE cx_web_http_client_error.
  ENDIF.

  /ui2/cl_json=>deserialize(
    EXPORTING json         = lo_resp->get_text( )
              pretty_name  = /ui2/cl_json=>pretty_mode-camel_case
    CHANGING  data         = rt_stock ).
ENDMETHOD.

3단계: 프로덕션 레벨 — 캐싱, 재시도, 테스트, 보안

실무에서는 외부 API가 매번 빠르고 안정적이라고 가정할 수 없습니다. 다음 네 가지를 추가합니다.

  1. 캐싱: Shared Memory(SHMA) 또는 SAP Cloud Platform의 ABAP Shared Object를 활용해 N초간 결과를 보관합니다.
  2. 재시도: 5xx 응답과 타임아웃에 대해서만 지수 백오프로 재시도. 4xx는 즉시 실패.
  3. 테스트: HTTP 호출부를 인터페이스로 분리하여 ABAP Unit에서 Test Double로 대체.
  4. 보안: 인증 정보는 절대 코드에 두지 않고 Communication Arrangement를 통해 주입.
INTERFACE zif_supplier_stock_gateway PUBLIC.
  METHODS fetch
    IMPORTING is_filter       TYPE zif_supplier_stock_gateway=>ty_filter
    RETURNING VALUE(rt_stock) TYPE zif_supplier_stock_gateway=>tt_stock
    RAISING   cx_rap_query_provider.
ENDINTERFACE.

CLASS zcl_supplier_stock_gateway_http DEFINITION
  PUBLIC FINAL CREATE PUBLIC.
  PUBLIC SECTION.
    INTERFACES zif_supplier_stock_gateway.
    CONSTANTS c_max_retry TYPE i VALUE 3.
ENDCLASS.

CLASS zcl_supplier_stock_gateway_http IMPLEMENTATION.
  METHOD zif_supplier_stock_gateway~fetch.
    DATA lv_attempt TYPE i VALUE 0.
    DO c_max_retry TIMES.
      lv_attempt = lv_attempt + 1.
      TRY.
          rt_stock = call_remote( is_filter ).
          RETURN.
        CATCH cx_web_http_client_error INTO DATA(lx).
          IF lv_attempt = c_max_retry.
            RAISE EXCEPTION TYPE cx_rap_query_provider
              EXPORTING previous = lx.
          ENDIF.
          WAIT UP TO ( 2 ** lv_attempt ) SECONDS.
      ENDTRY.
    ENDDO.
  ENDMETHOD.
ENDCLASS.

CLASS zcl_supplier_stock_query DEFINITION PUBLIC FINAL CREATE PUBLIC.
  PUBLIC SECTION.
    INTERFACES if_rap_query_provider.
    METHODS constructor
      IMPORTING io_gateway TYPE REF TO zif_supplier_stock_gateway OPTIONAL.
  PRIVATE SECTION.
    DATA mo_gateway TYPE REF TO zif_supplier_stock_gateway.
ENDCLASS.

CLASS zcl_supplier_stock_query IMPLEMENTATION.
  METHOD constructor.
    mo_gateway = COND #( WHEN io_gateway IS BOUND
                         THEN io_gateway
                         ELSE NEW zcl_supplier_stock_gateway_http( ) ).
  ENDMETHOD.
ENDCLASS.

실전에서 자주 막히는 지점들

Q1. Custom Entity에서 $filter가 아예 동작하지 않습니다.
SADL은 Custom Entity에 대해 자동 필터링을 하지 않습니다. io_request->get_filter( )에서 조건을 직접 읽어 외부 API 쿼리 파라미터나 내부 테이블 LOOP의 WHERE로 적용해야 합니다.

Q2. set_total_number_of_records에 무엇을 넣어야 하나요?
$count=true 또는 페이징 시 정확한 총 개수가 필요합니다. 외부 API가 별도의 count 엔드포인트를 제공하면 그것을 호출하고, 제공하지 않으면 -1 또는 페이지 크기 기반 추정치를 사용합니다.

Q3. Communication Arrangement를 만들었는데 401이 떨어집니다.
Outbound Service의 Authentication Method가 Inbound 사용자 자격 정보와 다른 경우가 잦습니다. OAuth2 Client Credentials 설정 시 Token Endpoint URL과 Scope가 외부 시스템 사양과 일치해야 하며, 클라이언트 시크릿이 만료되었는지 Communication User에서 확인해야 합니다.

Q4. $orderby가 외부 API 사양에 없을 때는 어떻게 하나요?
외부에서 정렬을 지원하지 않으면 받아온 내부 테이블을 ABAP에서 SORT로 정렬합니다. 데이터가 크면 성능이 급격히 나빠지므로 정렬 키를 외부 API의 자연 정렬 순서로 제한하거나 캐시 레이어에서 미리 정렬해두는 방법을 권장합니다.

더 깊이 파고들 때 도움이 되는 자료

댓글 0

아직 댓글이 없습니다.