RAP

%filter 없이 RAP 대량 조회? 이렇게 터집니다 #shorts #SAP #RAP

▶ YouTube에서 보기

개요 및 핵심 포인트

RAP(RESTful Application Programming Model) 기반 서비스에서 대량 데이터를 다룰 때 가장 흔히 발생하는 문제 중 하나가 바로 "필터링 누락으로 인한 전체 스캔"입니다. 프론트엔드에서 $filter, $top, $skip 쿼리를 던졌는데 백엔드 Read 핸들러가 이를 무시하고 전체 테이블을 메모리에 적재한다면, 데이터가 수백만 건이 되는 순간 서비스는 타임아웃이나 OOM(Out Of Memory)으로 죽습니다. 이 글에서는 Unmanaged/Managed 시나리오에서 %filter, %paging을 서버 사이드 SQL로 위임(pushdown)하는 방법을 실전 코드로 다룹니다.

  • OData 쿼리 옵션이 RAP 파라미터로 변환되는 흐름 이해
  • %filter, %paging을 SELECT 문의 WHERE/OFFSET에 반영
  • 필터링 미적용 시 성능 저하 원인 분석 및 방어 로직 구현
  • Application Log와 SQL Trace로 문제 지점 진단

사전에 알아둘 개념

이 글은 ABAP CDS View, Behavior Definition(BDEF), Behavior Implementation(Behavior Pool) 클래스에 대한 기초 이해를 전제로 합니다. 특히 Unmanaged/Managed 시나리오의 차이, FOR READ IMPORTING 메서드 시그니처, %tky/%key의 의미를 알고 있어야 합니다. OData V2/V4 쿼리 옵션($filter, $top, $skip, $orderby)도 사전 숙지가 필요합니다.

환경 및 준비 사항

이 글의 예제는 다음 환경을 기준으로 작성했습니다.

  • SAP BTP ABAP Environment (Steampunk) 2026 릴리스 또는 S/4HANA Cloud Private Edition 2023 이상
  • ABAP Development Tools(ADT) for Eclipse 2026.x
  • RAP Business Object: Unmanaged 시나리오 (레거시 함수 모듈 또는 외부 시스템 래핑을 가정)
  • SAP HANA Cloud 데이터베이스 (OFFSET/LIMIT SQL 지원)
  • Fiori Elements List Report 프론트엔드 (테스트용)

준비물로는 최소 100만 건 이상의 테스트 데이터가 담긴 테이블이 필요합니다. 소량 데이터로는 문제 증상이 재현되지 않으므로 반드시 대량 데이터로 벤치마킹해야 합니다. 성능 측정에는 트랜잭션 SAT(ABAP Runtime Analysis) 또는 ADT의 ABAP Profiler, HANA의 PlanViz를 사용합니다.

핵심 원리 파헤치기

RAP에서 OData 요청이 들어오면 프레임워크는 URL 쿼리 파라미터를 파싱하여 Read 핸들러의 IMPORTING 파라미터인 keyspaging, 그리고 필터 조건 filter로 전달합니다. 이를 그림으로 표현하면 다음과 같습니다.

[클라이언트 URL] /SalesOrders?$filter=OrderStatus eq 'OPEN'&$top=50&$skip=100
    ↓ (Gateway/OData 파서)
[RAP Runtime] io_request->get_filter( ), io_request->get_paging( )
    ↓ (Behavior Pool 개발자 책임)
[SQL] SELECT ... WHERE order_status = 'OPEN' OFFSET 100 LIMIT 50

여기서 중요한 점은 Managed 시나리오에서는 프레임워크가 CDS 뷰를 기반으로 자동으로 필터/페이징을 SQL에 넣어준다는 것입니다. 그러나 Unmanaged 시나리오나 함수 기반 CDS Entity(WITH PARAMETERS 없이 함수로 구현한 경우)에서는 개발자가 직접 get_as_ranges(), get_as_sql_string()으로 필터를 꺼내 SQL에 반영해야 한다는 점입니다.

필터를 무시하면 어떤 일이 벌어질까요? 예를 들어 클라이언트가 특정 고객의 최근 주문 10건만 원했는데, 서버가 SELECT * FROM zsales_order로 200만 건을 모두 읽어 lt_result에 담고, 그 후 ABAP 레벨에서 LOOP AT ... WHERE customer_id = ...로 필터링한다면 네트워크 왕복 시간, 메모리 사용량, DB CPU 모두가 폭증합니다. 이는 흔히 "code-to-data" 원칙 위반의 대표 사례로 언급됩니다.

실전 예제 1 — 기본 필터 위임

가장 단순한 형태로, Unmanaged RAP BO의 READ 핸들러에서 %filterWHERE 절로 변환하는 예입니다. 시나리오는 판매 주문(ZSALES_ORDER) 조회입니다.

