RAP

Unmanaged RAP CRUD 실수 5가지 #shorts #SAP #RAP

1. Managed와 Unmanaged의 갈림길 — 언제 Unmanaged를 선택하는가

RAP(RESTful ABAP Programming Model)는 ABAP Platform 1909 이상(특히 ABAP Cloud 및 S/4HANA 2020+)에서 비즈니스 객체를 선언적으로 정의하는 모델입니다. RAP은 구현 방식에 따라 크게 세 가지로 나뉩니다. Managed는 프레임워크가 영속화·락·번호채번까지 모두 처리하고, Managed with unmanaged save는 저장만 직접 처리하며, Unmanaged는 CREATE/READ/UPDATE/DELETE와 SAVE 시퀀스를 개발자가 전부 책임집니다.

Unmanaged를 선택하는 전형적인 시나리오는 다음과 같습니다.

  • 이미 BAPI/Function Module/Class 기반의 레거시 트랜잭션 로직이 존재하고 이를 그대로 재사용해야 할 때 (예: BAPI_SALESORDER_CREATEFROMDAT2)
  • 여러 테이블에 걸친 복잡한 영속화 규칙이 있거나 외부 시스템과의 연동이 필요할 때
  • 기존 트랜잭션 코드(SE11 테이블, Update Function Module 등)를 RAP 위에 노출시키는 "Brownfield" 시나리오

반대로 신규 비즈니스 객체이거나 단일 DB 테이블 중심이라면 Managed가 훨씬 생산적입니다. Unmanaged는 자유도가 높은 만큼 책임도 큽니다.

2. Unmanaged 구조와 Entity Buffer라는 가상 메모리

Unmanaged의 동작을 이해하려면 RAP 런타임이 "트랜잭션 버퍼(Transactional Buffer)"라는 개념을 사용한다는 점부터 봐야 합니다. 사용자가 OData로 PATCH/POST를 보내도 곧바로 DB가 변경되지 않습니다. 모든 변경은 메모리상의 버퍼에 누적되었다가, 클라이언트가 SAVE를 호출하는 순간 DB로 플러시됩니다.

비유하자면 Unmanaged 버퍼는 "주문서 임시 보관함"입니다. 손님(클라이언트)이 주문(MODIFY)을 추가/수정/취소할 때마다 보관함에 적어두고, 마지막에 "주방 전달(SAVE)" 신호가 오면 한 번에 조리장(DB)으로 넘깁니다. 도중에 같은 주문이 여러 번 수정되면 버퍼에서 합쳐서 처리해야 하죠.

이 흐름을 만들어내는 세 가지 출력 파라미터가 바로 mapped, failed, reported입니다.

  • mapped: 임시 ID(%cid)와 실제 키 매핑을 담는 컨테이너. CREATE 직후 클라이언트가 "내가 보낸 임시키가 어떤 진짜키가 됐는지" 알 수 있게 합니다.
  • failed: 처리 실패한 인스턴스 키와 실패 원인 코드. 후속 액션 차단에 사용됩니다.
  • reported: 메시지(T100, 텍스트)와 심볼릭 필드 매핑. UI의 메시지 영역에 표시됩니다.

그리고 핵심은 "버퍼는 BO 단위로 존재"한다는 점입니다. READ가 호출됐을 때 같은 LUW에서 직전에 CREATE된 데이터도 보여야 하므로, Unmanaged 구현자는 DB와 메모리 버퍼를 항상 함께 검색해야 합니다.

3. FOR CREATE — 생성 로직의 뼈대

BDEF가 implementation unmanaged로 선언되면, ADT가 ABAP Class Builder를 통해 Behavior Pool 클래스의 골격을 자동 생성합니다. 그 안의 FOR MODIFY 핸들러 내부에서 우리는 직접 ABAP internal table 변수(예: mt_buffer_hdr)에 데이터를 적재해야 합니다.

CLASS lhc_salesorder DEFINITION INHERITING FROM cl_abap_behavior_handler.
  PRIVATE SECTION.
    TYPES: BEGIN OF ty_buffer_hdr,
             order_id   TYPE zsalord_hdr-order_id,
             customer   TYPE zsalord_hdr-customer,
             order_date TYPE zsalord_hdr-order_date,
             total_amt  TYPE zsalord_hdr-total_amt,
             changed    TYPE abap_bool,
             created    TYPE abap_bool,
             deleted    TYPE abap_bool,
           END OF ty_buffer_hdr.

    CLASS-DATA mt_buffer_hdr TYPE STANDARD TABLE OF ty_buffer_hdr
                             WITH EMPTY KEY.

    METHODS create_salesorder FOR MODIFY
      IMPORTING entities FOR CREATE salesorder.
