📖 개요: 이 글에서 다루는 것
RAP(RESTful Application Programming Model)에서 Early Numbering을 직접 구현하면, 사용자가 저장 버튼을 누르기 전에 이미 문서번호가 확정됩니다. 편리해 보이지만, 두 사용자가 동시에 생성 트랜잭션을 시작하는 순간 같은 번호가 두 레코드에 붙는 사고가 날 수 있습니다. 이 글은 물류센터 피킹 리스트 채번 시나리오를 새로 만들어, 충돌이 발생하는 정확한 지점과 회피 방법을 코드로 검증합니다.
- Early Numbering이 RAP 트랜잭션의 어느 단계에서 실행되는지 이해한다
- SELECT MAX + 1 방식이 동시 생성 시 왜 깨지는지 재현 조건을 파악한다
- Number Range 객체, ENQUEUE 잠금, GUID 대체라는 3가지 회피 전략을 구현한다
- 번호 건너뜀(gap)과 버퍼링의 트레이드오프를 판단할 수 있다
📚 미리 알아두면 좋은 것
RAP Managed 시나리오의 기본 구조(Behavior Definition, Behavior Pool, EML)를 한 번이라도 다뤄봤다면 충분합니다. CDS 뷰 엔티티 문법과 ABAP SQL, 그리고 낙관적/비관적 잠금의 개념 차이를 알고 있으면 4번 섹션의 동시성 분석이 훨씬 빠르게 읽힙니다. Number Range 객체를 써본 경험은 없어도 됩니다. 이 글에서 처음부터 만듭니다.
🔧 테스트 환경
이 글의 코드는 다음 환경에서 작성·검증하는 것을 전제로 합니다.
- SAP BTP ABAP Environment(2308 이상) 또는 SAP S/4HANA 2022 이상(ABAP Platform 2022,
strict ( 2 )문법 사용) - ABAP Development Tools(ADT) — 클라우드에서는 Number Range 객체도 SNRO가 아닌 ADT의 Number Range Object 마법사로 생성합니다
- 개발 패키지에 대한 생성 권한과, 동시성 테스트를 위해 세션 2개를 띄울 수 있는 사용자 계정
온프레미스 S/4HANA에서는 SNRO 트랜잭션으로 Number Range 객체를 만들어도 되며, 런타임 API(cl_numberrange_runtime)는 양쪽 환경에서 일반적으로 동일하게 동작합니다.
💡 핵심 개념: Early Numbering과 동시성 창
RAP 트랜잭션은 크게 인터랙션 단계(interaction phase)와 저장 시퀀스(save sequence)로 나뉩니다. Early Numbering의 FOR NUMBERING 메서드는 CREATE 요청이 들어온 직후, 즉 인터랙션 단계에서 실행됩니다. COMMIT은 아직 멀었는데 키는 벌써 확정된다는 뜻입니다. 반대로 Late Numbering은 저장 시퀀스 안(adjust_numbers)에서 채번하므로 번호가 확정되는 시점이 훨씬 뒤입니다.
비유하면 이렇습니다. 은행 번호표 기계는 버튼을 누르는 순간 유일한 번호를 원자적으로 발급합니다. 반면 "대기실을 둘러보고 가장 큰 번호표 + 1을 내가 종이에 적는" 방식이라면, 두 사람이 동시에 둘러보는 순간 같은 번호를 적게 됩니다. SELECT MAX + 1 채번이 정확히 후자입니다.
충돌이 발생하는 조건을 시간축으로 펼치면 다음과 같습니다.
세션 A: MAX 조회 → 1042 → 1043 할당 (아직 미저장)
세션 B: MAX 조회 → 1042 (A가 커밋 전이므로 여전히 1042) → 1043 할당
세션 A 커밋 → 성공 / 세션 B 커밋 → 중복 키 SQL 오류 또는 덮어쓰기
핵심은 "조회 시점"과 "커밋 시점" 사이의 간격이 곧 충돌 창(window)이라는 점입니다. Early Numbering은 이 간격이 사용자가 화면에서 입력을 마칠 때까지 늘어나므로, Late Numbering보다 충돌 창이 수십 배 넓습니다. 그래서 Early Numbering을 직접 구현할 때는 채번 자체가 원자적인 메커니즘(Number Range, 잠금, GUID) 위에 서 있어야 합니다.
| 구분 | Early Numbering | Late Numbering |
|---|---|---|
| 채번 시점 | 인터랙션 단계(CREATE 직후) | 저장 시퀀스(adjust_numbers) |
| 사용자에게 번호 노출 | 저장 전부터 가능 | 커밋 후 |
| 충돌 창 | 넓음(입력 시간 전체) | 좁음(저장 시퀀스 내부) |
| 취소 시 번호 | 이미 소비되어 gap 발생 | 일반적으로 gap 최소화 유리 |
💻 실전 예제 3단계: 피킹 리스트 채번 구현
시나리오: 물류센터에서 출고 피킹 리스트를 생성하면 PL-0000001043 형태의 번호가 즉시 부여되어 작업자 단말에 표시되어야 합니다. 테이블 zwhm_picklist, 뷰 엔티티 ZR_WHM_PickingList를 사용합니다.
1단계 — 기본 골격과 '깨지는' 구현 재현
먼저 Behavior Definition에 early numbering을 선언하고, 키 필드는 읽기 전용으로 막습니다.
managed implementation in class zbp_r_whm_pickinglist unique;
strict ( 2 );
define behavior for ZR_WHM_PickingList alias PickingList
persistent table zwhm_picklist
lock master
authorization master ( instance )
early numbering
{
create;
update;
delete;
field ( readonly ) PickListId;
mapping for zwhm_picklist
{
PickListId = pick_list_id;
Warehouse = warehouse_id;
PickerGroup = picker_group;
}
}
이제 많은 팀이 처음 작성하는 순진한 구현입니다. 이 코드는 단일 사용자 테스트를 전부 통과하지만, 동시 생성 시 위 시간축 그대로 깨집니다.
METHOD earlynumbering_create.
" 위험: 조회와 할당 사이에 다른 세션이 끼어들 수 있다
SELECT MAX( pick_list_id ) FROM zwhm_picklist INTO @DATA(lv_max).
LOOP AT entities INTO DATA(ls_entity).
lv_max += 1.
APPEND VALUE #( %cid = ls_entity-%cid
%key-PickListId = lv_max )
TO mapped-pickinglist.
ENDLOOP.
ENDMETHOD.
재현 방법: ADT 콘솔 클래스 2개에서 각각 EML CREATE를 실행하되, 조회 직후 WAIT UP TO 5 SECONDS를 넣어 충돌 창을 인위적으로 벌리면 두 세션 모두 같은 번호를 받아가는 것을 확인할 수 있습니다.
2단계 — Number Range 객체로 원자적 채번 + 오류 처리
ADT에서 Number Range 객체 ZWHM_PICK(구간 01, 01000000000~01999999999)을 만든 뒤, 런타임 API로 교체합니다. Number Range는 DB의 구간 테이블을 잠그고 증가시키므로 여러 세션이 동시에 호출해도 서로 다른 번호를 받는 것이 일반적입니다. 대량 생성을 고려해 한 번에 필요한 수량을 블록으로 가져오고, 실패 시 failed/reported를 반드시 채웁니다.
METHOD earlynumbering_create.
DATA(lt_pending) = entities.
DELETE lt_pending WHERE PickListId IS NOT INITIAL. " 외부 지정 키 제외
CHECK lt_pending IS NOT INITIAL.
TRY.
cl_numberrange_runtime=>number_get(
EXPORTING nr_range_nr = '01'
object = 'ZWHM_PICK'
quantity = CONV #( lines( lt_pending ) )
IMPORTING number = DATA(lv_last)
returncode = DATA(lv_rc) ).
CATCH cx_number_ranges INTO DATA(lx_nr).
LOOP AT lt_pending INTO DATA(ls_err).
APPEND VALUE #( %cid = ls_err-%cid
%fail-cause = if_abap_behv=>cause-unspecific )
TO failed-pickinglist.
APPEND VALUE #( %cid = ls_err-%cid
%msg = new_message_with_text(
severity = if_abap_behv_message=>severity-error
text = lx_nr->get_text( ) ) )
TO reported-pickinglist.
ENDLOOP.
RETURN.
ENDTRY.
" 반환값은 블록의 마지막 번호이므로 시작 번호를 역산한다
DATA(lv_next) = CONV zwhm_picklist-pick_list_id( lv_last )
- lines( lt_pending ) + 1.
LOOP AT lt_pending INTO DATA(ls_entity).
APPEND VALUE #( %cid = ls_entity-%cid
%key-PickListId = lv_next )
TO mapped-pickinglist.
lv_next += 1.
ENDLOOP.
ENDMETHOD.
returncode가 경고(구간 소진 임박 등)를 담아 돌아오는 경우 로그 객체(예: Application Log)로 남겨 운영팀이 구간 확장 시점을 알 수 있게 하는 것이 권장됩니다.
3단계 — 프로덕션 관점: 버퍼링·잠금 대안·테스트
운영 배포 전 세 가지를 점검합니다.
① 버퍼링 판단 — Number Range에 버퍼링(main memory buffering)을 켜면 서버 인스턴스별로 번호 블록을 미리 가져가므로 처리량은 오르지만, 번호가 시간순으로 정렬되지 않고 인스턴스 재시작 시 블록 잔여분만큼 gap이 생깁니다. 피킹 리스트처럼 유일성만 중요하면 버퍼링을 켜고, 감사 추적상 연속성이 요구되는 문서라면 무버퍼(no buffering)를 유지하는 것이 일반적입니다.
② 잠금 객체 대안 — Number Range를 쓸 수 없는 제약(레거시 규칙 테이블 기반 채번 등)이 있다면, 채번 규칙 테이블에 ENQUEUE 잠금을 걸어 조회~할당 구간을 직렬화할 수 있습니다.
" 잠금 객체 EZWHM_PICKRULE 기반 직렬화 (온프레미스 스타일)
CALL FUNCTION 'ENQUEUE_EZWHM_PICKRULE'
EXPORTING warehouse_id = ls_entity-Warehouse
EXCEPTIONS foreign_lock = 1 system_failure = 2 OTHERS = 3.
IF sy-subrc <> 0.
" failed/reported 채우고 반환 — 잠금 실패를 침묵으로 넘기지 않는다
ENDIF.
" ... SELECT MAX + 1 수행 후 즉시 DEQUEUE
다만 이 방식은 잠금 해제 시점 관리가 까다롭고 대기 시간이 늘어나므로, 신규 설계에서는 Number Range가 우선입니다.
③ GUID 대체와 테스트 — 사람이 읽는 번호가 굳이 키일 필요가 없다면, 키는 uuid(cl_system_uuid)로 두고 표시용 번호만 Late Numbering이나 저장 시점 채번으로 미루는 설계가 충돌을 구조적으로 제거합니다. 마지막으로, 한 요청 안에서 여러 %cid가 서로 다른 번호를 받는지 유닛 테스트로 고정해 둡니다.
METHOD distinct_keys_for_two_cids FOR TESTING.
MODIFY ENTITIES OF zr_whm_pickinglist
ENTITY PickingList
CREATE FIELDS ( Warehouse )
WITH VALUE #( ( %cid = 'C1' Warehouse = 'WH01' )
( %cid = 'C2' Warehouse = 'WH01' ) )
MAPPED DATA(mapped) FAILED DATA(failed) REPORTED DATA(reported).
cl_abap_unit_assert=>assert_initial( failed-pickinglist ).
cl_abap_unit_assert=>assert_differs(
act = mapped-pickinglist[ 1 ]-PickListId
exp = mapped-pickinglist[ 2 ]-PickListId ).
ENDMETHOD.
DB 격리가 필요하면 ABAP SQL 테스트 환경(cl_osql_test_environment)으로 zwhm_picklist를 대역 처리해 테스트가 실제 구간을 소모하지 않게 합니다.
⚠️ 삽질 노트: 자주 만나는 함정
- Q1. 개발기에서는 아무리 눌러도 중복이 안 나는데 운영에서만 터집니다.
A. 충돌 창이 밀리초 단위라 단일 개발자는 재현이 거의 불가능합니다. 1단계처럼 WAIT로 창을 벌리거나, 병렬 배치로 수백 건을 동시 생성하는 부하 테스트를 해야 드러납니다. "재현 안 됨 = 안전"이 아닙니다. - Q2. 사용자가 생성 화면에서 취소했는데 번호가 사라졌습니다(gap).
A. 정상 동작입니다. Early Numbering은 인터랙션 단계에서 번호를 소비하므로 롤백해도 Number Range는 되돌아가지 않습니다. 연속 번호가 규제 요건이라면 Late Numbering으로 전환을 검토하세요. - Q3. 버퍼링을 켰더니 번호가 1043 다음에 1201로 점프합니다.
A. 인스턴스별 블록 선점 때문입니다. 결함이 아니라 버퍼링의 본질적 특성이므로, 순서가 중요하면 버퍼링을 끄고 처리량 요구와 저울질해야 합니다. - Q4. Draft를 켰더니 임시 저장 문서에도 번호가 붙어 있습니다.
A. Early Numbering은 CREATE 시점에 실행되므로 draft 인스턴스도 이미 확정 번호를 가집니다. draft 폐기 시 그 번호는 Q2와 마찬가지로 gap이 됩니다.
🚀 이어서 보면 좋은 글
이 글의 자연스러운 다음 주제는 Late Numbering입니다. late numbering 선언과 adjust_numbers 구현, 임시 키(%tmp) 매핑을 익히면 gap 요구사항이 있는 문서에 대응할 수 있습니다. 그다음으로는 Unmanaged 시나리오에서의 채번 책임 분리, 대량 생성 부하 테스트, 그리고 Number Range 구간 소진 모니터링을 Application Log와 연계하는 운영 자동화까지 확장해 보시길 권장합니다.
댓글 0
아직 댓글이 없습니다.