RAP

RAP ETag 없이 저장? — 낙관적 잠금 3가지 핵심 #shorts #SAP #RAP

📖 개요 — ETag 없는 RAP 서비스는 "나중에 저장한 사람이 이긴다"

두 명의 담당자가 같은 정비 오더를 동시에 열어 수정한다고 가정해 봅시다. A가 우선순위를 바꿔 저장하고, 몇 초 뒤 B가 상태값을 바꿔 저장하면 — ETag가 없는 RAP 서비스에서는 B의 저장이 A의 변경을 조용히 덮어씁니다. 오류도, 경고도 없습니다. 이 글은 SAP RAP(RESTful ABAP Programming Model)의 낙관적 잠금(Optimistic Lock)을 ETag 필드로 구현하고, 충돌을 HTTP 412로 감지하는 전 과정을 실전 예제로 다룹니다.

  • 낙관적 잠금과 비관적 잠금(Pessimistic Lock)의 역할 분담 이해
  • Behavior Definition의 etag master / etag dependent by / total etag 구문 작성
  • OData 요청의 If-Match 헤더와 412 Precondition Failed 흐름 확인
  • Draft 시나리오·ABAP Unit 검증까지 프로덕션 수준으로 마무리

📚 미리 알아두면 좋은 것

CDS 뷰 엔터티와 Behavior Definition(BDEF)의 기본 구조, managed 시나리오의 CRUD 동작, OData V4 서비스 바인딩 개념을 알고 있으면 수월합니다. HTTP 헤더(ETag, If-Match)와 상태 코드 412의 의미를 알고 있다면 원리 이해가 훨씬 빠릅니다. 중급(intermediate) 난이도의 글입니다.

🔧 환경 / 버전 / 준비물

이 예제는 다음 환경을 기준으로 작성했습니다.

  • SAP BTP ABAP Environment 또는 SAP S/4HANA 2022 이상(온프레미스는 ABAP Platform 2022 이상 권장) — strict ( 2 ) 모드 BDEF 기준
  • ABAP Development Tools(ADT) for Eclipse — BDEF·CDS 편집은 ADT에서만 가능합니다
  • OData V4 UI 서비스 바인딩(테스트는 ADT의 서비스 바인딩 Preview 또는 Postman/curl)
  • 개발 권한: CDS 뷰, 테이블, Behavior Definition/Implementation 생성 권한

일반적으로 S/4HANA 2020 이하 버전에서도 ETag 자체는 동작하지만, strict 모드와 일부 어노테이션 표기가 다를 수 있으므로 시스템 릴리스에 맞는 ABAP Keyword Documentation을 함께 확인하는 것이 좋습니다.

💡 핵심 개념 — 낙관적 잠금은 "버전 스티커"다

도서관 공유 문서에 버전 스티커가 붙어 있다고 상상해 봅시다. 문서를 빌릴 때 스티커 번호(v7)를 메모해 두고, 반납(저장)할 때 사서에게 "v7 기준으로 수정했어요"라고 말합니다. 그 사이 누군가 문서를 v8로 바꿔 놓았다면 사서는 반납을 거절합니다. 이것이 낙관적 잠금이고, 스티커 번호가 바로 ETag입니다.

RAP의 동시성 제어는 두 겹으로 동작합니다.

구분비관적 잠금 (lock master)낙관적 잠금 (etag)
시점수정 트랜잭션이 진행 중인 동안읽기 시점 ~ 저장 요청 사이
수단Enqueue 락 (서버 측 잠금)ETag 값 비교 (버전 검증)
실패 시잠금 획득 실패 메시지HTTP 412 Precondition Failed

웹 환경은 상태 비저장(stateless)이라 사용자가 화면에서 데이터를 "보고 있는" 동안 서버 락을 계속 잡을 수 없습니다. 그래서 조회 시점의 ETag를 클라이언트가 들고 있다가, 저장 요청의 If-Match 헤더로 되돌려 보내고, 서버는 현재 DB 값에서 계산한 ETag와 비교해 다르면 요청을 거절합니다.

RAP에는 ETag가 세 종류 등장합니다.

  • Entity ETag(etag master) — 엔터티 자신의 변경 시각 필드(보통 LocalLastChangedAt)를 버전 값으로 사용
  • Dependent ETag(etag dependent by) — 자식 엔터티가 부모(루트)의 ETag를 따라가도록 위임. 예를 들어 오더 아이템이 바뀌면 헤더의 ETag도 함께 갱신되는 구조에서 사용
  • Total ETag(total etag) — BO 전체(루트+자식 전부)를 대표하는 변경 시각. Draft를 쓰는 BO에서 원본과 초안 사이의 충돌 감지에 필요하며, 루트 엔터티에만 선언

흐름을 도식으로 정리하면 이렇습니다.

클라이언트 A: GET  /MaintOrder(...)          → 200, ETag: "2026-07-16T02:10:31Z"
클라이언트 B: GET  /MaintOrder(...)          → 200, ETag: "2026-07-16T02:10:31Z"
클라이언트 A: PATCH If-Match: "…02:10:31Z"   → 204 저장 성공 (ETag가 02:14:05Z로 갱신)
클라이언트 B: PATCH If-Match: "…02:10:31Z"   → 412 Precondition Failed (충돌 감지!)