CLASS lhc_sales_order IMPLEMENTATION.

  METHOD read.
    " io_request: 요청 컨텍스트 (필터/페이징 접근)
    DATA(lo_filter) = io_request->get_filter( ).
    DATA(lo_paging) = io_request->get_paging( ).

    " %filter를 SQL WHERE로 변환
    DATA(lv_where) = lo_filter->get_as_sql_string(
                       iv_reference_field_alias = 'SO' ).

    " %paging을 OFFSET/LIMIT로 변환
    DATA(lv_top)  = lo_paging->get_page_size( ).
    DATA(lv_skip) = lo_paging->get_offset( ).

    SELECT FROM zsales_order AS so
           FIELDS order_id, customer_id, order_status, total_amount
           WHERE (lv_where)
           ORDER BY order_id
           OFFSET @lv_skip
           UP TO @lv_top ROWS
           INTO TABLE @result.
  ENDMETHOD.

ENDCLASS.

이 코드의 핵심은 get_as_sql_string()이 반환한 동적 WHERE 문자열을 그대로 SQL에 심는다는 점입니다. HANA는 OFFSET/LIMIT를 네이티브로 지원하므로 페이징도 DB 레벨에서 처리됩니다. 결과적으로 200만 건 중 필터에 걸리는 수천 건만 스캔하고, 그중 50건만 네트워크로 전송됩니다.

실전 예제 2 — 에러 처리와 로깅 강화

실무에서는 필터가 없거나 페이지 크기가 비정상적으로 클 때를 방어해야 합니다. 아래는 청구서(Invoice) 목록 조회 시 안전장치를 추가한 버전입니다.

METHOD read.
  CONSTANTS: c_max_page_size TYPE i VALUE 500,
             c_log_object    TYPE balobj_d VALUE 'ZRAP_PERF'.

  DATA(lo_filter) = io_request->get_filter( ).
  DATA(lo_paging) = io_request->get_paging( ).

  TRY.
      DATA(lv_where) = lo_filter->get_as_sql_string(
                         iv_reference_field_alias = 'INV' ).
    CATCH cx_rap_query_filter_no_conv INTO DATA(lx_filter).
      " 변환 불가능한 필터 (예: 함수 호출) - 사용자에게 명확한 오류 전달
      APPEND VALUE #( %fail-cause = if_abap_behv=>cause-conflict
                      %msg = new_message(
                        id       = 'ZRAP_MSG'
                        number   = '010'
                        severity = if_abap_behv_message=>severity-error
                        v1       = lx_filter->get_text( ) ) )
             TO failed-invoice.
      RETURN.
  ENDTRY.

  " 페이지 크기 상한 강제
  DATA(lv_top) = lo_paging->get_page_size( ).
  IF lv_top <= 0 OR lv_top > c_max_page_size.
    lv_top = c_max_page_size.
  ENDIF.

  " 필터가 비어 있으면 감사 로그 기록
  IF lv_where IS INITIAL.
    cl_bali_message_setter=>create(
      severity = if_bali_constants=>c_severity_warning
      text     = |Invoice READ without filter - potential full scan| ).
  ENDIF.

  SELECT FROM zinvoice AS inv
         FIELDS invoice_id, buyer_id, invoice_status,
                gross_amount, currency_code
         WHERE (lv_where)
         ORDER BY invoice_id DESCENDING
         OFFSET @lo_paging->get_offset( )
         UP TO @lv_top ROWS
         INTO TABLE @result.
ENDMETHOD.

여기서 주목할 세 가지 방어 로직: 첫째, cx_rap_query_filter_no_conv를 잡아 변환 실패를 사용자 메시지로 되돌립니다. 둘째, c_max_page_size로 클라이언트가 $top=100000을 보내도 500으로 제한합니다. 셋째, 필터가 비어 있을 때 Application Log를 남겨 향후 모니터링 지표로 활용합니다.

실전 예제 3 — 프로덕션 급 성능·보안 강화

대규모 서비스에서는 페이징뿐 아니라 정렬($orderby), 인덱스 힌트, 인증 체크까지 통합해야 합니다. 아래는 구매 주문(Purchase Order) 시나리오입니다.

