RAP

채번 갭 방치하면 큰일 — 롤백 후 번호가 안 돌아오는 이유 #shorts #SAP #RAP

개요 — 이 글에서 확인할 내용

RAP(ABAP RESTful Application Programming Model)에서 액션이 실패해 트랜잭션 전체가 롤백되었는데, 다음 문서를 생성하면 번호가 하나 건너뛰어 있는 현상을 본 적이 있을 것입니다. 예를 들어 판매오더 SO-1007 생성이 실패했는데 다음 오더가 SO-1009로 저장되는 식입니다. 이것은 버그가 아니라 number range(채번 객체)가 애플리케이션 LUW와 별개로 동작하도록 설계된 결과입니다. 이 글을 통해 다음을 확인할 수 있습니다.

  • DB 롤백과 채번이 서로 다른 트랜잭션 경계에서 움직이는 이유
  • 채번 갭(gap)이 감사(audit) 관점에서 왜 정상 동작으로 취급되는지
  • RAP의 early numbering / late numbering 선택이 갭 빈도에 미치는 영향
  • 갭을 줄이거나, 갭이 있어도 문제없는 구조로 설계하는 실무 패턴

미리 알아두면 좋은 내용

RAP behavior definition(BDEF)의 기본 구조와 managed 시나리오, ABAP의 LUW(Logical Unit of Work)와 COMMIT WORK/ROLLBACK WORK 개념을 알고 있으면 좋습니다. 클래식 ABAP의 NUMBER_GET_NEXT 함수 모듈을 사용해 본 경험이 있다면 ABAP Cloud에서의 차이점을 비교하며 읽을 수 있습니다.

환경과 준비물

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

  • SAP BTP ABAP Environment(Steampunk) 또는 SAP S/4HANA 2023 이상(ABAP Cloud 개발 모델)
  • ABAP Development Tools(ADT) — number range object는 ABAP Cloud에서 ADT로 생성합니다(클래식 SNRO 트랜잭션 대신)
  • 채번 API: CL_NUMBERRANGE_RUNTIME (released API, ABAP Cloud에서 사용 가능)
  • 예제용 number range object ZSO_NR, 인터벌 01 (범위 1000000~1999999)

온프레미스 클래식 환경에서는 SNRO로 만든 객체와 NUMBER_GET_NEXT를 쓰지만, 이 글에서 설명하는 갭 발생 원리는 동일하게 적용됩니다.

핵심 개념 — 롤백과 채번이 별개로 움직이는 이유

채번을 은행 창구의 번호표 기계에 비유하면 이해가 쉽습니다. 번호표를 뽑은 손님이 볼일을 안 보고 그냥 나가도, 기계는 그 번호를 회수해서 다음 손님에게 다시 주지 않습니다. 번호표 기계(채번)와 창구 업무(비즈니스 트랜잭션)는 서로 독립된 시스템이기 때문입니다.

기술적으로 보면 이렇게 동작합니다.

  • 채번 상태는 NRIV 테이블 + 버퍼에서 관리 — 현재 번호 레벨은 시스템 테이블(NRIV)에 저장되고, 성능을 위해 일반적으로 메인 메모리에 버퍼링됩니다. 번호를 하나 뽑을 때마다 NRIV에 DB 접근을 하면 모든 문서 생성이 이 한 행의 락을 두고 직렬화되기 때문입니다.
  • 번호 인출은 애플리케이션 LUW 밖에서 확정 — 버퍼링된 채번은 번호 블록을 별도 커밋으로 미리 확보해 두고 메모리에서 나눠줍니다. 즉 애플리케이션이 ROLLBACK WORK을 해도 이미 인출된 번호는 채번 객체 입장에서 "소비된 것"으로 남습니다.
  • 의도된 설계 — 만약 롤백 시 번호를 되돌린다면, 그 사이에 다른 세션이 다음 번호를 이미 가져갔을 때 순서가 꼬이거나 채번 행 락을 트랜잭션이 끝날 때까지 유지해야 합니다. 후자는 시스템 전체 처리량을 심각하게 떨어뜨립니다. SAP는 성능과 동시성을 위해 "갭 허용"을 기본 설계로 채택했습니다.

도식으로 표현하면 다음과 같습니다.

# 두 개의 독립된 트랜잭션 경계
채번 LUW:        [번호 블록 확보 → 즉시 확정]     # 롤백 대상 아님
애플리케이션 LUW: [번호 사용 → 검증 실패 → 롤백]   # 데이터만 되돌아감
결과:            번호 1008은 소비됨, 문서는 없음 → 갭

