RAP

Root View Entity 실수 3가지 큰일 납니다 #shorts #SAP #RAP

▶ YouTube에서 보기

큰일 났습니다, 당신의 Root View Entity가 잘못 설계됐다면

"분명히 CDS View Entity로 모델링은 끝났는데, 왜 Fiori Elements 화면에서 자식 데이터가 안 나오죠?", "왜 Draft가 활성화 안 되죠?", "왜 트랜잭션 동작이 부모-자식 단위로 묶이지 않죠?" — RAP(ABAP RESTful Application Programming Model) 프로젝트 후반부에 이런 질문이 쏟아지기 시작하면, 거의 100% Root View Entity 설계 단계에서 놓친 부분이 있다는 신호입니다. 더 큰 문제는 이 실수가 단순한 컴파일 에러가 아니라, 비즈니스 객체(Business Object) 전체의 트랜잭션 일관성을 무너뜨린다는 점입니다.

이 글은 SAP BTP ABAP Environment 및 S/4HANA 2023 이상 환경에서 RAP의 출발점인 Root View Entity를 설계할 때 초보자들이 자주 빠지는 함정과 그 해결책을, 가상의 구매주문(Purchase Order) 모델을 예제로 단계별로 풀어내는 실전 예제입니다.

이 글에서 다룰 핵심 체크리스트

  • Root View Entity와 일반 CDS View Entity의 본질적 차이
  • Composition Tree가 깨졌을 때 RAP 런타임이 보이는 증상
  • Key 필드 선택이 추후 Lock·Draft·OData URL에 미치는 영향
  • 잘못된 설계 두 가지 패턴과 리팩토링 방법
  • 프로덕션 등급의 BDEF(Behavior Definition) 연결 예제

읽기 전에 알고 있으면 좋은 기반 지식

ABAP CDS의 기본 문법(define view entity, association, annotation)에 익숙해야 합니다. 또한 RAP의 두 가지 시나리오 — Managed/Unmanaged — 의 차이, 그리고 Business Object가 "Root + Child Nodes + Composition" 트리 구조로 구성된다는 점을 이해하고 있어야 합니다. Eclipse ADT(ABAP Development Tools) 설치는 필수이며, SEGW 기반 OData가 아닌 RAP 기반이라는 점도 전제로 합니다.

개발 환경과 사전 준비 사항

이 글의 예제는 다음 환경을 기준으로 검증되었습니다.

  • SAP BTP ABAP Environment(Steampunk) 2025 릴리스 또는 S/4HANA 2023 FPS01 이상
  • ABAP Development Tools for Eclipse 3.40 이상
  • ABAP 언어 버전: ABAP for Cloud Development
  • 패키지 권한: 로컬 객체($TMP) 또는 Cloud Package에 CDS, BDEF, Service Definition, Service Binding 생성 권한
  • 샘플 데이터베이스 테이블 2개: zpurord_hdr(헤더), zpurord_itm(아이템)