METHOD read.
  DATA: lt_sort_order TYPE if_rap_query_request=>tt_sort_elements.

  " 1) 필터
  DATA(lo_filter) = io_request->get_filter( ).
  DATA(lv_where)  = lo_filter->get_as_sql_string(
                      iv_reference_field_alias = 'PO' ).

  " 2) 정렬 화이트리스트 (SQL Injection 방어)
  DATA(lt_sort) = io_request->get_sort_elements( ).
  DATA lv_orderby TYPE string.
  LOOP AT lt_sort INTO DATA(ls_sort).
    CASE ls_sort-element_name.
      WHEN 'PURCHASEORDER' OR 'CREATIONDATE' OR 'SUPPLIER'.
        lv_orderby = |{ lv_orderby } { ls_sort-element_name }
                      { COND #( WHEN ls_sort-descending = abap_true
                                THEN 'DESCENDING' ELSE 'ASCENDING' ) },|.
      WHEN OTHERS.
        " 허용되지 않은 정렬 필드는 무시
    ENDCASE.
  ENDLOOP.
  IF lv_orderby IS NOT INITIAL.
    lv_orderby = shift_right( val = lv_orderby places = 1 ). "trailing comma
  ELSE.
    lv_orderby = 'PURCHASEORDER'.
  ENDIF.

  " 3) 권한 체크 - 구매 조직 단위
  AUTHORITY-CHECK OBJECT 'M_BEST_EKO'
    ID 'ACTVT' FIELD '03'
    ID 'EKORG' DUMMY.
  IF sy-subrc <> 0.
    APPEND VALUE #( %msg = new_message_with_text(
                       severity = if_abap_behv_message=>severity-error
                       text     = 'No authorization for purchase org' ) )
           TO failed-purchaseorder.
    RETURN.
  ENDIF.

  " 4) 페이징
  DATA(lv_top)    = COND i( WHEN io_request->get_paging( )->get_page_size( ) > 0
                            THEN io_request->get_paging( )->get_page_size( )
                            ELSE 100 ).
  DATA(lv_offset) = io_request->get_paging( )->get_offset( ).

  " 5) 실제 SELECT - 필드 최소화, 인덱스 활용
  SELECT FROM zpurchase_order AS po
         FIELDS purchaseorder, supplier, creationdate,
                totalnetamount, currency, deliverystatus
         WHERE (lv_where)
         ORDER BY (lv_orderby)
         OFFSET @lv_offset
         UP TO @lv_top ROWS
         INTO CORRESPONDING FIELDS OF TABLE @result.

  " 6) 총 건수 요청 시에만 COUNT 실행
  IF io_request->is_total_numb_of_rec_requested( ).
    SELECT COUNT(*) FROM zpurchase_order
           WHERE (lv_where)
           INTO @DATA(lv_count).
    io_response->set_total_number_of_records( lv_count ).
  ENDIF.
ENDMETHOD.

프로덕션 코드의 특징: 정렬 필드를 화이트리스트로 검증하여 임의 SQL 삽입을 차단하고, AUTHORITY-CHECK로 데이터 접근 권한을 확인하며, is_total_numb_of_rec_requested()를 통해 총 건수 COUNT 쿼리를 클라이언트가 실제로 요청했을 때만 실행합니다. Fiori Elements가 페이지네이션 컨트롤 표시를 위해 $count=true를 보낼 때만 COUNT가 실행되므로 불필요한 부하가 줄어듭니다.

자주 마주치는 함정과 해결

Q1. get_as_sql_string()이 빈 문자열을 반환하는데 SELECT가 실패합니다.
A. WHERE 절에 빈 문자열을 그대로 넣으면 문법 오류가 발생합니다. WHERE (lv_where) 형태로 괄호를 씌워도 마찬가지입니다. 해결책은 IF lv_where IS INITIAL. lv_where = '1 = 1'. ENDIF.로 항상 참인 조건을 넣거나, WHERE 절 자체를 조건적으로 조립하는 것입니다.

Q2. OData $filter에 substringof 같은 함수가 들어오면 예외가 납니다.
A. cx_rap_query_filter_no_conv가 발생하는 전형적 케이스입니다. get_as_ranges()로 우선 시도하고, 그것도 실패하면 get_filter_string()으로 원본 필터를 받아 수동 파싱하거나, 클라이언트에게 지원하지 않는 필터임을 알려야 합니다. 일반적으로 CDS 뷰에 @Search.searchable: true 애노테이션을 두고 $search 옵션을 대신 쓰도록 유도하는 방법이 권장됩니다.

Q3. 페이지 크기가 갑자기 5000처럼 커지면 어떻게 되나요?
A. RAP 프레임워크는 기본적으로 server_driven_paging을 통해 페이지 크기 상한을 강제할 수 있지만, Unmanaged에서는 개발자가 명시적으로 상한을 두는 것이 안전합니다. 위 예제 2처럼 상수로 500 등을 정의하고 clamp 처리하세요. 또한 CDS 뷰에 @OData.entitySet.pageSize: 100 애노테이션을 두어 기본값을 조정할 수 있습니다.

Q4. SQL Trace를 봐도 WHERE 절이 안 들어가 있어요.
A. Behavior Pool의 READ가 아니라 CDS View의 조회로 대체된 경우이거나, Managed 시나리오에서 프레임워크가 자동으로 처리한 케이스일 수 있습니다. ADT의 Runtime Type Editor에서 실제 실행 경로를 확인하고, ST05 또는 HANA PlanViz로 최종 SQL을 확인하세요.

확장 학습 방향

이 글에서 다룬 필터/페이징 위임은 시작점입니다. 후속으로 다음 주제를 권장합니다.

  • Query Implementation Class (IF_RAP_QUERY_PROVIDER): 커스텀 엔티티(Custom Entity)로 완전한 제어권을 갖는 조회 로직 구현
  • Virtual Elements와 성능: 계산 필드가 필터 대상일 때 SQL pushdown 여부 검토
  • CDS View Entity의 Table Function 활용: AMDP로 복잡한 로직도 HANA에서 실행
  • Draft-enabled BO에서의 필터링: Draft 인스턴스와 Active 인스턴스 병합 조회 시 고려사항
  • OData V4 $apply 지원: 집계(aggregation) 쿼리 처리

더 읽어볼 문서

댓글 0

아직 댓글이 없습니다.