감사(audit) 관점에서도 갭은 정상입니다. 감사에서 중요한 것은 일반적으로 "번호가 연속인가"가 아니라 "같은 번호가 두 번 발급되지 않았는가(유일성)"와 "번호가 재사용되지 않았는가"입니다. 갭이 있는 번호 체계는 오히려 "이 번호로 트랜잭션 시도가 있었으나 완료되지 않았다"는 흔적으로 해석됩니다. SAP Note 62077도 내부 채번이 연속적이지 않을 수 있음을 오래전부터 명시해 왔습니다. 단, 일부 국가의 법정 문서(세금계산서 등)는 무갭(gap-free) 채번을 요구할 수 있는데, 이 경우 버퍼링을 끄고 처리량 저하를 감수하는 별도 설계가 필요합니다. 무갭 요건이 없는 곳에 무갭 설계를 적용하는 것은 일반적으로 권장되지 않습니다.

실전 코드 3단계

1단계 — early numbering으로 갭을 직접 관찰하기

판매오더 엔티티에 unmanaged early numbering을 적용한 BDEF입니다. 생성 시점에 번호를 뽑기 때문에, 이후 검증 실패로 롤백되면 갭이 생기는 것을 가장 쉽게 관찰할 수 있습니다.

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

define behavior for ZR_SalesOrder alias SalesOrder
persistent table zso_order
lock master
authorization master ( instance )
early numbering
{
  create;
  update;
  delete;
  field ( readonly ) OrderId;

  action ( features : instance ) submitOrder result [1] $self;
}

채번 구현부입니다. CL_NUMBERRANGE_RUNTIME=>NUMBER_GET이 번호를 인출하는 순간, 이 인출은 현재 LUW의 롤백 대상에서 벗어납니다.

METHOD earlynumbering_create.
  DATA(lv_count) = CONV cl_numberrange_runtime=>nr_quantity(
                     lines( entities ) ).

  TRY.
      cl_numberrange_runtime=>number_get(
        EXPORTING nr_range_nr = '01'
                  object      = 'ZSO_NR'
                  quantity    = lv_count
        IMPORTING number      = DATA(lv_number)
                  returncode  = DATA(lv_rc) ).
    CATCH cx_number_ranges INTO DATA(lx_nr).
      " 채번 실패 시 인스턴스 전체를 failed로 보고
      LOOP AT entities INTO DATA(ls_fail).
        APPEND VALUE #( %cid = ls_fail-%cid
                        %fail-cause = if_abap_behv=>cause-unspecific )
               TO failed-salesorder.
      ENDLOOP.
      RETURN.
  ENDTRY.

  DATA(lv_current) = CONV i( lv_number ) - lv_count + 1.
  LOOP AT entities INTO DATA(ls_entity).
    APPEND VALUE #( %cid    = ls_entity-%cid
                    OrderId = |SO-{ lv_current }| )
           TO mapped-salesorder.
    lv_current += 1.
  ENDLOOP.
ENDMETHOD.

이 상태에서 submitOrder 액션이나 저장 단계 검증이 실패하면 데이터는 롤백되지만 SO-xxxxxxx 번호는 이미 소비되어 갭이 남습니다.

2단계 — 롤백 시나리오와 갭 로깅

액션에서 신용 한도 검증이 실패하는 실무 시나리오입니다. 갭 자체는 막을 수 없으므로, 대신 "번호가 소비되었으나 문서가 확정되지 않은" 이벤트를 애플리케이션 로그로 남겨 감사 추적성을 확보합니다.

METHOD submitOrder.
  READ ENTITIES OF zr_salesorder IN LOCAL MODE
    ENTITY SalesOrder
    FIELDS ( OrderId CustomerId TotalAmount )
    WITH CORRESPONDING #( keys )
    RESULT DATA(lt_orders).

  LOOP AT lt_orders INTO DATA(ls_order).
    IF zcl_credit_check=>is_exceeded( customer = ls_order-CustomerId
                                      amount   = ls_order-TotalAmount ).
      APPEND VALUE #( %tky = ls_order-%tky ) TO failed-salesorder.
      APPEND VALUE #( %tky = ls_order-%tky
                      %msg = new_message_with_text(
                        severity = if_abap_behv_message=>severity-error
                        text = |{ ls_order-OrderId }: 신용 한도 초과| ) )
             TO reported-salesorder.

      " 갭 추적 로그: 번호는 소비되었으나 문서는 롤백될 예정
      " (로그 기록은 별도 LUW로 커밋되어야 롤백에 휩쓸리지 않음)
      zcl_nr_gap_logger=>log_in_new_luw(
        iv_object = 'ZSO_NR'
        iv_number = ls_order-OrderId
        iv_reason = 'CREDIT_CHECK_FAILED' ).
    ENDIF.
  ENDLOOP.
ENDMETHOD.

주의할 점은 로그 기록 자체도 같은 LUW에서 INSERT하면 함께 롤백된다는 것입니다. 로거 내부에서는 백그라운드 처리(예: bgPQ 백그라운드 프로세싱 프레임워크)나 별도 커밋 경로를 사용해야 합니다. 채번과 같은 문제를 로깅에서도 반복하지 않도록 설계하는 것이 핵심입니다.

3단계 — 프로덕션: late numbering으로 갭 빈도 최소화

