RAP

자식 레코드 남기는 거 그만 — RAP Cascade 3가지 #shorts #SAP #RAP

▶ YouTube에서 보기

구매 오더 삭제 시 라인/스케줄이 남는 문제 파헤치기

ERP 실무에서 가장 흔히 만나는 문제 중 하나가 PurchaseOrder(구매 오더)를 삭제했는데 PurchaseOrderItem(라인 아이템)이나 PurchaseOrderItemSchedule(납기 일정)이 데이터베이스에 그대로 남는 현상입니다. Fiori UI에서는 삭제된 것처럼 보이지만, SE16이나 HANA Studio로 원본 테이블을 열어보면 고아 레코드(orphan record)가 잔뜩 쌓여 있죠. 이 글에서는 SAP RAP(RESTful ABAP Programming Model)의 Composition Tree에서 Delete Cascade가 어떻게 동작하는지, 왜 자동으로 지워지지 않는 케이스가 발생하는지, 그리고 3계층 composition 구조에서 어떻게 안정적으로 부모-자식 삭제를 처리할지 실전 코드와 함께 정리합니다.

이 글을 통해 다음을 얻을 수 있습니다.

  • RAP Composition의 부모-자식 삭제 전파 원리 이해
  • CDS 뷰에서 composition [0..*] of 정확한 정의 방법
  • Behavior Definition의 with draft, authorization, internal 옵션이 삭제에 미치는 영향
  • Managed / Unmanaged 시나리오별 delete 구현 차이
  • 3계층(주문-라인-스케줄) 구조에서 cascade가 끊기는 지점 진단 방법

이 글을 읽기 전에 알아두면 좋은 것

RAP 기본 아티팩트(CDS View Entity, Behavior Definition, Behavior Implementation Class)의 역할을 알고 있어야 합니다. ABAP 개발 툴(ADT) 사용 경험, 그리고 MANAGED 시나리오와 UNMANAGED 시나리오의 차이를 구분할 수 있으면 이해가 훨씬 빠릅니다. Draft 기반 처리(with draft)와 early numbering/late numbering도 알아두면 좋고, EML(Entity Manipulation Language) 문법 기초는 필수입니다.

버전 및 개발 환경 준비

이 글의 예제는 다음 환경 기준으로 검증되었습니다. 클라우드 에디션(ABAP Cloud) 사용자는 릴리즈 컨트랙트 C1 계약이 걸린 오브젝트만 사용해야 하므로, 온프레미스와는 사용 가능한 신택스가 조금 다를 수 있습니다.

  • SAP S/4HANA 2023 FPS02 (온프레미스) 또는 ABAP Environment 2402 (BTP)
  • ABAP Development Tools(ADT) 3.36 이상 (Eclipse 2024-03 권장)
  • HANA DB 2.0 SPS07 이상
  • 테스트 도구: SAP Gateway Client, Fiori Elements 프리뷰, RAP Generator 툴
  • 권한: S_DEVELOP 개발 권한, 테스트 사용자에 Z_PO_MAINTAIN 등 예제 오소라이제이션 오브젝트

테이블은 3개를 생성합니다. ZTPO_HEADER(오더 헤더, 키: po_id), ZTPO_ITEM(라인 아이템, 키: po_id+item_no), ZTPO_SCHED(스케줄, 키: po_id+item_no+sched_no). 이렇게 상위 키를 하위가 완전히 포함(inclusive)해야 RAP의 강한 composition 특성이 성립합니다.

Composition Tree와 삭제 전파의 원리

RAP에서 Composition은 단순한 참조(Association)와는 근본적으로 다릅니다. Composition은 "존재론적 종속(existential dependency)"을 표현합니다. 즉, 부모가 사라지면 자식은 존재할 이유가 없기 때문에 함께 사라져야 한다는 의미입니다. UML의 검은 다이아몬드(filled diamond)와 같은 개념이라고 보면 됩니다.

Association은 "친구 관계"고, Composition은 "부모-자식 관계"다. 친구는 내가 죽어도 살아있지만, 자식은 부모(부모 노드)가 사라지면 논리적으로 함께 사라진다.

RAP 프레임워크는 이 종속성을 활용해 Delete Cascade를 자동으로 제공합니다. 개발자가 부모 엔티티에서 DELETE 명령을 EML로 실행하면, 프레임워크가 Composition Tree를 재귀적으로 순회하면서 하위 자식들의 DELETE를 순차적으로 호출합니다. 3계층 구조에서는 아래 순서로 이벤트가 발생합니다.

  1. 루트 엔티티(PurchaseOrder)의 DELETE 요청 접수
  2. 프레임워크가 자식 PurchaseOrderItem을 조회 후 각각 DELETE
  3. 각 아이템의 자식 PurchaseOrderItemSchedule을 조회 후 DELETE
  4. Save Sequence 진입 → save_modified에서 실제 DB DELETE 수행