ENDCLASS.

CLASS lhc_salesorder IMPLEMENTATION.
  METHOD create_salesorder.
    LOOP AT entities INTO DATA(ls_in).
      DATA(lv_new_id) = cl_system_uuid=>create_uuid_c22_static( ).

      APPEND VALUE #( order_id   = lv_new_id
                      customer   = ls_in-customer
                      order_date = sy-datum
                      total_amt  = ls_in-total_amt
                      created    = abap_true ) TO mt_buffer_hdr.

      APPEND VALUE #( %cid     = ls_in-%cid
                      order_id = lv_new_id ) TO mapped-salesorder.
    ENDLOOP.
  ENDMETHOD.
ENDCLASS.

핵심 포인트는 세 가지입니다. 첫째, 키 생성은 개발자가 직접 합니다(UUID, 번호채번 객체 등). 둘째, 생성된 키를 반드시 mapped-salesorder%cid와 함께 넣어야 클라이언트가 후속 호출에서 그 인스턴스를 식별할 수 있습니다. 셋째, DB INSERT는 여기서 하지 않습니다 — 오직 메모리 버퍼에만 표시합니다.

4. FOR READ와 FOR READ MANY — 조회 시 버퍼와 DB를 동시에 보기

Unmanaged에서 READ를 호출하면 "DB에 이미 저장된 데이터"와 "현재 LUW에서 임시로 만든 데이터"를 합쳐서 반환해야 합니다. 만약 버퍼를 무시하고 DB만 읽으면, 방금 만든 주문이 UI에서 사라지는 버그가 발생합니다.

METHOD read_salesorder.
  DATA lt_keys TYPE TABLE FOR READ IMPORT zi_salesorder.
  lt_keys = keys.

  " 1. DB 조회
  SELECT order_id, customer, order_date, total_amt
    FROM zsalord_hdr
    FOR ALL ENTRIES IN @lt_keys
    WHERE order_id = @lt_keys-order_id
    INTO TABLE @DATA(lt_db).

  " 2. 버퍼 병합 (생성/수정된 인스턴스 우선)
  LOOP AT lt_keys ASSIGNING FIELD-SYMBOL(<ls_key>).
    READ TABLE mt_buffer_hdr WITH KEY order_id = <ls_key>-order_id
         ASSIGNING FIELD-SYMBOL(<ls_buf>).
    IF sy-subrc = 0 AND <ls_buf>-deleted = abap_false.
      APPEND VALUE #( order_id   = <ls_buf>-order_id
                      customer   = <ls_buf>-customer
                      order_date = <ls_buf>-order_date
                      total_amt  = <ls_buf>-total_amt ) TO result.
    ELSE.
      READ TABLE lt_db WITH KEY order_id = <ls_key>-order_id
           ASSIGNING FIELD-SYMBOL(<ls_db>).
      IF sy-subrc = 0.
        APPEND CORRESPONDING #( <ls_db> ) TO result.
      ELSE.
        APPEND VALUE #( %key = <ls_key>-%key ) TO failed-salesorder.
      ENDIF.
    ENDIF.
  ENDLOOP.
ENDMETHOD.

READ BY ASSOCIATION 처리(예: SalesOrder → Items)도 같은 패턴을 따르지만, keys 구조에 sourcetarget이 분리되어 들어온다는 점만 다릅니다. 권장 패턴은 헬퍼 메서드로 "버퍼 우선 → DB 보충" 로직을 한 곳에 추상화하는 것입니다.

5. FOR UPDATE와 FOR DELETE — 부분 갱신과 안전 삭제

UPDATE는 OData v4 PATCH 의미론을 따르기 때문에 "보내지 않은 필드는 건드리지 말아야" 합니다. 이를 위해 RAP는 각 필드별로 변경 여부를 표시하는 %control 구조를 함께 전달합니다.

METHOD update_salesorder.
  LOOP AT entities INTO DATA(ls_in).
    READ TABLE mt_buffer_hdr WITH KEY order_id = ls_in-order_id
         ASSIGNING FIELD-SYMBOL(<ls_buf>).
    IF sy-subrc <> 0.
      " 버퍼에 없으면 DB에서 로드 후 버퍼에 적재
      SELECT SINGLE * FROM zsalord_hdr
        WHERE order_id = @ls_in-order_id INTO @DATA(ls_db).
      IF sy-subrc <> 0.
        APPEND VALUE #( %key = ls_in-%key
                        %fail-cause = if_abap_behv=>cause-not_found )
               TO failed-salesorder.
        CONTINUE.
      ENDIF.
      APPEND CORRESPONDING #( ls_db ) TO mt_buffer_hdr
        ASSIGNING <ls_buf>.
    ENDIF.

    IF ls_in-%control-customer = if_abap_behv=>mk-on.
      <ls_buf>-customer = ls_in-customer.
    ENDIF.
    IF ls_in-%control-total_amt = if_abap_behv=>mk-on.
      <ls_buf>-total_amt = ls_in-total_amt.
    ENDIF.
    <ls_buf>-changed = abap_true.
  ENDLOOP.