실제 프로덕션에서는 명명 규칙(Z*, Y*, 고객 네임스페이스 /CUST/*)과 패키지 분리 전략을 먼저 정해두는 것을 권장합니다. 또한 Composition을 설계할 때는 데이터베이스 테이블의 외래키 관계가 이미 ER 다이어그램 수준에서 명확히 정리되어 있어야, CDS에서 헤매지 않고 한 번에 트리를 그릴 수 있습니다.

Root View Entity를 정확히 이해하기

Root View Entity는 단순히 "맨 위에 있는 CDS View"가 아닙니다. RAP에서는 Business Object의 루트 노드를 정의하는 특수한 역할을 부여받은 View Entity입니다. 비유하자면 회사 조직도의 최상위 법인입니다. 자회사(Child Node)들은 모두 이 법인을 통해서만 외부와 거래할 수 있고, 트랜잭션·Lock·Draft·권한 검사 역시 이 루트를 기준으로 일어납니다.

일반 View Entity와 다른 점은 세 가지입니다.

  1. 어노테이션: @ObjectModel.semanticKeyprovider contract transactional_query가 붙어야 RAP 트리의 루트로 인식됩니다. 또한 BDEF에서 define behavior for ZR_PurchaseOrder alias PurchaseOrder ... with draft 식으로 루트 단위에서 행동(Behavior)이 정의됩니다.
  2. Composition: composition [0..*] of ZR_PurchaseOrderItem as _Item처럼 자식을 "구성 관계"로 끌어안습니다. Association과 달리 Composition은 부모의 생명주기에 자식이 종속됩니다. 부모가 삭제되면 자식도 함께 사라지는 강한 결합입니다.
  3. Key: 루트의 Key는 곧 OData URL의 식별자가 됩니다. /PurchaseOrder(PurchaseOrderUUID=guid'...')와 같이 노출되며, 한 번 결정하면 외부 시스템 연동까지 영향을 미치므로 변경이 매우 어렵습니다.

도식으로 표현하면 다음과 같습니다.

[Root View Entity: ZR_PurchaseOrder]
        |
        | composition [0..*]
        v
[Child View Entity: ZR_PurchaseOrderItem]
        |
        | composition [0..*]
        v
[Grandchild View Entity: ZR_PurchaseOrderItemSchedule]

이 트리에서 모든 노드는 반드시 association to parent로 부모를 역참조해야 합니다. 이 한 줄을 빼먹는 순간, RAP 런타임은 트리 일관성을 보장할 수 없다고 판단해 액티베이션 단계에서 거부하거나, 더 나쁘게는 런타임에 "child not reachable" 류의 에러를 던집니다.

잘못된 설계 패턴 1: Composition 대신 Association을 쓴다

가장 흔한 실수는 Composition과 Association을 같은 것으로 착각하는 경우입니다. 처음 RAP를 접한 개발자는 익숙한 Association으로 모든 관계를 표현하려 합니다.

// 잘못된 예: Root처럼 보이지만 Root가 아님
@AccessControl.authorizationCheck: #CHECK
define view entity ZR_PurchaseOrder_BAD
  as select from zpurord_hdr as Header
  association [0..*] to ZR_PurchaseOrderItem_BAD as _Item
    on $projection.PurchaseOrderUUID = _Item.PurchaseOrderUUID
{
  key Header.po_uuid    as PurchaseOrderUUID,
      Header.po_number  as PurchaseOrderNumber,
      Header.supplier   as Supplier,
      Header.total_amt  as TotalAmount,
      _Item
}

이 코드는 일반 View Entity로는 잘 컴파일됩니다. 하지만 BDEF에서 with draft를 시도하거나, Fiori Elements List Report에서 Object Page로 내려가 자식 아이템을 편집하려 하면 즉시 문제가 드러납니다. RAP는 부모-자식의 생명주기 관리를 Composition을 통해서만 인식하기 때문에, Association으로만 묶인 자식은 "내가 책임지지 않는 외부 엔티티"로 간주됩니다. 결과적으로 Create-by-Association이 동작하지 않고, Save 시 자식이 누락되는 끔찍한 버그가 발생합니다.

잘못된 설계 패턴 2: Key를 비즈니스 키로만 잡는다

두 번째 함정은 Key 설계입니다. 초보자는 "구매주문번호가 유일하니까 그걸 Key로 쓰면 되겠다"고 생각합니다.

// 잘못된 예: 비즈니스 키만 사용
define root view entity ZR_PurchaseOrder_BAD2
  as select from zpurord_hdr
  composition [0..*] of ZR_PurchaseOrderItem_BAD2 as _Item
{
  key po_number   as PurchaseOrderNumber,  // 비즈니스 키
      supplier    as Supplier,
      total_amt   as TotalAmount,
      _Item
}

이 설계의 문제는 Draft 처리에 있습니다. RAP의 Draft 메커니즘은 "아직 저장되지 않은 상태"의 객체를 임시 영역에 보관하는데, 이때 비즈니스 키(채번된 PO 번호)는 저장 시점에야 결정되는 경우가 많습니다. 그래서 Draft 단계에서는 Key가 비어 있게 되고, 동일한 빈 키를 가진 Draft가 여러 개 생기면 UNIQUE 제약 위반이 발생합니다. 또한 Late Numbering(저장 시 채번) 시나리오에서는 Key가 도중에 바뀌어야 하는데, RAP는 Key 변경을 허용하지 않습니다.

올바른 Composition Tree 설계

해결책은 UUID 기반 기술 키 + Composition + Association to Parent 삼종 세트입니다. 다음은 리팩토링된 Root View Entity입니다.

@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Purchase Order - Root'
@Search.searchable: true
@ObjectModel.semanticKey: ['PurchaseOrderNumber']
define root view entity ZR_PurchaseOrder
  as select from zpurord_hdr as Header
  composition [0..*] of ZR_PurchaseOrderItem as _Item
{
  key Header.po_uuid              as PurchaseOrderUUID,
      Header.po_number            as PurchaseOrderNumber,
      Header.supplier_id          as SupplierID,
      Header.order_date           as OrderDate,
      @Semantics.amount.currencyCode: 'Currency'
      Header.total_amt            as TotalAmount,
      Header.currency             as Currency,
      Header.overall_status       as OverallStatus,
      Header.created_by           as CreatedBy,
      Header.created_at           as CreatedAt,
      Header.last_changed_by      as LastChangedBy,
      Header.last_changed_at      as LastChangedAt,
      @Semantics.systemDate.localInstanceLastChangedAt: true
      Header.local_last_changed_at as LocalLastChangedAt,
      _Item
}

그리고 자식 노드는 다음과 같이 부모로 돌아가는 길을 명시해야 합니다.

define view entity ZR_PurchaseOrderItem
  as select from zpurord_itm as Item
  association to parent ZR_PurchaseOrder as _PurchaseOrder
    on $projection.PurchaseOrderUUID = _PurchaseOrder.PurchaseOrderUUID
  composition [0..*] of ZR_PurchaseOrderItemSchedule as _Schedule
{
  key Item.item_uuid          as ItemUUID,
      Item.po_uuid            as PurchaseOrderUUID,
      Item.item_position      as ItemPosition,
      Item.material_id        as MaterialID,
      Item.quantity           as Quantity,
      Item.unit               as Unit,
      @Semantics.amount.currencyCode: 'Currency'
      Item.net_amount         as NetAmount,
      Item.currency           as Currency,
      _PurchaseOrder,
      _Schedule
}

핵심 포인트 세 가지를 다시 정리하면, 첫째 루트는 define root view entity 키워드를 명시해야 하고, 둘째 자식과 손자는 모두 association to parent를 가져야 하며, 셋째 부모의 Key 필드가 자식에도 포함되어 있어야 트리 무결성이 보장됩니다.

Key 필드 설계의 다섯 가지 규칙

  1. UUID를 기술 키로 사용한다. 타입은 sysuuid_x16(raw 16바이트)을 권장합니다. Draft·Lock·OData URL 모두에서 안정적입니다.
  2. 비즈니스 키는 별도 필드로 유지한다. PO 번호, 계약 번호 같은 사용자 인지 식별자는 @ObjectModel.semanticKey로 마킹해 UI에서 표시용으로 활용합니다.
  3. 자식 노드의 Key에는 부모 Key를 포함시킨다. 그래야 데이터베이스 조인이 효율적이고 Composition 무결성이 유지됩니다.
  4. Key 필드는 절대 변경 가능하면 안 된다. BDEF에서 field ( readonly ) PurchaseOrderUUID;를 명시해 외부 수정으로부터 보호합니다.
  5. Key 개수는 최소로. 다중 키는 OData URL을 복잡하게 만들고 캐시 효율을 떨어뜨립니다. 가능하면 단일 UUID로 통일합니다.

End-to-End 실전 예제: BDEF와 Service Binding까지

설계가 끝났다면 Behavior Definition으로 행동을 입혀줍니다. 다음은 Managed 시나리오에서 Draft까지 활성화한 예제입니다.

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

define behavior for ZR_PurchaseOrder alias PurchaseOrder
persistent table zpurord_hdr
draft table zpurord_hdr_d
lock master
total etag LastChangedAt
authorization master ( instance )
etag master LocalLastChangedAt
{
  field ( readonly ) PurchaseOrderUUID, CreatedBy, CreatedAt,
                     LastChangedBy, LastChangedAt, LocalLastChangedAt;
  field ( mandatory ) SupplierID, OrderDate;

  create;
  update;
  delete;

  draft action Edit;
  draft action Activate optimized;
  draft action Discard;
  draft action Resume;
  draft determine action Prepare;

  association _Item { create; with draft; }
}

define behavior for ZR_PurchaseOrderItem alias PurchaseOrderItem
persistent table zpurord_itm
draft table zpurord_itm_d
lock dependent by _PurchaseOrder
authorization dependent by _PurchaseOrder
etag master LocalLastChangedAt
{
  field ( readonly ) ItemUUID, PurchaseOrderUUID;
  field ( mandatory ) MaterialID, Quantity, Unit;

  update;
  delete;

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

여기서 주목할 점은 자식 노드의 lock dependent by _PurchaseOrder입니다. 이 한 줄이 "자식을 편집할 때 부모 단위로 Lock을 건다"는 의미이며, Composition Tree가 올바르게 설계되어 있어야만 컴파일됩니다. 만약 앞서 본 잘못된 패턴처럼 Association만 썼다면 이 줄 자체가 문법 오류를 일으킵니다.

마지막으로 Projection View와 Service Definition·Binding을 연결합니다.

@EndUserText.label: 'Purchase Order - Projection'
@AccessControl.authorizationCheck: #CHECK
define root view entity ZC_PurchaseOrder
  provider contract transactional_query
  as projection on ZR_PurchaseOrder
{
  key PurchaseOrderUUID,
      PurchaseOrderNumber,
      SupplierID,
      OrderDate,
      TotalAmount,
      Currency,
      OverallStatus,
      LastChangedAt,
      LocalLastChangedAt,
      /* associations */
      _Item : redirected to composition child ZC_PurchaseOrderItem
}
@EndUserText.label: 'Purchase Order Service'
define service ZUI_PurchaseOrder {
  expose ZC_PurchaseOrder      as PurchaseOrder;
  expose ZC_PurchaseOrderItem  as PurchaseOrderItem;
}

