RAP

일반 Read vs Read for Update 잠금 차이 #shorts #SAP #RAP

▶ YouTube에서 보기

개요와 이 글에서 다룰 내용

RAP(ABAP RESTful Application Programming Model)에서 데이터를 읽는 작업은 단순해 보이지만, 실제 트랜잭션 처리에서는 두 가지 전혀 다른 의미를 갖습니다. 단순 조회용 READ ENTITIES와 수정을 전제로 한 READ ENTITIES ... FOR UPDATE는 잠금 처리, 트랜잭션 버퍼 적재, 동시성 제어 측면에서 근본적으로 다르게 동작합니다. 이 글은 SalesOrder 시나리오를 통해 두 패턴의 내부 메커니즘과 실무에서의 선택 기준을 정리합니다.

  • 일반 Read와 Read for Update의 동작 차이 이해
  • RAP 트랜잭션 버퍼와 잠금(Lock) 흐름 파악
  • SalesOrder 시나리오로 Determination/Validation/Action 구현 패턴 습득
  • 동시성 충돌 시나리오와 ETag/Optimistic Concurrency 대응법 정리

알고 있어야 할 배경

이 글은 advanced 레벨을 가정하며, 다음 개념은 사전 이해가 필요합니다: RAP의 BO(Business Object) 구조, Behavior Definition(BDEF)/Behavior Implementation(BIL), Managed vs Unmanaged 시나리오, EML(Entity Manipulation Language) 기본 구문, CDS Composition 트리, Draft 처리 개요. SAP S/4HANA 2022 이상 또는 BTP ABAP Environment 2024 릴리스 기준으로 설명합니다.

실습 환경과 사전 준비

본 예제는 다음 환경을 전제로 합니다.

  • SAP BTP ABAP Environment (Steampunk) 2024 release 또는 S/4HANA 2023 on-premise
  • ABAP Development Tools(ADT) for Eclipse 2024-03 이상
  • RAP BO: ZR_SALESORDER_001 (root) + ZR_SALESORDERITEM_001 (child)
  • Behavior 구현 클래스: ZBP_R_SALESORDER_001
  • Strict mode level 2 적용 (BDEF에서 strict ( 2 ) 선언 권장)

BTP 환경에서는 SAP_BR_DEVELOPER 비즈니스 롤이 부여된 사용자로 작업해야 하며, 트랜잭션 버퍼는 세션 단위로 관리되므로 ADT 디버거에서 cl_abap_tx 클래스를 통한 모드 확인이 가능합니다. 일반적으로 권장되는 패턴은 BDEF에 with draft를 함께 선언해 Draft 처리와 잠금 처리를 명확히 분리하는 방식입니다.

핵심 개념: 두 가지 Read의 본질적 차이

RAP에서 READ ENTITIES는 두 가지 모드로 동작합니다. 첫째는 일반 Read로, CDS View나 데이터베이스 테이블에서 데이터를 단순 조회합니다. 둘째는 Read for Update로, 데이터를 읽는 동시에 트랜잭션 버퍼에 적재하고 해당 인스턴스에 대한 비관적 잠금(pessimistic lock)을 획득합니다.

비유하자면 일반 Read는 도서관에서 책을 "열람"하는 행위이고, Read for Update는 책을 "대출"하는 행위입니다. 열람은 다른 사람도 동시에 가능하지만, 대출은 책이 반납될 때까지 독점됩니다.

두 모드의 내부 동작을 단계별로 비교하면 다음과 같습니다.

  • 일반 Read: DB 또는 active table에서 직접 조회 → 잠금 없음 → 버퍼에 적재되지 않음 → 동시 사용자 N명 가능
  • Read for Update: 잠금 객체(Enqueue) 획득 시도 → 성공 시 인스턴스를 트랜잭션 버퍼로 로드 → 후속 Modify는 이 버퍼 위에서 동작 → SAVE 시점에 일괄 반영