ENDMETHOD.

METHOD delete_salesorder.
  LOOP AT keys INTO DATA(ls_key).
    READ TABLE mt_buffer_hdr WITH KEY order_id = ls_key-order_id
         ASSIGNING FIELD-SYMBOL(<ls_buf>).
    IF sy-subrc = 0.
      <ls_buf>-deleted = abap_true.
    ELSE.
      APPEND VALUE #( order_id = ls_key-order_id
                      deleted  = abap_true ) TO mt_buffer_hdr.
    ENDIF.
  ENDLOOP.
ENDMETHOD.

주의할 점은 DELETE도 즉시 DB DELETE를 하지 않고 "삭제 표시(soft mark)"만 한다는 것입니다. 실제 삭제는 SAVE 단계에서 수행됩니다. 이렇게 해야 같은 LUW 내에서 UPDATE → DELETE → READ 같은 임의 시퀀스가 일관되게 동작합니다.

6. SAVE 사이클의 3박자: finalize, check_before_save, save

RAP SAVE는 단일 메서드가 아니라 시퀀스입니다. 클라이언트가 SAVE 요청을 보내면 프레임워크가 다음 순서로 콜백을 호출합니다.

  1. finalize: 마지막 정합성 보정 단계. 예를 들어 헤더 total_amt를 아이템 합계로 재계산하거나, 의존 필드 채우기. 이 시점까지는 아직 "변경 가능"합니다.
  2. check_before_save: 저장 직전 최종 검증. 여기서 failed에 항목을 추가하면 전체 트랜잭션이 롤백됩니다. DB 변경은 절대 금지.
  3. save: 실제 INSERT/UPDATE/DELETE 수행. 이후 finalize_global_after_save(있다면)에서 outbound 이벤트 발행 등 후처리를 합니다.
METHOD finalize.
  LOOP AT mt_buffer_hdr ASSIGNING FIELD-SYMBOL(<ls_buf>)
       WHERE deleted = abap_false.
    SELECT SUM( item_amount ) FROM zsalord_itm
      WHERE order_id = @<ls_buf>-order_id
      INTO @<ls_buf>-total_amt.
  ENDLOOP.
ENDMETHOD.

METHOD check_before_save.
  LOOP AT mt_buffer_hdr ASSIGNING FIELD-SYMBOL(<ls_buf>)
       WHERE deleted = abap_false.
    IF <ls_buf>-customer IS INITIAL.
      APPEND VALUE #( order_id = <ls_buf>-order_id ) TO failed-salesorder.
      APPEND VALUE #( order_id = <ls_buf>-order_id
                      %msg     = new_message( id = 'ZSO'
                                              number = '001'
                                              severity = if_abap_behv_message=>severity-error ) )
             TO reported-salesorder.
    ENDIF.
  ENDLOOP.
ENDMETHOD.