여기서 흔한 오해가 있습니다. "Composition을 걸어놨으니 알아서 다 지워지겠지" 라고 생각하지만, 실제로는 Behavior Definition에 delete 명령이 명시적으로 선언되어 있어야 하며, Composition 관계가 CDS와 Behavior 양쪽 모두에서 일관되게 정의되어야 합니다. 한쪽만 되어 있으면 프레임워크는 자식 노드까지 재귀 호출을 하지 않습니다.

Delete Cascade가 안 되는 대표적인 원인들

실무에서 자주 부딪히는 원인을 정리하면 다음과 같습니다.

  • 자식 엔티티 Behavior Definition에 delete가 선언되지 않은 경우 (cascade 자체가 시도되지만 명령이 없어서 실패)
  • CDS에서 composition of가 아닌 association to로 잘못 정의
  • 자식 CDS의 _Parent to-one association이 누락 (역참조 없음)
  • Unmanaged 시나리오에서 개발자가 deletedelete_by_association 핸들러를 구현하지 않음
  • 키 필드 매핑(field mapping)이 부모-자식 간에 불일치
  • 중간 노드(라인 아이템)에는 delete가 있지만 최하위(스케줄)에는 없음 → 부분적으로만 지워짐

CDS Composition 정의 실전 예제

먼저 3계층 CDS 뷰를 올바르게 정의해봅시다. 헤더 뷰가 라인을 composition으로 갖고, 라인 뷰가 다시 스케줄을 composition으로 갖는 구조입니다.

// ZR_PURCHASEORDER (Root)
@AccessControl.authorizationCheck: #CHECK
@Metadata.allowExtensions: true
define root view entity ZR_PURCHASEORDER
  as select from ztpo_header as Header
  composition [0..*] of ZR_PURCHASEORDERITEM as _Item
{
  key Header.po_id           as PurchaseOrderId,
      Header.supplier_id     as SupplierId,
      Header.overall_status  as OverallStatus,
      Header.total_amount    as TotalAmount,
      Header.currency_code   as CurrencyCode,
      Header.created_by      as CreatedBy,
      Header.created_at      as CreatedAt,
      Header.last_changed_at as LastChangedAt,
      _Item
}
// ZR_PURCHASEORDERITEM (Child of Root)
define view entity ZR_PURCHASEORDERITEM
  as select from ztpo_item as Item
  association to parent ZR_PURCHASEORDER as _PurchaseOrder
    on $projection.PurchaseOrderId = _PurchaseOrder.PurchaseOrderId
  composition [0..*] of ZR_POITEMSCHEDULE as _Schedule
{
  key Item.po_id       as PurchaseOrderId,
  key Item.item_no     as ItemNumber,
      Item.material_id as MaterialId,
      Item.quantity    as Quantity,
      Item.unit        as Unit,
      Item.net_price   as NetPrice,
      _PurchaseOrder,
      _Schedule
}
// ZR_POITEMSCHEDULE (Grandchild)
define view entity ZR_POITEMSCHEDULE
  as select from ztpo_sched as Sched
  association to parent ZR_PURCHASEORDERITEM as _Item
    on  $projection.PurchaseOrderId = _Item.PurchaseOrderId
    and $projection.ItemNumber      = _Item.ItemNumber
{
  key Sched.po_id       as PurchaseOrderId,
  key Sched.item_no     as ItemNumber,
  key Sched.sched_no    as ScheduleNumber,
      Sched.delivery_date as DeliveryDate,
      Sched.sched_qty     as ScheduledQuantity,
      _Item
}

핵심 포인트는 세 가지입니다. 첫째, 루트(root)에만 root view entity를 붙이고 자식은 그냥 view entity입니다. 둘째, 자식은 반드시 association to parent로 역참조를 걸어야 합니다. 셋째, 부모의 키가 자식 키에 순서대로 포함되어야 하며 on 조건에서 모든 키 필드가 매핑되어야 합니다. 하나라도 빠지면 프레임워크가 트리를 재구성하지 못합니다.

Behavior Definition의 delete 처리 흐름과 실전 코드

이제 Behavior Definition을 작성해봅시다. 여기가 실제로 cascade 동작을 결정하는 지점입니다.

