RAP

아직도 Draft 없이 RAP 개발? 놓치는 것 3가지 #shorts #SAP #RAP

▶ YouTube에서 보기

RAP과 Draft: 왜 처음부터 설계에 포함되었는가

ABAP RESTful Application Programming Model(RAP)을 설계한 SAP 엔지니어링 팀은 단순한 CRUD 프레임워크를 만들고자 했던 것이 아닙니다. 그들이 마주한 문제는 명확했습니다 — 클라우드 환경에서 다수의 사용자가 동시에 같은 비즈니스 객체를 편집할 때, 어떻게 데이터 무결성과 사용자 경험을 모두 지킬 것인가. 이 질문에 대한 답이 바로 Draft 메커니즘입니다.

Draft는 단순히 "임시 저장" 기능이 아닙니다. 이는 비즈니스 트랜잭션의 "편집 세션"을 명시적으로 모델링한 아키텍처 패턴입니다. 사용자가 데이터를 수정하기 시작하는 순간부터 Save 버튼을 누르는 순간까지의 모든 상태가 별도의 Draft 테이블에 기록되며, 이 과정에서 Pessimistic Lock(exclusive)과 Optimistic Lock(ETag) 두 메커니즘이 자연스럽게 결합됩니다. 그래서 많은 시니어 ABAP 개발자들이 "Draft 없이 RAP을 쓰는 건 절반만 쓰는 것"이라고 말하는 것입니다.

이 글에서는 Draft를 의도적으로 제외한 RAP 개발이 어떤 함정에 빠질 수 있는지, ETag와 Lock 메커니즘이 어떻게 무너지는지, 그리고 그럼에도 불구하고 Draft 없이 가야 한다면 무엇을 보강해야 하는지를 다룹니다. 난이도는 advanced이며, BDEF/Behavior Implementation 작성 경험이 있다는 것을 전제로 합니다.

읽기 전 필요한 배경

이 글을 효과적으로 따라가려면 다음 개념에 익숙해야 합니다. CDS View와 Behavior Definition(BDEF)의 기본 문법, managedunmanaged 시나리오의 차이, RAP의 Interaction Phase와 Save Sequence, Fiori Elements List Report/Object Page의 동작 흐름, 그리고 SAP S/4HANA Cloud 또는 BTP ABAP Environment에서의 OData V4 노출 방식입니다. EML(Entity Manipulation Language)MODIFY ENTITIES 구문을 직접 작성해본 경험이 있다면 7번 섹션의 코드를 더 빠르게 이해할 수 있습니다.

검증 환경과 적용 버전

이 글의 모든 예제는 다음 환경에서 검증되었습니다.

  • SAP BTP ABAP Environment 2026 release (Steampunk 기반)
  • ABAP Development Tools(ADT) for Eclipse 2026-03 build
  • SAP S/4HANA 2023 FPS02 (on-premise BDEF 호환 확인용)
  • SAP Fiori Elements for OData V4, UI5 1.120 LTS
  • RAP Generator 2026 버전 — managed/unmanaged 시나리오 모두 검증

예제에 등장하는 비즈니스 엔티티는 공식 문서의 Travel 예제 대신 ZI_PurchaseContract(구매 계약), ZI_ServiceRequest(서비스 요청), ZI_VendorInvoice(공급업체 인보이스)로 재구성했습니다. 실무에서 마주하는 동시 편집 충돌 빈도가 높은 도메인을 의도적으로 선택한 것입니다. 모든 SQL/EML 코드는 ABAP Cloud 호환 모드에서 작성되어 Release Contract C1을 위반하지 않습니다.

Behavior Definition에서 with draft를 제거하면 내부에서 무엇이 사라지는가

RAP에서 Draft 활성화는 BDEF 헤더의 with draft 한 줄로 결정됩니다. 이 한 줄이 사라지는 순간, 프레임워크 내부에서는 다음과 같은 일들이 연쇄적으로 일어나지 않게 됩니다.

첫째, Draft 테이블이 생성되지 않습니다. BDEF에 draft table zdr_purchctr를 선언하면 RAP 런타임은 이 섀도우 테이블에 모든 중간 상태를 기록하는데, Draft 없는 BDEF에서는 이런 중간 영속화 자체가 일어나지 않습니다. 둘째, Exclusive Lock 획득 시점이 달라집니다. Draft가 있으면 사용자가 "Edit" 버튼을 누르는 순간 즉시 행 단위 Lock을 잡지만, Draft가 없으면 Lock은 Save 직전, 즉 MODIFY ENTITIES 호출 시점에야 잡힙니다. 셋째, EditAction이 자동 생성되지 않습니다. Fiori Elements는 Draft가 있을 때 자동으로 EditAction을 호출해 Draft 인스턴스를 만들지만, 없으면 이 핸들러가 아예 등록되지 않습니다.