왜 이런 구분이 필요할까요? 핵심은 "수정 전 상태(before-image) 보존"에 있습니다. RAP의 Determination, Validation, Action 메서드는 수정될 데이터의 현재 상태를 기준으로 판단합니다. 만약 수정 도중 다른 트랜잭션이 같은 인스턴스를 변경한다면 lost update 문제가 발생합니다. Read for Update는 버퍼에 적재된 before-image를 기준으로 Validation을 수행하고, 잠금으로 동시 변경을 막아 정합성을 보장합니다.

Managed 시나리오에서는 프레임워크가 자동으로 잠금을 처리하지만, Unmanaged나 Managed with unmanaged save 시나리오에서는 개발자가 명시적으로 FOR UPDATE를 호출해야 합니다. 또한 FOR UPDATE는 두 가지 옵션을 제공합니다.

  • FOR UPDATE: 기본 모드. 잠금 획득 실패 시 즉시 failed 테이블에 오류 반환
  • FOR UPDATE MODE 'O': Optimistic mode. 잠금 없이 진행하고 충돌 시점에 ETag로 검증

실전 예제 1단계: 기본 Read와 Read for Update 비교

가장 단순한 형태로 SalesOrder 헤더를 조회하는 두 패턴을 비교합니다. 동일한 BO에 대해 일반 Read와 Read for Update가 어떻게 다르게 동작하는지 확인합니다.

" 일반 Read: 단순 조회용 (예: Factory class에서 데이터 표시)
READ ENTITIES OF zr_salesorder_001
  ENTITY SalesOrder
    FIELDS ( SalesOrderId CustomerId TotalAmount OverallStatus )
    WITH VALUE #( ( SalesOrderId = '4500001234' ) )
  RESULT DATA(lt_order_read)
  FAILED DATA(ls_failed_read)
  REPORTED DATA(ls_reported_read).

" 이 시점에 lock 없음, 버퍼 적재 없음
" 다른 사용자가 동시에 같은 주문을 수정 가능

" Read for Update: 수정을 전제로 한 조회
READ ENTITIES OF zr_salesorder_001 IN LOCAL MODE
  ENTITY SalesOrder
    FIELDS ( SalesOrderId CustomerId TotalAmount OverallStatus )
    WITH VALUE #( ( SalesOrderId = '4500001234' ) )
  RESULT DATA(lt_order_for_update)
  FAILED DATA(ls_failed_upd)
  REPORTED DATA(ls_reported_upd).
" 주의: 위 구문은 BIL 내부에서만 의미가 있음
" 실제로 lock을 거는 것은 MODIFY ENTITIES 직전 또는 별도 lock 처리

여기서 핵심은 IN LOCAL MODE입니다. Behavior Implementation(BIL) 클래스 내부에서 호출되는 EML은 권한 검사를 건너뛰고 트랜잭션 버퍼를 직접 참조합니다. 외부 컨슈머에서는 OData 요청이 들어올 때 RAP 런타임이 자동으로 잠금을 관리하지만, 커스텀 Action 내부에서 다른 인스턴스를 참조해 수정한다면 명시적인 Read for Update가 필요합니다.

실전 예제 2단계: Action에서 Read for Update로 상태 전이 처리

SalesOrder를 "Released" 상태로 전이하는 Action을 구현합니다. 이 시나리오에서는 헤더 상태 변경과 동시에 모든 Item의 ProcessingStatus도 갱신해야 하므로, 헤더와 자식 모두를 버퍼에 적재해야 합니다. 에러 처리와 로깅도 함께 다룹니다.