자주 만나는 오류와 트러블슈팅 FAQ

Q1. "Entity is not a child entity" 에러가 BDEF 액티베이션 시 발생합니다.
원인은 자식 View Entity에 association to parent가 없거나, 루트에 composition이 빠진 경우입니다. CDS와 BDEF가 트리 구조에서 서로를 인식하지 못합니다. 자식 쪽 CDS를 열어 첫 줄에 association to parent ZR_PurchaseOrder as _PurchaseOrder on ...이 있는지 확인하세요.

Q2. Draft를 활성화했는데 List Report에서 "Edit" 버튼이 회색입니다.
이는 보통 Projection View에 provider contract transactional_query가 빠졌거나, BDEF에 with draft가 없는 경우입니다. 또한 Service Binding 타입이 OData V4 - UI가 아니라 Web API로 잡혀 있어도 Draft가 안 보입니다. Binding 설정을 다시 확인하세요.

Q3. OData URL에 키가 두 개 들어가서 너무 복잡합니다.
Composite Key를 사용한 결과입니다. 일반적으로 RAP에서는 단일 UUID 키를 권장합니다. 만약 기존 테이블 호환 때문에 어쩔 수 없다면 Projection 단계에서 키를 단일화하거나, Custom Entity로 노출 단계를 한 번 더 감싸는 방법을 고려하세요.