비유하자면 Draft 있는 RAP은 "도서관에서 책을 대출해 자기 자리에서 메모하다가 반납"하는 모델이고, Draft 없는 RAP은 "서가에 꽂힌 책에 직접 펜으로 쓰고 닫는" 모델입니다. 후자는 빠르지만, 다른 사람이 같은 페이지를 펴고 있을 때 무슨 일이 벌어질지 예측할 수 없습니다.

// Draft 있는 BDEF - 안전한 편집 세션
managed implementation in class zbp_i_purchcontract unique;
strict ( 2 );
with draft;

define behavior for ZI_PurchaseContract alias Contract
persistent table zpurchctr_t
draft table zdr_purchctr
etag master LastChangedAt
lock master
authorization master ( instance )
{
  field ( readonly ) ContractUUID, ContractID;
  field ( mandatory ) VendorID, TotalAmount;
  update;
  delete;
  draft action Edit;
  draft action Activate;
  draft action Discard;
  draft determine action Prepare;
}

// Draft 없는 BDEF - 즉시 반영, 그러나 위험 영역 진입
managed implementation in class zbp_i_purchcontract unique;
strict ( 2 );

define behavior for ZI_PurchaseContract alias Contract
persistent table zpurchctr_t
etag master LastChangedAt
lock master
{
  field ( readonly ) ContractUUID, ContractID;
  update;
  delete;
  // EditAction, Activate, Discard, Prepare 모두 사라짐
}

ETag 기반 낙관적 잠금이 실제로 어떻게 동작하는가

ETag(Entity Tag)는 HTTP/1.1 표준에서 가져온 개념으로, 리소스의 특정 버전을 가리키는 짧은 식별자입니다. RAP에서는 BDEF에 etag master LastChangedAt처럼 선언된 필드의 값이 그 역할을 합니다. 클라이언트가 GET으로 데이터를 가져오면 응답 헤더에 ETag: "20260621093045.1234567" 같은 값이 실리고, 이후 PATCH/PUT/DELETE 요청 시 클라이언트는 이 값을 If-Match 헤더에 담아 보내야 합니다.

서버 측 RAP 런타임은 MODIFY ENTITIES 직전에 데이터베이스의 현재 LastChangedAt 값과 요청에 담긴 ETag를 비교합니다. 두 값이 같으면 "내가 본 그 버전 그대로다"라는 의미이므로 수정을 허용하고, 다르면 누군가 그 사이에 먼저 변경했다는 뜻이므로 HTTP 412 Precondition Failed를 반환합니다. 이것이 낙관적 잠금의 본질입니다 — Lock을 미리 잡지 않고, Save 시점에 "충돌 여부"만 검사하는 방식입니다.

중요한 것은 ETag 검사는 RAP 프레임워크가 자동으로 수행한다는 점입니다. 개발자가 명시적으로 코드를 작성할 필요가 없습니다. 하지만 이 자동화는 BDEF에 etag master가 선언되어 있어야 작동합니다. 만약 ETag 필드를 잘못 지정하거나, Update 시 LastChangedAt이 갱신되지 않도록 구현하면, 낙관적 잠금은 침묵 속에서 무력화됩니다. 사용자는 오류 한 번 보지 않고 동료의 수정사항을 덮어쓰게 됩니다.

Draft가 없을 때 ETag 충돌 처리가 무너지는 시나리오

Draft가 있는 환경에서는 ETag가 매우 자연스럽게 작동합니다. 사용자가 Edit을 누르는 순간 Draft 인스턴스가 생성되고, 이 Draft에는 원본의 ETag가 스냅샷으로 박혀있습니다. 사용자가 30분 동안 편집해도 Draft 안의 ETag는 그대로이고, Save(Activate) 시점에 원본 테이블의 현재 ETag와 비교됩니다. 그 30분 사이 다른 사용자가 같은 레코드를 수정했다면 412 에러가 나면서 "다른 사용자가 먼저 변경했습니다. 다시 시작해주세요"라는 친절한 메시지가 뜹니다.

