Deep Insert가 필요한 이유 — 기존 방식의 한계
전형적인 주문 처리 화면을 떠올려 봅시다. 사용자는 SalesOrder 헤더를 입력하고, 그 아래에 여러 줄의 OrderItem을 함께 채워 넣은 뒤 한 번의 저장 버튼으로 모두 등록하길 원합니다. 그런데 OData V2 시절 흔히 쓰던 방식대로 부모를 먼저 POST한 다음, 응답으로 받은 key를 가지고 자식을 다시 N번 POST하는 구조라면 어떤 문제가 생길까요?
- 원자성 결여: 헤더는 저장됐는데 일부 라인 등록이 실패하면 데이터 정합성이 깨집니다.
- 네트워크 비용: 라인 수만큼 왕복이 발생해 응답 시간이 길어집니다.
- 잠금(lock) 충돌: 동일 헤더에 대해 여러 라인을 순차 POST하면 락 경합이 자주 발생합니다.
- UX 복잡성: 프런트엔드에서 부분 실패 시 롤백 로직을 직접 구현해야 합니다.
이 글에서 다루는 Deep Insert는 OData V4와 ABAP RESTful Application Programming Model(RAP)이 함께 제공하는 기능으로, 부모와 자식 엔티티를 한 번의 HTTP 요청 안에서 트리 형태로 묶어 전송하고, BTP/S/4HANA Cloud의 RAP 런타임이 이를 단일 트랜잭션으로 처리하도록 보장합니다. 이 글의 목표 체크리스트는 다음과 같습니다.
- Composition 관계가 Deep Insert를 가능하게 하는 메커니즘 이해
- CDS, Behavior Definition, Behavior Implementation 3계층에서의 설정 포인트 파악
- SalesOrder/OrderItem 시나리오로 실전 코드 작성
- Validation, Determination, SaveSequence를 활용한 일관성 유지
- 흔한 오류(BDEF mismatch, key 누락)에 대한 트러블슈팅
읽기 전 갖춰두면 좋은 배경
이 글은 advanced 난이도이므로 다음 개념에 대한 기본 이해를 가정합니다. ABAP CDS View와 어노테이션 문법, Managed RAP 시나리오의 기본 구조(Behavior Definition/Implementation), OData V4의 batch 요청과 changeset 개념, ADT(ABAP Development Tools) 사용법, 그리고 EML(Entity Manipulation Language)의 MODIFY ENTITIES 구문입니다. 또한 Eclipse ADT에서 Service Binding을 생성하고 Service Preview로 테스트해 본 경험이 있으면 마지막 섹션을 따라가기 수월합니다.
실행 환경과 준비물
실습 환경은 다음 조합을 권장합니다.
- SAP BTP, ABAP Environment 2308 이상 또는 S/4HANA Cloud Public Edition 2402 이상 (Steampunk/Embedded Steampunk)
- ABAP Development Tools(ADT) for Eclipse 3.36 이상 — Behavior Definition 편집기와 Local Class Generator를 사용
- OData 모델은 V4 사용을 권장 (Deep Insert payload 구조가 V2와 다름)
- 테스트 도구: Service Binding의 Service Preview, Postman 또는 ADT 내장 REST 클라이언트
온프레미스 ABAP 7.58 이상이라면 일반적으로 동일한 RAP 문법을 사용할 수 있지만, Cloud 환경의 Released API만 허용되는 제약과 차이가 있습니다. 권한은 Developer 역할(SAP_BR_DEVELOPER 또는 BTP의 Business_Catalog_Developer)이 필요합니다.
RAP Composition과 Deep Insert가 맞물리는 방식
Composition은 단순한 1:N 연관관계(association)와는 결정적으로 다릅니다. 비유하자면 association은 "내가 알고 지내는 사람들의 명함첩"이고, composition은 "내 몸의 일부인 장기"입니다. 부모가 사라지면 자식도 같이 사라져야 하는, 존재 자체가 종속된 관계지요.
Composition: 부모의 lifecycle에 자식이 묶여 있다. 부모를 삭제하면 자식도 cascade로 삭제되고, 부모를 생성하면 자식은 같은 트랜잭션의 일부로 존재해야 한다.
RAP에서 Deep Insert가 작동하는 흐름은 다음과 같이 이해할 수 있습니다.
- 클라이언트가 부모 payload 안에 자식 배열을 포함한 JSON을 POST
- OData V4 런타임이 이를 파싱하고 RAP의 EML MODIFY 명령으로 변환 — 내부적으로 부모 CREATE 1건과 자식 CREATE_BY_ASSOCIATION N건으로 분해
- Interaction Phase에서 모든 CREATE가 transactional buffer에 적재
- Save Sequence가 트리거되면 CHECK_BEFORE_SAVE → ADJUST_NUMBERS → SAVE 순서로 일괄 처리
- 중간에 어느 한 단계라도 실패하면 전체 ROLLBACK
핵심은 %cid(content ID)라는 클라이언트 측 임시 식별자입니다. 헤더가 아직 DB에 저장되지 않아 진짜 키가 없는 상태에서, 자식이 어떤 부모에 속하는지 표시해야 하기 때문이지요. 자식은 부모의 %cid를 참조하여 "이 부모의 자식임"을 선언합니다. 런타임은 ADJUST_NUMBERS 단계에서 실제 번호를 채번한 뒤 부모-자식 키를 일관되게 연결합니다.
CDS 모델링 — SalesOrder와 OrderItem
가장 먼저 부모 CDS 루트 엔티티와 자식 CDS 엔티티를 정의합니다. 자식 엔티티에는 부모 쪽에 composition of child_view as _Items를 선언하고 자식 쪽에서 association to parent _SalesOrder를 두는 양방향 구조가 일반적입니다.
@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Sales Order Root'
define root view entity ZI_SalesOrder
as select from zsalesord_hdr as Header
composition [0..*] of ZI_SalesOrderItem as _Items
{
key Header.so_uuid as SalesOrderUUID,
Header.so_id as SalesOrderID,
Header.customer_id as CustomerID,
Header.order_date as OrderDate,
Header.currency_code as CurrencyCode,
Header.gross_amount as GrossAmount,
@Semantics.systemDateTime.createdAt: true
Header.created_at as CreatedAt,
@Semantics.user.createdBy: true
Header.created_by as CreatedBy,
_Items
}
@AccessControl.authorizationCheck: #CHECK
define view entity ZI_SalesOrderItem
as select from zsalesord_itm as Item
association to parent ZI_SalesOrder as _SalesOrder
on $projection.SalesOrderUUID = _SalesOrder.SalesOrderUUID
{
key Item.item_uuid as ItemUUID,
Item.so_uuid as SalesOrderUUID,
Item.item_pos as ItemPosition,
Item.product_id as ProductID,
Item.quantity as Quantity,
Item.quantity_unit as QuantityUnit,
Item.net_amount as NetAmount,
_SalesOrder
}
주의할 점은 자식의 association to parent 조건에 부모 키 필드(여기서는 SalesOrderUUID)가 정확히 매핑되어야 한다는 것입니다. 이 매핑이 곧 Deep Insert에서 부모-자식 키 동기화의 근거가 됩니다.
BDEF에서 Deep Insert를 가능하게 하는 권한 설정
다음으로 Behavior Definition을 작성합니다. 부모에는 create를, 자식에는 create가 아닌 association _Items { create; } 형태로 "부모를 거쳐서만 생성 가능"하도록 표현하는 것이 권장 패턴입니다.
managed implementation in class zbp_i_salesorder unique;
strict ( 2 );
with draft;
define behavior for ZI_SalesOrder alias SalesOrder
persistent table zsalesord_hdr
draft table zsalesord_hdr_d
lock master
authorization master ( instance )
etag master CreatedAt
{
field ( readonly, numbering : managed ) SalesOrderUUID;
field ( readonly ) SalesOrderID, CreatedAt, CreatedBy, GrossAmount;
field ( mandatory ) CustomerID, CurrencyCode;
create;
update;
delete;
association _Items { create; with draft; }
determination calcGrossAmount on save { create; field _Items; }
validation validateCustomer on save { create; field CustomerID; }
}
define behavior for ZI_SalesOrderItem alias SalesOrderItem
persistent table zsalesord_itm
draft table zsalesord_itm_d
lock dependent by _SalesOrder
authorization dependent by _SalesOrder
etag master CreatedAt
{
field ( readonly, numbering : managed ) ItemUUID;
field ( readonly ) SalesOrderUUID;
field ( mandatory ) ProductID, Quantity, QuantityUnit;
update;
delete;
association _SalesOrder;
}
여기서 중요한 키워드는 lock dependent와 authorization dependent입니다. 자식이 부모의 lock과 권한을 상속하므로 Deep Insert 시 단일 lock으로 전체 트리가 보호됩니다. 또한 자식 쪽에는 create;가 없습니다 — 자식은 오직 association _Items { create; }를 통해서만 생성 가능합니다.
1단계 — 단일 OData 요청으로 SalesOrder + OrderItem 동시 생성
Service Definition과 Service Binding을 통해 OData V4로 노출했다고 가정합시다. 클라이언트는 다음과 같은 JSON으로 POST 요청을 보냅니다.
POST /sap/opu/odata4/sap/zui_salesorder_o4/srvd_a2x/sap/zsalesorder/0001/SalesOrder
Content-Type: application/json
{
"CustomerID": "CUST-100023",
"CurrencyCode": "EUR",
"OrderDate": "2026-06-18",
"_Items": [
{
"ItemPosition": "10",
"ProductID": "PROD-BIKE-X",
"Quantity": "2",
"QuantityUnit": "EA",
"NetAmount": "1500.00"
},
{
"ItemPosition": "20",
"ProductID": "PROD-HELMET-S",
"Quantity": "2",
"QuantityUnit": "EA",
"NetAmount": "120.00"
}
]
}
응답에는 채번된 SalesOrderID와 두 OrderItem이 expand되어 함께 돌아옵니다. 부모 키가 자식에도 자동으로 채워졌음을 확인할 수 있습니다.
2단계 — CREATE 메서드에 Validation과 파생 필드 처리 추가
실무에서는 단순 저장만으로 끝나지 않습니다. 고객 존재 여부 검증, 통화 유효성, 라인 합계로부터 헤더 GrossAmount 계산 등이 필요합니다. Behavior Implementation의 saver 메서드와 determination에 다음과 같이 작성합니다.
METHOD calcGrossAmount.
READ ENTITIES OF zi_salesorder IN LOCAL MODE
ENTITY SalesOrder BY \_Items
FIELDS ( NetAmount )
WITH CORRESPONDING #( keys )
RESULT DATA(items)
LINK DATA(item_links).
LOOP AT keys ASSIGNING FIELD-SYMBOL(<parent>).
DATA(lv_total) = REDUCE decfloat34(
INIT t = CONV decfloat34( 0 )
FOR i IN items WHERE ( SalesOrderUUID = <parent>-SalesOrderUUID )
NEXT t = t + i-NetAmount ).
MODIFY ENTITIES OF zi_salesorder IN LOCAL MODE
ENTITY SalesOrder
UPDATE FIELDS ( GrossAmount )
WITH VALUE #( ( %tky = <parent>-%tky
GrossAmount = lv_total ) )
REPORTED DATA(upd_reported).
ENDLOOP.
ENDMETHOD.
METHOD validateCustomer.
READ ENTITIES OF zi_salesorder IN LOCAL MODE
ENTITY SalesOrder
FIELDS ( CustomerID )
WITH CORRESPONDING #( keys )
RESULT DATA(orders).
LOOP AT orders INTO DATA(order).
SELECT SINGLE customer_id FROM zcust_master
WHERE customer_id = @order-CustomerID
INTO @DATA(lv_exists).
IF sy-subrc <> 0.
APPEND VALUE #( %tky = order-%tky ) TO failed-salesorder.
APPEND VALUE #( %tky = order-%tky
%msg = NEW zcm_sales_order(
textid = zcm_sales_order=>customer_unknown
severity = if_abap_behv_message=>severity-error
customer = order-CustomerID )
%element-CustomerID = if_abap_behv=>mk-on
) TO reported-salesorder.
ENDIF.
ENDLOOP.
ENDMETHOD.
이 validation은 on save 시점에 실행되므로, Deep Insert로 들어온 부모와 자식이 모두 buffer에 적재된 후에 호출됩니다. 따라서 자식 합계를 부모 GrossAmount에 반영하는 determination도 동일한 시점에 안전하게 동작합니다.
3단계 — 프로덕션 수준의 일관성, 성능, 보안
SaveSequence와 EML의 SAVE_MODIFIED를 활용하면 대량 Deep Insert에서도 일관성을 유지할 수 있습니다. 또한 numbering의 managed/unmanaged 선택, 그리고 instance authorization을 통한 행 단위 권한 체크가 중요합니다.
METHOD save.
DATA: lt_so_db TYPE STANDARD TABLE OF zsalesord_hdr,
lt_item_db TYPE STANDARD TABLE OF zsalesord_itm.
LOOP AT create-salesorder INTO DATA(ls_so).
APPEND VALUE #( so_uuid = ls_so-SalesOrderUUID
so_id = ls_so-SalesOrderID
customer_id = ls_so-CustomerID
currency_code = ls_so-CurrencyCode
order_date = ls_so-OrderDate
gross_amount = ls_so-GrossAmount
created_at = cl_abap_context_info=>get_system_date( )
created_by = cl_abap_context_info=>get_user_technical_name( ) )
TO lt_so_db.
ENDLOOP.
LOOP AT create-salesorderitem INTO DATA(ls_it).
APPEND VALUE #( item_uuid = ls_it-ItemUUID
so_uuid = ls_it-SalesOrderUUID
item_pos = ls_it-ItemPosition
product_id = ls_it-ProductID
quantity = ls_it-Quantity
quantity_unit = ls_it-QuantityUnit
net_amount = ls_it-NetAmount )
TO lt_item_db.
ENDLOOP.
INSERT zsalesord_hdr FROM TABLE @lt_so_db.
INSERT zsalesord_itm FROM TABLE @lt_item_db.
ENDMETHOD.
프로덕션 체크리스트는 다음과 같습니다.
- Numbering 전략: SalesOrderUUID는 managed(시스템 채번), SalesOrderID는 early numbering으로 비즈니스 번호 부여 — 외부에 노출 전에 결정되어야 하면 early, 그렇지 않으면 late를 일반적으로 권장
- Instance Authorization: 영업조직별 권한 체크는 부모 BDEF의
authorization master ( instance )로 구현하고 자식은 dependent로 상속 - 로깅:
cl_bali_log_db또는 Application Log API로 Deep Insert 실패 시 payload 일부를 마스킹 후 기록 - ETag: 부모의 CreatedAt/LastChangedAt를 etag로 지정해 동시성 충돌 감지
- 성능: 대량 라인을 가진 Deep Insert는 buffer 사이즈를 모니터링하고, 100건을 초과할 때는 클라이언트가 batch changeset으로 분할하도록 가이드
자주 마주치는 함정과 해결책
Q1. "Entity SalesOrderItem is not creatable" 오류가 납니다. 자식 BDEF에 create;를 직접 넣지 않았기 때문에 정상으로 보이지만, 부모 BDEF의 association _Items { create; } 선언을 빠뜨린 경우 발생합니다. 부모와 자식 양쪽의 association 선언을 모두 확인하세요.
Q2. Deep Insert는 성공했는데 자식의 SalesOrderUUID가 NULL입니다. CDS의 association to parent 조건절이 자식의 FK 필드(so_uuid)와 부모 키를 정확히 매핑하지 않으면 런타임이 키를 propagate하지 못합니다. 또한 자식 BDEF의 SalesOrderUUID 필드를 field ( readonly )로 표시해야 클라이언트가 임의 값을 보내도 무시되고 부모 키가 자동 채워집니다.
Q3. Validation은 통과했는데 Save 단계에서 dump가 납니다. 대개 saver 메서드에서 자식 buffer를 처리할 때 부모의 채번된 키 대신 임시 %cid를 그대로 INSERT 시도하는 경우입니다. RAP 런타임은 ADJUST_NUMBERS 단계에서 부모 키를 자식에게 propagate하므로, saver 메서드에서는 항상 propagate 이후의 키를 사용해야 합니다. 디버깅 시 ADT의 RAP BO Test Class를 활용하면 각 단계 buffer 상태를 확인할 수 있습니다.
추가 트러블슈팅 팁: OData V4 Service Preview에서 Deep Insert 요청을 보낼 때 Content-Type: application/json과 Accept: application/json을 명시하고, 응답 상태가 201 Created인지 그리고 Location 헤더가 새 부모 리소스 URL을 가리키는지 확인합니다. expand된 자식 배열이 응답에 포함되지 않으면 URL 끝에 ?$expand=_Items를 붙여 검증합니다.
한 걸음 더 — 확장 학습 주제
- Deep Update: PATCH로 부모와 자식을 동시에 수정하는 패턴 — Deep Insert와 동일한 트리 payload 구조
- Draft 처리와 Deep Insert: with draft 시 transactional buffer가 draft 테이블에 저장되는 흐름
- Unmanaged RAP에서의 Deep Insert: 직접 작성한 CREATE 핸들러에서 _Items association을 어떻게 처리하는지
- Fiori Elements의 List Report → Object Page 시나리오에서 자동으로 Deep Insert가 트리거되는 메커니즘
- OData $batch + changeset로 여러 Deep Insert를 묶는 대량 입력 처리
더 찾아볼 만한 문서와 링크
댓글 0
아직 댓글이 없습니다.