METHOD release_order.

  " 1) 헤더를 Read for Update로 버퍼에 적재
  READ ENTITIES OF zr_salesorder_001 IN LOCAL MODE
    ENTITY SalesOrder
      FIELDS ( SalesOrderId OverallStatus CustomerId TotalAmount )
      WITH CORRESPONDING #( keys )
    RESULT DATA(lt_order)
    FAILED DATA(ls_read_failed)
    REPORTED DATA(ls_read_reported).

  " 2) 비즈니스 검증: 이미 Released 상태면 실패 처리
  LOOP AT lt_order INTO DATA(ls_order).
    IF ls_order-OverallStatus = 'R'.
      APPEND VALUE #(
        %tky = ls_order-%tky
        %fail-cause = if_abap_behv=>cause-unspecific
      ) TO failed-salesorder.

      APPEND VALUE #(
        %tky = ls_order-%tky
        %msg = new_message(
          id       = 'ZSALES_MSG'
          number   = '012'
          severity = if_abap_behv_message=>severity-error
          v1       = ls_order-SalesOrderId )
      ) TO reported-salesorder.

      DELETE lt_order WHERE SalesOrderId = ls_order-SalesOrderId.
    ENDIF.
  ENDLOOP.

  CHECK lt_order IS NOT INITIAL.

  " 3) 자식(Item) 인스턴스도 Read for Update로 적재
  READ ENTITIES OF zr_salesorder_001 IN LOCAL MODE
    ENTITY SalesOrder BY \_Item
      FIELDS ( ItemId ProcessingStatus Quantity )
      WITH CORRESPONDING #( lt_order )
    RESULT DATA(lt_items).

  " 4) MODIFY로 상태 전이 (헤더 + Item 일괄)
  MODIFY ENTITIES OF zr_salesorder_001 IN LOCAL MODE
    ENTITY SalesOrder
      UPDATE FIELDS ( OverallStatus )
      WITH VALUE #( FOR <ord> IN lt_order
                      ( %tky          = <ord>-%tky
                        OverallStatus = 'R' ) )
    ENTITY SalesOrder
      UPDATE BY \_Item
      FIELDS ( ProcessingStatus )
      WITH VALUE #( FOR <itm> IN lt_items
                      ( %tky = <itm>-%tky
                        %target = VALUE #(
                          ( ItemId = <itm>-ItemId
                            ProcessingStatus = 'P' ) ) ) )
    FAILED   DATA(ls_modify_failed)
    REPORTED DATA(ls_modify_reported).

  " 5) Action 결과 반환
  result = VALUE #( FOR <ord> IN lt_order
                      ( %tky   = <ord>-%tky
                        %param = CORRESPONDING #( <ord> ) ) ).

ENDMETHOD.

주목할 점은 두 가지입니다. 첫째, READ ENTITIES ... IN LOCAL MODE는 BIL 내부에서 사용되며 RAP 런타임이 이미 잠금을 관리하고 있는 상황을 전제합니다. 둘째, 자식 엔티티는 BY \_Item 어소시에이션 경로를 통해 함께 읽어와야 동일한 트랜잭션 버퍼에 적재됩니다.

실전 예제 3단계: Unmanaged 시나리오에서 명시적 잠금과 ETag 처리

레거시 함수 모듈을 감싸는 Unmanaged 시나리오에서는 잠금 처리를 직접 구현해야 합니다. 동시성 제어를 위해 EQ_SALESORDER 잠금 객체와 ETag 비교를 결합한 패턴입니다.

METHOD update_for_salesorder.

  DATA: lt_lock_keys TYPE TABLE OF eseskey,
        lv_etag_db   TYPE timestampl.

  " 1) Enqueue lock 명시적 획득 (Unmanaged 시나리오)
  LOOP AT entities INTO DATA(ls_entity).

    CALL FUNCTION 'ENQUEUE_EZ_SALESORDER'
      EXPORTING
        mode_zsalesorder = 'E'
        mandt            = sy-mandt
        salesorderid     = ls_entity-SalesOrderId
        _scope           = '2'
      EXCEPTIONS
        foreign_lock     = 1
        system_failure   = 2
        OTHERS           = 3.

    IF sy-subrc <> 0.
      APPEND VALUE #(
        %tky        = ls_entity-%tky
        %fail-cause = if_abap_behv=>cause-locked
      ) TO failed-salesorder.

      APPEND VALUE #(
        %tky = ls_entity-%tky
        %msg = new_message_with_text(
          severity = if_abap_behv_message=>severity-error
          text     = |주문 { ls_entity-SalesOrderId } 잠금 실패: 다른 사용자가 편집 중| )
      ) TO reported-salesorder.

      CONTINUE.
    ENDIF.

    " 2) DB에서 현재 ETag 읽기 (lock 후 fresh read)
    SELECT SINGLE last_changed_at FROM zsalesorder
      INTO @lv_etag_db
      WHERE salesorderid = @ls_entity-SalesOrderId.

    " 3) Optimistic concurrency check
    IF ls_entity-%control-LocalLastChangedAt = if_abap_behv=>mk-on
       AND ls_entity-LocalLastChangedAt <> lv_etag_db.
      APPEND VALUE #(
        %tky = ls_entity-%tky
        %msg = new_message_with_text(
          severity = if_abap_behv_message=>severity-error
          text     = |ETag 불일치: 데이터가 그 사이 변경되었습니다| )
      ) TO reported-salesorder.
      CONTINUE.
    ENDIF.

    " 4) 안전하게 update 수행
    UPDATE zsalesorder SET
      total_amount      = @ls_entity-TotalAmount,
      last_changed_at   = @cl_abap_context_info=>get_system_time( ),
      last_changed_by   = @sy-uname
      WHERE salesorderid = @ls_entity-SalesOrderId.

  ENDLOOP.