💻 실전 코드 3단계

1단계 — 기본: ETag 필드와 etag master 선언

정비 오더 루트 뷰에 시맨틱 어노테이션이 달린 관리 필드를 준비합니다. 핵심은 localInstanceLastChangedAt 필드입니다. managed 시나리오에서는 RAP 프레임워크가 이 필드를 저장 시점마다 자동 갱신해 줍니다.

define root view entity ZI_MaintOrder
  as select from zmnt_order
  composition [0..*] of ZI_MaintOrderStep as _Step
{
  key order_uuid            as OrderUuid,
      order_no              as OrderNo,
      equipment_id          as EquipmentId,
      priority              as Priority,
      order_status          as OrderStatus,
      @Semantics.systemDateTime.lastChangedAt: true
      last_changed_at       as LastChangedAt,
      @Semantics.systemDateTime.localInstanceLastChangedAt: true
      local_last_changed_at as LocalLastChangedAt,
      _Step
}

Behavior Definition에서 이 필드를 ETag로 지정합니다.

managed implementation in class zbp_i_maintorder unique;
strict ( 2 );

define behavior for ZI_MaintOrder alias MaintOrder
persistent table zmnt_order
lock master
authorization master ( instance )
etag master LocalLastChangedAt
{
  create;
  update;
  delete;
  association _Step { create; }

  field ( readonly, numbering : managed ) OrderUuid;
  field ( readonly ) LastChangedAt, LocalLastChangedAt;
}

이것만으로 OData V4 서비스는 응답에 ETag 헤더를 싣고, 수정 요청에 If-Match 검증을 수행합니다. ETag 필드는 반드시 readonly로 막아 클라이언트가 임의 조작하지 못하게 하는 것이 권장됩니다.

2단계 — 실무: 자식 엔터티 위임과 412 충돌 처리·로깅

헤더-아이템 구조에서는 아이템(작업 단계) 수정도 하나의 오더 변경으로 봐야 하는 경우가 많습니다. 자식에는 etag dependent by로 부모 ETag를 위임합니다.

define behavior for ZI_MaintOrderStep alias OrderStep
persistent table zmnt_order_step
lock dependent by _MaintOrder
authorization dependent by _MaintOrder
etag dependent by _MaintOrder
{
  update;
  delete;
  field ( readonly ) OrderUuid, StepUuid;
  association _MaintOrder;
}

이제 아이템만 고쳐도 프레임워크가 루트의 LocalLastChangedAt을 갱신하므로, 헤더 기준 ETag 하나로 BO 전체의 충돌을 감지할 수 있습니다. 클라이언트 쪽에서 412를 잡아 사용자에게 알리고 로그를 남기는 예제는 다음과 같습니다. (SAP Fiori elements 앱은 이 처리를 내장하고 있어 "다른 사용자가 데이터를 변경했습니다" 대화상자를 자동으로 띄웁니다. 아래는 커스텀 클라이언트 기준입니다.)

async function updateOrder(orderUuid, etag, payload) {
  const res = await fetch(`/sap/opu/odata4/sap/zui_maintorder_o4/srvd/sap/`
      + `zui_maintorder/0001/MaintOrder(${orderUuid})`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      "If-Match": etag              // 조회 시점에 받아둔 ETag
    },
    body: JSON.stringify(payload)
  });

  if (res.status === 412) {
    console.warn(`[Conflict] order=${orderUuid} staleETag=${etag}`);
    // 최신 데이터 재조회 후 사용자에게 병합/재시도 안내
    return { conflict: true, latest: await refetchOrder(orderUuid) };
  }
  if (!res.ok) throw new Error(`Update failed: ${res.status}`);
  return { conflict: false, newEtag: res.headers.get("ETag") };
}

주의할 점 하나 — If-Match: *를 보내면 ETag 검증을 사실상 무력화(무조건 덮어쓰기)하므로, 배치성 통합 클라이언트에서 습관적으로 *를 쓰고 있지 않은지 점검해야 합니다.

3단계 — 프로덕션: Draft의 total etag와 ABAP Unit 검증

Draft를 켜는 순간 이야기가 하나 늘어납니다. 사용자가 초안을 만들어 두는 동안 다른 사용자가 원본(active)을 바꿀 수 있고, 이 충돌은 BO 전체 단위로 감지해야 하므로 루트에 total etag가 필요합니다. Draft가 활성화된 BDEF에서 total etag가 없으면 문법 오류가 발생합니다.

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

define behavior for ZI_MaintOrder alias MaintOrder
persistent table zmnt_order
draft table zmnt_order_d
lock master total etag LastChangedAt      " BO 전체 대표 타임스탬프
authorization master ( instance )
etag master LocalLastChangedAt            " 엔터티 개별 ETag
{
  create;
  update;
  delete;
  draft action Edit;
  draft action Activate optimized;
  draft action Discard;
  draft action Resume;
  draft determine action Prepare;

  field ( readonly ) LastChangedAt, LocalLastChangedAt;
}