그런데 Draft 없는 RAP에서는 이 흐름이 깨집니다. 핵심 문제는 ETag를 들고 있는 주체가 사라진다는 점입니다. Fiori Elements가 Draft 없는 엔티티를 다룰 때는 Object Page를 열 때마다 GET을 다시 호출해 최신 ETag를 가져옵니다. 즉, 사용자가 화면을 열고 5분간 입력 필드에 타이핑하는 동안, 그 5분 전 GET 응답의 ETag가 클라이언트 메모리에 머물러 있습니다. 만약 이 사이에 동일 레코드가 백그라운드 잡이나 다른 사용자에 의해 수정되면, 사용자의 PATCH 요청은 412로 거부됩니다.

여기까지는 정상입니다. 문제는 일부 커스텀 클라이언트나 SAP Build Apps, 또는 직접 만든 UI5 컨트롤러가 If-Match 헤더를 누락하는 경우입니다. Draft 있는 시나리오에서는 EditAction이 Lock까지 잡아주기 때문에 ETag가 빠져도 동시 편집 자체가 막혔지만, Draft 없는 환경에서는 이 안전망이 사라집니다. 더 나쁜 것은 OData V4 사양상 If-Match: *를 보내면 ETag 검사를 우회한다는 점입니다. 이걸 모르고 디버깅 편의를 위해 *를 박아두면, 운영 환경에서 데이터 덮어쓰기가 일상이 됩니다.

Lock 메커니즘의 공백 — 동시 편집이 데이터를 덮어쓰는 과정

실제로 일어나는 일을 시간순으로 따라가 보겠습니다. ZI_ServiceRequest에서 요청 ID SR-2026-0815에 대해 담당자 A와 매니저 B가 동시에 작업하는 상황입니다.

09:00:00 — A가 Object Page를 엽니다. GET 응답으로 ETag: "T1", 상태는 "OPEN", 우선순위는 "MEDIUM"이 클라이언트에 들어옵니다. A는 우선순위를 "HIGH"로 바꾸려고 합니다.

09:00:15 — B도 같은 Object Page를 엽니다. 동일한 ETag: "T1"이 B의 클라이언트에도 들어옵니다. B는 상태를 "IN_PROGRESS"로 바꾸려 합니다.

09:01:30 — B가 먼저 Save를 누릅니다. PATCH 요청이 If-Match: "T1"로 도착하고, 서버는 DB의 ETag도 "T1"이므로 수정을 허용합니다. LastChangedAt이 갱신되어 ETag는 "T2"가 됩니다.

09:02:10 — A가 Save를 누릅니다. A의 PATCH는 여전히 If-Match: "T1"입니다. 정상적인 RAP이라면 412가 떨어져야 합니다. 그런데 여기서 BDEF에 etag master 선언이 없거나, Update 핸들러가 LastChangedAt을 갱신하지 않는 구현이라면, 서버는 충돌을 감지하지 못하고 A의 수정을 그대로 반영합니다. B가 설정한 "IN_PROGRESS"는 사라지고, A의 우선순위 변경만 살아남습니다. B는 자기 변경이 사라진 줄도 모르고 다음 업무로 넘어갑니다.

Draft가 있었다면 A가 09:00:00에 Edit을 누른 순간 Exclusive Lock을 잡았기 때문에, B가 09:00:15에 Edit을 시도할 때 "다른 사용자가 편집 중입니다(User XYZ is currently editing)"라는 메시지가 떴을 것입니다. Lock과 ETag 두 겹의 방어가 동시에 작동하기 때문입니다. Draft 없는 RAP에서는 이 방어가 ETag 한 겹뿐이며, 그 한 겹마저 구현 실수로 쉽게 무너집니다.

Fiori Elements와 Draft 연계 — Draft 없으면 깨지는 UI 패턴들

Fiori Elements for OData V4는 Draft 사용을 기본 가정으로 설계된 프레임워크입니다. 그래서 Draft를 끄는 순간 다음 UX 요소들이 작동을 멈추거나 어색하게 변합니다.

첫째, "Continue Editing" 기능이 사라집니다. Draft가 있으면 사용자가 브라우저를 닫았다 다시 열어도 List Report에 "Draft" 라벨이 붙은 행이 보이고, 클릭하면 이어서 편집할 수 있습니다. Draft 없는 환경에서는 모든 입력이 휘발됩니다. 둘째, Side-by-Side 편집과 Inline Create가 어색해집니다. 자식 엔티티(예: 계약 항목)를 추가할 때 Draft 환경에서는 부모-자식이 한 트랜잭션으로 묶이지만, Draft 없으면 자식 추가가 즉시 DB에 반영되어 "Cancel"을 눌러도 되돌릴 수 없습니다. 셋째, "Unsaved Changes" 경고가 부정확해집니다. 브라우저 뒤로가기나 탭 닫기 시 표시되는 경고는 클라이언트 측 dirty flag에만 의존하므로, 네트워크 장애로 PATCH가 실패해도 경고가 뜨지 않을 수 있습니다.