managed implementation in class zbp_r_purchaseorder unique;
strict ( 2 );
with draft;

define behavior for ZR_PURCHASEORDER alias PurchaseOrder
persistent table ztpo_header
draft table ztpo_header_d
lock master
total etag LastChangedAt
authorization master ( instance )
etag master LastChangedAt
{
  create;
  update;
  delete;
  association _Item { create; with draft; }

  field ( readonly ) PurchaseOrderId, CreatedBy, CreatedAt;
  field ( numbering : managed ) PurchaseOrderId;

  draft determine action Prepare
    { validation validateSupplier;
      validation validateItems; }
}

define behavior for ZR_PURCHASEORDERITEM alias PurchaseOrderItem
persistent table ztpo_item
draft table ztpo_item_d
lock dependent by _PurchaseOrder
authorization dependent by _PurchaseOrder
etag master LastChangedAt
{
  update;
  delete;
  field ( readonly ) PurchaseOrderId, ItemNumber;
  field ( numbering : managed ) ItemNumber;

  association _PurchaseOrder;
  association _Schedule { create; with draft; }
}

define behavior for ZR_POITEMSCHEDULE alias PurchaseOrderItemSchedule
persistent table ztpo_sched
draft table ztpo_sched_d
lock dependent by _Item
authorization dependent by _Item
{
  update;
  delete;
  field ( readonly ) PurchaseOrderId, ItemNumber, ScheduleNumber;

  association _Item;
}

이 정의에서 절대 빠지면 안 되는 요소를 표로 정리합니다.

요소역할빠졌을 때 증상
delete;해당 노드 삭제 허용Cascade 시 CX_RAP_QUERY 예외
lock dependent by부모 잠금 승계동시성 오류, 부분 삭제
authorization dependent by부모 권한 상속권한 부족으로 자식만 남음
association _ParentBehavior 레벨 역참조Cascade 재귀 중단

Managed 시나리오에서의 삭제 트리거

Managed 시나리오라면 위 정의만으로 프레임워크가 알아서 cascade 삭제를 수행합니다. 소비자(consumer) 코드에서는 다음처럼 EML을 사용합니다.

METHOD delete_purchase_order.
  DATA lt_po_delete TYPE TABLE FOR DELETE ZR_PURCHASEORDER.

  lt_po_delete = VALUE #( ( %key-PurchaseOrderId = iv_po_id
                            %control-PurchaseOrderId = if_abap_behv=>mk-on ) ).

  MODIFY ENTITIES OF ZR_PURCHASEORDER
    ENTITY PurchaseOrder
    DELETE FROM lt_po_delete
    FAILED   DATA(ls_failed)
    REPORTED DATA(ls_reported).

  IF ls_failed IS NOT INITIAL.
    " 실패 로깅
    LOOP AT ls_failed-purchaseorder INTO DATA(ls_fail).
      MESSAGE i001(zpo_msg) WITH ls_fail-%key-PurchaseOrderId
                                 ls_fail-%fail-cause.
    ENDLOOP.
    RETURN.
  ENDIF.

  COMMIT ENTITIES RESPONSE OF ZR_PURCHASEORDER
    FAILED   DATA(ls_commit_failed)
    REPORTED DATA(ls_commit_reported).
ENDMETHOD.

Unmanaged 시나리오의 delete 핸들러 (프로덕션)

레거시 함수모듈이나 BAPI와 연동해야 하는 실무 상황에서는 Unmanaged로 직접 구현해야 합니다. 이때는 FOR MODIFY 메서드에서 delete를 명시적으로 구현하고, cascade는 개발자가 책임집니다.

CLASS lhc_purchaseorder IMPLEMENTATION.

  METHOD delete.
    DATA: lt_item_keys  TYPE TABLE FOR KEY OF ZR_PURCHASEORDERITEM,
          lt_sched_keys TYPE TABLE FOR KEY OF ZR_POITEMSCHEDULE.

    " 1) 자식(라인) 키 수집
    READ ENTITIES OF ZR_PURCHASEORDER IN LOCAL MODE
      ENTITY PurchaseOrder BY \_Item
      ALL FIELDS WITH CORRESPONDING #( keys )
      RESULT DATA(lt_items).

    " 2) 손자(스케줄) 키 수집
    READ ENTITIES OF ZR_PURCHASEORDER IN LOCAL MODE
      ENTITY PurchaseOrderItem BY \_Schedule
      ALL FIELDS WITH VALUE #( FOR item IN lt_items
                               ( %key-PurchaseOrderId = item-PurchaseOrderId
                                 %key-ItemNumber      = item-ItemNumber ) )
      RESULT DATA(lt_schedules).

    " 3) 하위부터 위로 삭제 요청
    MODIFY ENTITIES OF ZR_PURCHASEORDER IN LOCAL MODE
      ENTITY PurchaseOrderItemSchedule
        DELETE FROM VALUE #( FOR s IN lt_schedules
                             ( %key = s-%key
                               %control-PurchaseOrderId = if_abap_behv=>mk-on ) )
      ENTITY PurchaseOrderItem
        DELETE FROM VALUE #( FOR i IN lt_items
                             ( %key = i-%key ) )
      ENTITY PurchaseOrder
        DELETE FROM keys
      FAILED failed
      REPORTED reported.

    " 4) 로그
    IF failed IS NOT INITIAL.
      cl_ba_log=>write( iv_object = 'ZPO' iv_subobj = 'DELETE'
                       iv_text = |Cascade delete failed for keys| ).
    ENDIF.
  ENDMETHOD.