ENDMETHOD.

이 패턴에서 중요한 점은 잠금 획득 → DB read → ETag 비교 → update의 순서입니다. ETag(LocalLastChangedAt)는 RAP가 자동으로 OData payload에 포함시키는 동시성 토큰이며, 클라이언트가 보낸 값과 DB의 현재 값이 다르면 다른 사용자가 그 사이에 수정했다는 신호입니다. _scope = '2'는 잠금이 트랜잭션 종료까지 유지되도록 합니다.

자주 마주치는 실수와 해결 방법

실무에서 RAP Read 패턴을 적용할 때 반복적으로 발생하는 문제와 대응법입니다.

  • Q1. Determination에서 일반 Read를 사용했더니 수정된 값이 반영되지 않습니다.
    Determination은 트랜잭션 버퍼의 현재 상태를 읽어야 하므로 반드시 IN LOCAL MODE로 호출해야 합니다. CDS View에서 직접 SELECT 하면 아직 SAVE되지 않은 변경분을 볼 수 없습니다.
  • Q2. "FOREIGN_LOCK" 예외가 자주 발생합니다.
    잠금 보유 시간이 길거나 잠금 범위가 너무 광범위할 가능성이 큽니다. SalesOrder 헤더 단위로 잠그되 Item 단위 작업은 별도 잠금으로 분리하거나, Optimistic mode(FOR UPDATE MODE 'O') 적용을 검토하세요. SM12 트랜잭션으로 현재 잠금 상태를 확인할 수 있습니다.
  • Q3. Draft를 사용하는 BO인데 잠금이 이중으로 걸리는 듯합니다.
    Draft는 자체적으로 inactive instance에 대한 잠금을 관리합니다. with draft 선언된 BO에서는 명시적 Enqueue를 추가로 호출하지 말고, EDIT action을 통해 Draft 진입 시 자동으로 처리되도록 위임하는 것이 일반적으로 권장됩니다.
  • Q4. Read for Update를 호출했는데도 다른 세션이 같은 인스턴스를 동시에 수정합니다.
    Strict mode가 비활성화되었거나, Test Double로 잠금을 우회한 테스트 환경일 수 있습니다. BDEF에 strict ( 2 )를 명시하고, 운영 환경에서 다시 검증해야 합니다.

심화 학습으로 이어지는 주제들

Read for Update를 마스터했다면 다음 영역으로 확장할 수 있습니다. RAP Draft 처리 심화(Edit Action, Discard, Activate 흐름), Late Numbering 시나리오에서의 Key 할당 타이밍, Side Effects 어노테이션을 통한 클라이언트 측 캐시 무효화, ETag 전략(Calculated vs Stored), 그리고 CDS Entity Buffering과 RAP 트랜잭션 버퍼의 상호작용. 특히 Managed with additional save 패턴은 잠금과 버퍼 동기화 측면에서 가장 복잡한 사례이므로 별도 학습이 필요합니다.

더 깊이 학습할 수 있는 자료

댓글 0

아직 댓글이 없습니다.