Edit·Resume 시점에 프레임워크가 total etag를 비교해, 초안 생성 이후 원본이 바뀌었으면 충돌로 처리합니다. 마지막으로 "저장하면 ETag 필드가 실제로 갱신되는가"를 ABAP Unit으로 회귀 검증합니다.

CLASS ltc_etag_update DEFINITION FINAL FOR TESTING
  DURATION SHORT RISK LEVEL HARMLESS.
  PRIVATE SECTION.
    METHODS etag_changes_on_update FOR TESTING RAISING cx_static_check.
ENDCLASS.

CLASS ltc_etag_update IMPLEMENTATION.
  METHOD etag_changes_on_update.
    " 사전 준비된 테스트 오더 1건 조회
    READ ENTITIES OF zi_maintorder
      ENTITY MaintOrder
        FIELDS ( LocalLastChangedAt ) WITH VALUE #( ( OrderUuid = gv_test_uuid ) )
      RESULT DATA(lt_before).

    MODIFY ENTITIES OF zi_maintorder
      ENTITY MaintOrder
        UPDATE FIELDS ( Priority )
        WITH VALUE #( ( OrderUuid = gv_test_uuid  Priority = '1' ) )
      FAILED DATA(ls_failed).
    COMMIT ENTITIES.

    cl_abap_unit_assert=>assert_initial( ls_failed ).

    READ ENTITIES OF zi_maintorder
      ENTITY MaintOrder
        FIELDS ( LocalLastChangedAt ) WITH VALUE #( ( OrderUuid = gv_test_uuid ) )
      RESULT DATA(lt_after).

    cl_abap_unit_assert=>assert_differs(
      act = lt_after[ 1 ]-LocalLastChangedAt
      exp = lt_before[ 1 ]-LocalLastChangedAt
      msg = 'ETag 필드가 저장 후에도 갱신되지 않음' ).
  ENDMETHOD.
ENDCLASS.

보안·성능 관점 체크리스트: ETag 필드는 항상 readonly 유지, unmanaged 시나리오라면 save 단계에서 타임스탬프 갱신 로직을 직접 구현했는지 확인, 그리고 UTC 타임스탬프(timestampl, 소수점 7자리) 사용을 권장합니다 — 초 단위 정밀도로는 같은 초 안의 연속 저장을 구분하지 못할 수 있습니다.

⚠️ 흔한 실수 / 트러블슈팅

Q1. etag master를 선언했는데 저장해도 ETag 값이 안 바뀝니다.

CDS 필드에 @Semantics.systemDateTime.localInstanceLastChangedAt: true 어노테이션이 빠졌을 가능성이 큽니다. managed 시나리오의 자동 갱신은 이 시맨틱 어노테이션을 보고 동작합니다. unmanaged라면 자동 갱신 자체가 없으므로 save 구현에서 직접 utclong_current( ) 등으로 채워야 합니다.

Q2. 아이템만 수정했는데 다른 사용자의 헤더 수정과 충돌이 감지되지 않습니다.

자식 엔터티에 etag dependent by _Parent가 없고 자식 자체 필드로 etag master를 선언한 경우입니다. 이러면 헤더와 아이템이 각자 버전을 관리해 BO 관점의 교차 충돌을 놓칩니다. 헤더-아이템이 하나의 업무 단위라면 dependent 위임 구조가 일반적으로 안전합니다.

Q3. Draft 활성화 후 BDEF에서 "total etag required" 계열 문법 오류가 납니다.

with draft;를 켠 루트 BO는 lock master 뒤에 total etag <필드>를 반드시 선언해야 합니다. 이때 필드는 보통 @Semantics.systemDateTime.lastChangedAt 어노테이션이 붙은 BO 전체 대표 타임스탬프를 사용합니다. entity ETag(etag master)와 별개 필드로 두는 구성이 권장됩니다.

Q4. Postman 테스트에서 매번 412가 납니다.

이전 조회에서 받은 오래된 ETag를 재사용하고 있는 경우입니다. PATCH 성공 응답의 ETag 헤더로 로컬 값을 갱신하거나, 저장 직전에 GET으로 최신 ETag를 다시 받아야 합니다. 반대로 412가 전혀 안 난다면 If-Match: *를 보내고 있지 않은지 확인하세요.

🚀 이어서 살펴보면 좋은 주제

ETag로 충돌을 "감지"했다면, 다음은 충돌을 "예방"하는 쪽입니다. lock master와 Enqueue 기반 비관적 잠금의 상세 동작, Draft의 exclusive lock 수명 주기(DraftAdministrativeData), unmanaged 시나리오에서의 잠금·ETag 직접 구현, 그리고 RAP BO 소비 측면에서 EML의 FAILED/REPORTED 응답 구조 분석으로 확장해 보길 권합니다. Fiori elements의 충돌 대화상자 커스터마이징도 실무에서 자주 만나는 주제입니다.

📚 더 깊이 볼 자료

댓글 0

아직 댓글이 없습니다.