METHOD save.
  " INSERT
  DATA lt_ins TYPE STANDARD TABLE OF zsalord_hdr.
  LOOP AT mt_buffer_hdr INTO DATA(ls_b) WHERE created = abap_true
                                        AND   deleted = abap_false.
    APPEND CORRESPONDING #( ls_b ) TO lt_ins.
  ENDLOOP.
  IF lt_ins IS NOT INITIAL.
    INSERT zsalord_hdr FROM TABLE @lt_ins.
  ENDIF.

  " UPDATE
  LOOP AT mt_buffer_hdr INTO ls_b WHERE changed = abap_true
                                  AND   created = abap_false
                                  AND   deleted = abap_false.
    UPDATE zsalord_hdr FROM @( CORRESPONDING #( ls_b ) ).
  ENDLOOP.

  " DELETE
  LOOP AT mt_buffer_hdr INTO ls_b WHERE deleted = abap_true.
    DELETE FROM zsalord_hdr WHERE order_id = @ls_b-order_id.
  ENDLOOP.

  CLEAR mt_buffer_hdr.
ENDMETHOD.

SAVE가 끝난 직후에는 반드시 버퍼를 비워야 합니다. 그렇지 않으면 같은 세션에서 다음 트랜잭션 시 유령 데이터가 남게 됩니다. 또한 cleanupcleanup_finalize 메서드도 구현해 두는 것이 일반적으로 권장됩니다 — 에러나 Discard 시 버퍼 초기화를 보장합니다.

7. 실전 예제: SalesOrder로 전체 CRUD 흐름 연결하기

지금까지 각 메서드를 개별적으로 살펴봤다면, 이제 전체 흐름을 하나의 시나리오로 연결해 봅니다. 시나리오: 구매팀 직원이 Fiori UI에서 신규 SalesOrder를 생성하고, 수량을 수정한 뒤 저장하는 과정입니다.

" 1. POST /SalesOrder → create_salesorder 호출
"    ls_in-%cid = 'NEW_ORD_001', customer = 'CUST_KR01'
"    → mt_buffer_hdr에 { order_id='UUID1', created=abap_true } 추가
"    → mapped-salesorder에 { %cid='NEW_ORD_001', order_id='UUID1' } 추가

" 2. PATCH /SalesOrder('UUID1') → update_salesorder 호출
"    ls_in-order_id = 'UUID1', total_amt = 150000
"    → 버퍼에서 UUID1 찾아 total_amt 갱신, changed=abap_true

" 3. GET /SalesOrder('UUID1') → read_salesorder 호출
"    → 버퍼에서 UUID1 발견 → result에 반환 (DB 조회 없이)

" 4. SAVE → finalize → check_before_save → save 순으로 프레임워크 호출
"    finalize: 아이템 합계 재계산
"    check_before_save: customer 필수 검증 통과
"    save: INSERT zsalord_hdr FROM TABLE @lt_ins. 실행 → DB에 확정
"    → mt_buffer_hdr CLEAR

이 흐름에서 두 가지 핵심이 드러납니다. 하나는 클라이언트가 방금 만든 %cid를 통해 UUID를 역참조할 수 있다는 점, 다른 하나는 SAVE 이전까지 DB에는 아무것도 없다는 점입니다. 이 두 가지를 놓치면 "DB 조회하면 왜 없나?" 또는 "SAVE 전에 데이터를 읽으면 왜 보이나?"를 이해하지 못합니다.

8. Unmanaged 구현 시 반드시 알아야 할 주의사항과 디버깅 포인트

Unmanaged RAP를 처음 구현할 때 가장 자주 만나는 함정들과 그 해결책을 정리합니다.

문제 1. CREATE 직후 READ를 하면 데이터가 없습니다.
원인: mapped-salesorder%cid 매핑을 빠뜨렸거나, READ 구현에서 버퍼를 합치지 않은 경우입니다. mapped 구조 채우기와 READ 메서드 내 버퍼 병합 로직 두 곳을 모두 점검하세요.

문제 2. SAVE에서 short dump가 발생합니다.
원인: check_before_save에서 DB를 건드렸을 가능성이 큽니다. 이 단계는 read-only로 유지하고, 모든 영속화는 save 단계로 모아야 합니다. check_before_save에서 SELECT만 허용됩니다.

문제 3. PATCH로 일부 필드만 바꿨는데 다른 필드가 초기화됩니다.
원인: %control-필드명 체크를 누락한 전형적 증상입니다. 반드시 IF ls_in-%control-customer = if_abap_behv=>mk-on인지 확인 후 대입해야 합니다.

문제 4. 동시 수정 시 데이터가 덮어씌워집니다.
원인: 락(Lock) 객체를 직접 호출하지 않았기 때문입니다. Unmanaged는 자동 락이 없으므로 ENQUEUE_* Function Module을 save 진입 직전에 호출해야 합니다. ETag 기반 낙관적 락이 필요하다면 BDEF에 etag master local_field를 선언하고 UPDATE 시 비교 로직을 직접 넣어야 합니다.

문제 5. 에러 후 다음 요청에도 이전 데이터가 남아있습니다.
원인: cleanupcleanup_finalize 메서드를 구현하지 않았기 때문입니다. 에러나 Discard 시 프레임워크가 이 메서드를 호출하므로, 반드시 CLEAR mt_buffer_hdr를 여기서도 수행해야 합니다.

디버깅 시에는 ADT의 ABAP Debugger에서 mt_buffer_hdr 내용을 직접 확인하고, 프레임워크 호출 순서(MODIFY → READ → finalize → check_before_save → save)를 순서대로 브레이크포인트로 추적하는 것이 가장 효과적입니다. cl_abap_unit_assert를 활용한 단위 테스트로 각 메서드의 mapped/failed/reported 출력을 검증하는 것도 Unmanaged 구현의 필수 습관입니다.

댓글 0

아직 댓글이 없습니다.