ENDCLASS.

보안 관점에서는 authorization dependent by _PurchaseOrder를 통해 부모의 권한 체크를 상속받도록 설정하고, 삭제 이력은 /IWBEP/CL_MGW_ABS_MODEL 대신 cl_ba_log나 커스텀 감사 테이블에 반드시 남깁니다. 프로덕션에서는 COMMIT ENTITIES IN LOCAL MODE 이후 예외 발생 시 ROLLBACK ENTITIES를 호출해 부분 커밋을 방지해야 합니다.

흔한 실수와 트러블슈팅 FAQ

Q1. 부모는 지워졌는데 자식만 남습니다. 왜죠?

자식 Behavior Definition에 delete;가 없거나, Composition이 association to로 잘못 정의된 경우가 90% 이상입니다. ADT의 Behavior 에디터에서 자식 노드에 delete가 노란 하이라이트로 활성화되어 있는지 먼저 확인하세요.

Q2. Cascade는 되는데 저장 시 CX_RAP_QUERY_PROVIDER 예외가 납니다.

대부분 잠금(lock) 설정 문제입니다. 자식은 lock dependent by _Parent로 지정해야 하며, 부모는 lock master로 지정되어야 합니다. 부모-자식이 서로 lock master로 되어 있으면 잠금 충돌이 발생합니다.

Q3. Draft 모드에서는 삭제되는데 Active 모드에서 자식이 남습니다.

Draft 테이블과 Active 테이블의 키 구조가 일치하지 않거나, draft table이 자식 Behavior에 선언되어 있지 않은 경우입니다. Draft에서 Activate 시 프레임워크가 draft 데이터를 기준으로 active를 재구성하는데, 자식의 draft 테이블이 없으면 삭제 상태가 반영되지 못합니다.

Q4. 3계층 중 중간 노드(라인)까지는 지워지는데 스케줄이 남습니다.

중간 노드의 Behavior에서 association _Schedule 선언이 빠졌거나, 라인-스케줄 CDS composition의 on 조건에 스케줄 키 매핑이 누락된 경우입니다. RAP는 명시된 composition만 순회하기 때문에 한 단계라도 끊기면 그 아래는 손대지 않습니다.

Q5. 삭제 성능이 너무 느립니다.

Managed 시나리오에서 자식이 수만 건인 경우, 프레임워크가 한 건씩 순회하며 삭제 호출을 만들기 때문에 느려집니다. 이런 대량 삭제 시나리오에서는 별도의 백그라운드 아카이빙 잡을 두거나, Unmanaged로 전환해 DELETE FROM ztpo_item WHERE po_id = @iv_po_id 같은 벌크 SQL을 직접 사용하는 것이 일반적으로 권장됩니다.

확장 방향과 관련 주제

이 글의 내용을 마스터했다면 다음 주제로 확장해보세요. 첫째, determinationsvalidations를 delete 트리거에 연결해 삭제 전 비즈니스 규칙 체크(예: 이미 GR 처리된 오더는 삭제 불가) 로직을 붙일 수 있습니다. 둘째, Feature Control로 특정 상태의 오더에서만 delete 버튼이 활성화되도록 UI를 제어할 수 있습니다. 셋째, Business Event(with additional save)를 활용해 삭제 시 SAP Event Mesh로 이벤트를 발행할 수 있습니다. 넷째, 아카이빙 오브젝트(ADK)와 연동해 물리 삭제 대신 아카이브 후 삭제 전략을 구현할 수도 있습니다.

더 깊이 파고들 수 있는 참고 링크

댓글 0

아직 댓글이 없습니다.