넷째, Multi-Edit과 Mass Update 패턴이 위험해집니다. List Report에서 여러 행을 선택해 일괄 수정할 때 Draft 환경은 각 행을 Draft로 만들어 검증 후 일괄 Activate하지만, Draft 없으면 한 행씩 즉시 DB에 반영되며 중간에 하나가 실패해도 앞선 행은 롤백되지 않습니다.

실전 예제 — Draft 없는 RAP에서 안전한 Update 로직 구성

그럼에도 불구하고 Draft 없이 가야 하는 상황은 분명히 존재합니다(API 전용 엔티티, 매우 단순한 마스터 데이터, 외부 시스템 동기화 등). 이때 어떻게 ETag와 Lock을 명시적으로 보강할지 보겠습니다. ZI_VendorInvoice의 Behavior Implementation 예시입니다.

CLASS lhc_invoice DEFINITION INHERITING FROM cl_abap_behavior_handler.
  PRIVATE SECTION.
    METHODS update FOR MODIFY
      IMPORTING entities FOR UPDATE Invoice.

    METHODS get_instance_features FOR INSTANCE FEATURES
      IMPORTING keys REQUEST requested_features FOR Invoice
      RESULT result.

    METHODS precheck_update FOR PRECHECK
      IMPORTING entities FOR UPDATE Invoice.
ENDCLASS.

CLASS lhc_invoice IMPLEMENTATION.

  METHOD precheck_update.
    " ETag 명시 검증 - 프레임워크 자동 검증을 보완
    READ ENTITIES OF zi_vendorinvoice IN LOCAL MODE
      ENTITY Invoice
        FIELDS ( LastChangedAt OverallStatus )
        WITH CORRESPONDING #( entities )
      RESULT DATA(db_invoices).

    LOOP AT entities INTO DATA(req_invoice).
      READ TABLE db_invoices INTO DATA(db_invoice)
        WITH KEY InvoiceUUID = req_invoice-InvoiceUUID.
      IF sy-subrc <> 0.
        APPEND VALUE #( %tky = req_invoice-%tky
                        %fail-cause = if_abap_behv=>cause-not_found )
               TO failed-invoice.
        CONTINUE.
      ENDIF.

      " 송장이 이미 승인되었으면 수정 불가
      IF db_invoice-OverallStatus = 'APPROVED'.
        APPEND VALUE #(
          %tky = req_invoice-%tky
          %msg = new_message(
            id       = 'ZINV'
            number   = '042'
            severity = if_abap_behv_message=>severity-error
            v1       = req_invoice-InvoiceUUID )
        ) TO reported-invoice.
        APPEND VALUE #( %tky = req_invoice-%tky )
               TO failed-invoice.
      ENDIF.
    ENDLOOP.
  ENDMETHOD.

  METHOD get_instance_features.
    READ ENTITIES OF zi_vendorinvoice IN LOCAL MODE
      ENTITY Invoice
        FIELDS ( OverallStatus PaymentBlock )
        WITH CORRESPONDING #( keys )
      RESULT DATA(invoices).

    result = VALUE #( FOR inv IN invoices (
      %tky                   = inv-%tky
      %field-TotalAmount     = COND #(
        WHEN inv-OverallStatus = 'POSTED'
        THEN if_abap_behv=>fc-f-read_only
        ELSE if_abap_behv=>fc-f-unrestricted )
      %action-ReleasePayment = COND #(
        WHEN inv-PaymentBlock = abap_true
        THEN if_abap_behv=>fc-o-enabled
        ELSE if_abap_behv=>fc-o-disabled )
    ) ).
  ENDMETHOD.

  METHOD update.
    " Draft 없는 환경에서는 LastChangedAt을 반드시 직접 갱신
    MODIFY ENTITIES OF zi_vendorinvoice IN LOCAL MODE
      ENTITY Invoice
        UPDATE FIELDS ( LastChangedAt LastChangedBy )
          WITH VALUE #( FOR e IN entities (
            %tky          = e-%tky
            LastChangedAt = cl_abap_context_info=>get_system_date( )
            LastChangedBy = cl_abap_context_info=>get_user_technical_name( )
          ) )
      REPORTED DATA(update_reported).

    reported = CORRESPONDING #( DEEP update_reported ).
  ENDMETHOD.