갭을 근본적으로 줄이는 방법은 번호 인출 시점을 최대한 뒤로 미루는 것입니다. late numbering은 저장 시퀀스의 adjust_numbers 단계, 즉 모든 검증이 통과한 뒤에야 번호를 뽑습니다. 그 전까지는 임시 키(%pid 또는 임시 키값)로 인스턴스를 다룹니다.

define behavior for ZR_SalesOrder alias SalesOrder
persistent table zso_order
lock master
authorization master ( instance )
late numbering
{
  create;
  update;
  delete;
  field ( readonly ) OrderId;
}
CLASS lsc_zr_salesorder IMPLEMENTATION.
  METHOD adjust_numbers.
    " 이 시점은 검증이 모두 끝난 save 단계 → 갭 확률이 크게 감소
    TRY.
        cl_numberrange_runtime=>number_get(
          EXPORTING nr_range_nr = '01'
                    object      = 'ZSO_NR'
                    quantity    = CONV #( lines( mapped-salesorder ) )
          IMPORTING number      = DATA(lv_last) ).
      CATCH cx_number_ranges.
        RAISE EXCEPTION TYPE zcx_so_numbering.
    ENDTRY.

    DATA(lv_no) = CONV i( lv_last ) - lines( mapped-salesorder ) + 1.
    LOOP AT mapped-salesorder REFERENCE INTO DATA(lr_map).
      lr_map->OrderId = |SO-{ lv_no }|.
      lv_no += 1.
    ENDLOOP.
  ENDMETHOD.
ENDCLASS.

프로덕션 체크리스트:

  • 성능 — number range object의 버퍼링(main memory buffering)은 유지하는 것이 일반적으로 권장됩니다. 버퍼 블록 크기가 클수록 시스템 재시작 시 버려지는 번호가 늘어 갭이 커지지만 처리량은 좋아집니다. 트레이드오프를 문서화하세요.
  • 테스트 — 단위 테스트에서는 CL_NUMBERRANGE_RUNTIME을 직접 호출하지 말고 인터페이스로 감싼 뒤 test double을 주입해, 테스트 실행이 실제 번호를 소비하지 않도록 합니다.
  • 보안/감사 — 번호를 화면에 노출하기 전(early numbering) 사용자가 이탈하면 갭이 생긴다는 점을 감안해, ID를 미리 보여줄 비즈니스 요구가 없다면 late numbering을 기본값으로 선택하세요.

흔한 실수와 트러블슈팅

Q1. 갭이 생겼는데 데이터 유실인가요?

아닙니다. 채번 갭은 "번호가 인출된 뒤 트랜잭션이 완료되지 않았다"는 의미일 뿐이며, 저장된 데이터가 사라진 것이 아닙니다. 유실 여부는 문서 테이블과 변경 이력으로 확인해야 하며, 번호 연속성으로 판단하면 안 됩니다.

Q2. 갭을 없애려고 액션 안에서 직접 ROLLBACK WORK 후 번호를 재사용하면 안 되나요?

안 됩니다. RAP에서 명시적 COMMIT/ROLLBACK WORK은 프레임워크가 관리하는 영역이라 직접 호출하면 안 되고, 인출된 번호를 수동으로 재사용하면 동시 세션과 충돌해 중복 키가 발생할 수 있습니다. 중복은 갭보다 훨씬 심각한 감사 이슈입니다.

Q3. 감사팀이 번호 연속성을 요구하면 어떻게 설명하나요?

"채번은 유일성과 비재사용을 보장하는 장치이며, 갭은 미완료 트랜잭션의 정상적 흔적"이라고 설명하고 SAP Note 62077을 근거로 제시하는 방식이 일반적입니다. 법정 무갭 요건이 실제로 존재한다면 해당 문서 유형만 무버퍼 채번 또는 별도 순번 테이블로 분리 설계합니다.

Q4. 개발계에서는 갭이 없었는데 운영계에서만 크게 벌어집니다.

버퍼 블록이 애플리케이션 서버별로 확보되기 때문입니다. 서버가 여러 대이거나 재시작이 잦으면 미사용 블록이 통째로 버려져 갭이 커집니다. 정상 동작이며, 갭 크기를 줄이려면 버퍼 수량을 조정합니다(처리량 저하 감수).

이어서 살펴보면 좋은 주제

  • RAP late numbering과 %pid의 내부 동작 — 임시 키가 최종 키로 매핑되는 save 시퀀스 상세
  • RAP 저장 시퀀스(finalize → check_before_save → adjust_numbers → save) — 각 단계에서 실패 시 무엇이 롤백되는지
  • bgPQ(백그라운드 프로세싱) — 롤백에 휩쓸리지 않는 로깅/후속 처리 설계
  • UUID 키 vs 가독 번호 — 기술 키는 UUID, 표시용 번호만 채번하는 이중 키 전략

더 읽어볼 문서

댓글 0

아직 댓글이 없습니다.