Q4. Composition을 0..1로 잡았는데 자식이 두 개 생성됩니다.
Cardinality는 모델링 의도일 뿐, 실제 데이터 무결성은 데이터베이스 제약과 BDEF의 Validation으로 보강해야 합니다. determination on save 또는 validation을 추가해 런타임에 강제하세요.

핵심 정리 — 설계 전 반드시 확인하는 체크리스트

Root View Entity 설계를 마치기 전에 다음 일곱 가지를 반드시 점검하세요. 이 체크리스트 하나면 앞서 살펴본 대부분의 함정을 사전에 차단할 수 있습니다.

  1. 루트 View에 define root view entity 키워드가 있는가?
  2. 자식과의 관계가 composition으로 선언되어 있는가? (association이 아니라)
  3. 모든 자식 노드에 association to parent가 있는가?
  4. 루트의 Key가 UUID 기반 기술 키인가?
  5. 자식의 Key에 부모 UUID가 포함되어 있는가?
  6. Projection View에 provider contract transactional_query가 선언되어 있는가?
  7. BDEF에서 자식의 lock/authorization이 dependent by _ParentAssociation으로 설정되어 있는가?

RAP는 강력한 프레임워크이지만, 그 힘의 절반은 올바른 Composition Tree 설계에서 나옵니다. 처음 한 번만 제대로 설계해두면 Draft·Lock·트랜잭션·Fiori Elements가 모두 자동으로 맞물려 돌아갑니다. 반대로 이 단계를 서두르면 프로젝트 막판에 구조를 통째로 뜯어고치는 대공사를 피할 수 없습니다. 이 글이 그 첫 단추를 바르게 끼우는 데 도움이 되기를 바랍니다.

댓글 0

아직 댓글이 없습니다.