ENDCLASS.

핵심 포인트는 세 가지입니다. precheck 단계에서 비즈니스 상태(승인됨, 게시됨 등)에 따른 수정 가능 여부를 명시적으로 검사하고, get_instance_features로 필드별 readonly 상태를 동적으로 제어하며, update 핸들러에서 LastChangedAt을 반드시 갱신해 ETag 체인이 끊기지 않게 합니다. 이 갱신이 빠지면 두 번째 수정자가 첫 번째 수정자의 ETag를 그대로 사용해도 통과되는 끔찍한 버그가 생깁니다.

Draft 없이 가도 되는 경우 vs 반드시 써야 하는 경우

판단 기준을 정리하겠습니다. Draft 없이 가도 무방한 경우는 다음과 같습니다. 머신 대 머신 통신 전용 API(사용자 UI가 붙지 않음), 변경 빈도가 매우 낮고 동시 편집 가능성이 사실상 0인 마스터 데이터(예: 회사 코드, 통화 코드), 외부 시스템과의 동기화 엔드포인트, 단일 필드만 토글하는 액션 중심 엔티티, 그리고 Read-only가 95% 이상이고 어쩌다 한 번 관리자만 수정하는 설정 테이블입니다.

반드시 Draft를 써야 하는 경우는 Fiori Elements UI가 붙는 모든 트랜잭션 엔티티, 한 객체에 여러 사용자가 접근할 수 있는 워크플로 객체(승인, 검토, 협업), 부모-자식 복합 구조로 일괄 검증이 필요한 경우, 사용자가 5초 이상 입력 작업을 하는 폼, 그리고 입력 중 검색 도움이나 외부 호출이 끼어드는 화면입니다.

흔히 마주치는 문제와 빠른 점검 항목

Q1. If-Match 헤더가 없는데도 PATCH가 성공합니다. 정상인가요? 아닙니다. OData V4 사양상 ETag 정의된 엔티티에 If-Match 없는 수정 요청은 428 Precondition Required 또는 412로 거부되어야 합니다. /IWBEP/CL_MGW_REQUEST 또는 BTP의 게이트웨이 설정에서 strict ETag 검사가 비활성화되어 있을 가능성이 높습니다. SICF 또는 Communication Arrangement 설정을 확인하세요.

Q2. LastChangedAt을 갱신했는데도 매번 412가 떨어집니다. 시간 타입 정밀도 문제입니다. timestampl(7자리 소수)과 timestamp(0자리) 사이의 캐스팅에서 trailing zero가 잘리면서 ETag 비교가 어긋나는 경우가 많습니다. BDEF 선언 타입과 DB 컬럼 타입을 일치시키세요.

Q3. Draft 없이 만들었더니 Fiori Elements에서 "Edit" 버튼이 안 보입니다. 정상 동작입니다. Draft 없는 엔티티는 List Report에서 바로 Inline Edit 모드로 진입하거나, Object Page에서 필드가 처음부터 편집 가능한 상태로 표시됩니다. UX 흐름을 Draft 모델과 동일하게 만들고 싶다면 일반적으로 Draft를 활성화하는 것이 권장됩니다.

Q4. lock master만 선언하면 Pessimistic Lock이 잡히나요? 부분적으로만 그렇습니다. lock master는 RAP이 Save 단계에서 Enqueue를 시도하도록 지시하지만, Draft 없는 환경에서는 사용자 편집 세션 동안 Lock이 유지되지 않습니다. Lock은 짧은 트랜잭션 윈도우에만 존재합니다.

이어서 탐구하면 좋은 영역들

이 글의 내용을 발전시키려면 다음 주제들을 살펴보는 것이 권장됩니다. RAP의 unmanaged save 시나리오에서 직접 ETag와 Lock을 구현하는 방법, OData V4의 Prefer: handling=strict 헤더를 통한 일괄 처리 시 부분 성공 제어, SAP Gateway Foundation의 ETag 변환 옵션, 그리고 BTP ABAP Environment에서 Background Job과 사용자 편집이 충돌할 때의 Enqueue 전략입니다. 또한 RAP BO Test Double Framework로 동시 편집 시나리오를 단위 테스트하는 방법도 실무에서 매우 유용합니다.

더 깊이 들어갈 수 있는 자료들

댓글 0

아직 댓글이 없습니다.