이 글에서 다루는 내용과 체크포인트
RAP(ABAP RESTful Application Programming Model)에서 Dynamic Feature Control은 인스턴스별로 필드의 readonly/mandatory 여부나 액션의 활성화 여부를 런타임에 결정하는 강력한 메커니즘입니다. 그러나 이 기능을 잘못 구현하면 OData 응답이 수 초씩 늘어지는 성능 병목으로 직결됩니다. 이 글에서는 컨설팅 현장에서 가장 자주 발견되는 3가지 안티패턴을 잘못된 코드와 올바른 코드로 대조하며 해부합니다.
- GET INSTANCE FEATURES 내부에서 DB SELECT를 남발하는 패턴 진단
- 키별 단건 조회를 FOR ALL ENTRIES 기반 일괄 조회로 전환하는 기법
- 같은 트랜잭션 내 중복 조회를 막는 buffer/transactional buffer 활용
- Behavior Implementation Class의 메서드 시그니처와 result 테이블 채우기 원칙 재정리
- SAT(런타임 분석) / SQL Trace(ST05)로 병목 검증 절차
이 글을 따라가기 전에 알고 있으면 좋은 것
RAP의 기본 빌딩 블록(Behavior Definition, Behavior Implementation, BDEF Derived Type)에 대한 이해가 필요합니다. CDS View Entity와 projection view의 차이, EML(Entity Manipulation Language)의 READ ENTITIES 문법, 그리고 ABAP 7.55 이상의 inline declaration 문법에 익숙해야 코드 흐름을 따라가기 수월합니다. 추가로 ST05/SAT 기반 성능 측정 경험이 있으면 좋습니다.
실습 환경과 사전 준비
이 글의 예제는 다음 환경을 가정합니다.
- ABAP Platform 2023 (on-premise) 또는 SAP BTP ABAP Environment 2402 이상
- ADT(ABAP Development Tools) for Eclipse 2024-03 이상
- RAP managed/unmanaged scenario 모두 적용 가능 (예제는 managed 기준)
- 테스트 데이터:
ZSALES_ORDER(헤더),ZSALES_ITEM(항목) 각 5만 건 이상 권장 - 측정 도구: ST05(SQL Trace), SAT(Runtime Analysis),
cl_abap_runtime
Dynamic Feature Control은 BDEF에서 field ( features : instance ) 또는 action ... ( features : instance )로 선언하며, 이때 Behavior Implementation Class에는 FOR INSTANCE FEATURES 메서드가 자동 생성됩니다. 이 메서드가 호출되는 빈도와 결과 캐싱 여부가 본문 전체의 핵심입니다.
Dynamic Feature Control의 동작 원리
Static Feature Control(field ( readonly ) 등)이 BDEF 메타데이터로 굳혀지는 것과 달리, Dynamic Feature Control은 "이 SalesOrder는 상태가 Released라서 ItemQuantity를 수정 못 한다"처럼 인스턴스 데이터에 따라 결과가 달라집니다. 프레임워크는 OData 응답 시 각 엔티티 인스턴스마다 %assoc, %action, %field 구조에 채울 값을 결정해야 하므로 FOR INSTANCE FEATURES 메서드를 행 단위 또는 청크 단위로 반복 호출합니다.
비유하자면, 200건의 SalesOrder 목록을 화면에 그리는 동안 프레임워크는 "이 행의 Edit 버튼을 켤까 끌까"를 200번 묻습니다. 메서드 안에서 매번 DB를 조회하면 200회의 SELECT가 발생하지만, 메서드 안으로 들어온 keys 테이블 전체를 한 번에 처리하면 SELECT는 1회로 줄어듭니다.
핵심 시그니처는 다음 형태입니다.
METHODS get_instance_features FOR INSTANCE FEATURES
IMPORTING keys REQUEST requested_features FOR salesorder
RESULT result.
여기서 keys는 여러 건이 한꺼번에 넘어오는 internal table입니다. 이 사실을 인지하지 못한 채 LOOP AT keys 안에서 single-record SELECT를 던지는 순간 N+1 문제가 시작됩니다.
실수 1: LOOP 안에서 단건 SELECT를 던지는 패턴
가장 흔하면서도 가장 치명적인 패턴입니다. SalesOrder의 결제 상태에 따라 cancelOrder 액션 활성화 여부를 결정하는 시나리오를 봅니다.
" 잘못된 구현 - DB 폭격 패턴
METHOD get_instance_features.
LOOP AT keys ASSIGNING FIELD-SYMBOL(<key>).
SELECT SINGLE payment_status
FROM zsales_order
WHERE order_id = @<key>-OrderId
INTO @DATA(lv_status).
APPEND VALUE #(
%tky = <key>-%tky
%action-cancelOrder = COND #( WHEN lv_status = 'PAID'
THEN if_abap_behv=>fc-o-disabled
ELSE if_abap_behv=>fc-o-enabled )
) TO result.
ENDLOOP.
ENDMETHOD.
OData 클라이언트가 100건의 SalesOrder를 조회하면 위 메서드 한 번 호출에 SELECT 100개가 추가로 발생합니다. ST05로 보면 동일 쿼리가 키만 바뀌어 줄줄이 찍히는 광경을 확인할 수 있습니다. AppGyver/Fiori 리스트 페이지에서 응답 시간이 2~3초씩 늘어지는 주범이 바로 이것입니다.
" 올바른 구현 - FOR ALL ENTRIES 일괄 조회
METHOD get_instance_features.
IF keys IS INITIAL.
RETURN.
ENDIF.
SELECT order_id, payment_status
FROM zsales_order
FOR ALL ENTRIES IN @keys
WHERE order_id = @keys-OrderId
INTO TABLE @DATA(lt_status).
result = VALUE #(
FOR <key> IN keys
( %tky = <key>-%tky
%action-cancelOrder =
COND #( WHEN line_exists( lt_status[ order_id = <key>-OrderId
payment_status = 'PAID' ] )
THEN if_abap_behv=>fc-o-disabled
ELSE if_abap_behv=>fc-o-enabled ) )
).
ENDMETHOD.
SELECT가 1회로 압축됩니다. line_exists의 비용이 신경 쓰인다면 SORTED TABLE이나 HASHED TABLE로 선언해 키 lookup을 O(1)에 가깝게 만들 수 있습니다.
실수 2: 같은 트랜잭션에서 동일 데이터를 반복 조회
RAP는 동일 요청 내에서 GET INSTANCE FEATURES, GET INSTANCE AUTHORIZATIONS, 그리고 Determination/Validation이 각자 따로 호출됩니다. 각 메서드가 같은 SalesOrder 헤더 데이터를 따로 SELECT하면, 단건 조회 패턴을 고쳤다 해도 여전히 같은 데이터가 3~5번 읽힙니다. PurchaseOrder 승인 시나리오를 예로 듭니다.
" 잘못된 구현 - 메서드마다 중복 조회
CLASS lhc_purchaseorder IMPLEMENTATION.
METHOD get_instance_features.
SELECT po_id, total_amount, approver_id
FROM zpurch_order
FOR ALL ENTRIES IN @keys
WHERE po_id = @keys-PoId
INTO TABLE @DATA(lt_po).
" ... feature 계산
ENDMETHOD.
METHOD get_instance_authorizations.
SELECT po_id, total_amount, approver_id
FROM zpurch_order
FOR ALL ENTRIES IN @keys
WHERE po_id = @keys-PoId
INTO TABLE @DATA(lt_po). " 동일 데이터를 다시 읽음
" ... authorization 계산
ENDMETHOD.
ENDCLASS.
해결책은 요청 단위 캐시(transactional buffer)를 클래스 attribute로 두고, EML READ ENTITIES를 활용해 RAP의 transactional buffer를 1차로 활용하는 것입니다.
" 올바른 구현 - EML READ + 정적 캐시 활용
CLASS lhc_purchaseorder DEFINITION INHERITING FROM cl_abap_behavior_handler.
PRIVATE SECTION.
TYPES: BEGIN OF ty_cache,
po_id TYPE zpurch_order-po_id,
total_amount TYPE zpurch_order-total_amount,
approver_id TYPE zpurch_order-approver_id,
END OF ty_cache.
DATA mt_cache TYPE HASHED TABLE OF ty_cache WITH UNIQUE KEY po_id.
METHODS load_into_cache IMPORTING it_keys TYPE STANDARD TABLE.
ENDCLASS.
CLASS lhc_purchaseorder IMPLEMENTATION.
METHOD load_into_cache.
DATA lt_missing TYPE TABLE OF zpurch_order-po_id.
LOOP AT it_keys ASSIGNING FIELD-SYMBOL(<k>).
ASSIGN <k>-('POID') TO FIELD-SYMBOL(<po_id>).
IF NOT line_exists( mt_cache[ po_id = <po_id> ] ).
APPEND <po_id> TO lt_missing.
ENDIF.
ENDLOOP.
IF lt_missing IS NOT INITIAL.
READ ENTITIES OF zi_purchase_order
ENTITY PurchaseOrder
FIELDS ( TotalAmount ApproverId )
WITH VALUE #( FOR id IN lt_missing ( PoId = id ) )
RESULT DATA(lt_read).
mt_cache = VALUE #( BASE mt_cache
FOR r IN lt_read
( po_id = r-PoId
total_amount = r-TotalAmount
approver_id = r-ApproverId ) ).
ENDIF.
ENDMETHOD.
METHOD get_instance_features.
load_into_cache( keys ).
" 이제 mt_cache 에서만 읽어서 feature 계산
ENDMETHOD.
ENDCLASS.
READ ENTITIES를 쓰면 RAP의 transactional buffer가 이미 메모리에 있는 데이터를 재활용하므로, 동일 트랜잭션 내 수정 중인 인스턴스의 일관성도 자동으로 보장됩니다. 인스턴스 핸들러 클래스의 lifetime은 요청 단위이므로 attribute 기반 캐시도 안전합니다.
실수 3: Association을 통한 자식 데이터를 한 행씩 펼치기
DeliveryItem 단위로 "수정 가능" 여부를 결정하는데, 그 판단이 부모 Delivery의 상태에 달려 있는 경우가 많습니다. 잘못 짜면 자식마다 부모를 SELECT합니다.
" 잘못된 구현 - 자식별로 부모 조회
METHOD get_instance_features.
LOOP AT keys ASSIGNING FIELD-SYMBOL(<k>).
SELECT SINGLE delivery_status
FROM zdelivery
WHERE delivery_id = @<k>-DeliveryId
INTO @DATA(lv_status).
" ... readonly 계산
ENDLOOP.
ENDMETHOD.
올바른 접근은 부모 키를 한 번에 distinct로 모아 일괄 조회한 뒤 메모리에서 join하는 것입니다.
" 올바른 구현 - 부모 키 distinct 조회 후 메모리 join
METHOD get_instance_features.
DATA(lt_parent_keys) = VALUE STANDARD TABLE OF zdelivery-delivery_id(
FOR <k> IN keys ( <k>-DeliveryId )
).
SORT lt_parent_keys.
DELETE ADJACENT DUPLICATES FROM lt_parent_keys.
SELECT delivery_id, delivery_status
FROM zdelivery
FOR ALL ENTRIES IN @lt_parent_keys
WHERE delivery_id = @lt_parent_keys-table_line
INTO TABLE @DATA(lt_parent)
##too_many_itab_fields.
result = VALUE #(
FOR <k> IN keys
( %tky = <k>-%tky
%field-Quantity =
COND #( WHEN line_exists(
lt_parent[ delivery_id = <k>-DeliveryId
delivery_status = 'SHIPPED' ] )
THEN if_abap_behv=>fc-f-read_only
ELSE if_abap_behv=>fc-f-unrestricted ) )
).
ENDMETHOD.
자식 1,000건이 부모 50건을 공유한다면 SELECT 1,000회가 1회로, 그것도 50건만 읽는 좁은 쿼리로 줄어듭니다.
현장에서 자주 부딪히는 함정과 대응
Q1. FOR ALL ENTRIES인데 keys가 비었으면? 빈 internal table을 그대로 넘기면 WHERE 절이 무효화되어 전체 테이블이 읽힙니다. 메서드 진입부에서 IF keys IS INITIAL. RETURN. ENDIF.는 거의 의무입니다.
Q2. requested_features를 무시해도 되나요? 권장되지 않습니다. 클라이언트가 특정 feature만 요청했는데 메서드가 모든 feature를 계산하면 불필요한 비용이 발생합니다. IF requested_features-%action-cancelOrder = if_abap_behv=>mk-on.로 분기해 필요한 것만 계산하세요.
Q3. 클래스 attribute 캐시가 다른 요청에 오염되지 않나요? Behavior Implementation handler 인스턴스는 일반적으로 요청 단위로 생성·해제되지만, 정확한 lifetime은 시나리오와 릴리즈에 따라 다르므로 FINALIZE 메서드에서 캐시를 초기화하거나, READ ENTITIES 기반 접근을 우선 사용하는 편이 안전합니다.
Q4. 측정은 어떻게 하나요? ADT의 RAP Generator로 OData URL을 받아 SAT 트레이스 → "DB Accesses" 탭에서 SELECT 개수와 누적 시간을 확인합니다. ST05의 "Identical Selects" 카운트가 0에 가까울수록 좋은 신호입니다.
이어서 살펴보면 좋은 주제
Dynamic Feature Control 최적화에 익숙해졌다면, FOR INSTANCE AUTHORIZATIONS의 동일 패턴, Determination의 on save vs on modify 트리거 최소화, side effects 정의로 불필요한 재조회 줄이기, 그리고 EML의 READ ENTITIES IN LOCAL MODE 활용을 차례로 보면 좋습니다. 대용량 트랜잭션 환경에서는 strict ( 2 ) 모드와 numbering 전략까지 함께 검토하는 것이 일반적인 권장 순서입니다.
더 깊이 파고들 수 있는 자료
댓글 0
아직 댓글이 없습니다.