본문 바로가기
ABAP

[ABAP] 재고는 있는데 재고 부족? BAPI 이슈 해결

by 키노s 2025. 6. 29.

주말은 주식장이 안 열리기에 주중에 있던 것을 정리해봤습니다.

그중에서 미스터리 소설 같았던 기술적 난제를 해결한 경험을 공유하려 합니다.

시스템은 분명 "재고가 있다"고 하는데, 정작 BAPI는 "재고가 없다"고 외치는, 개발자라면 뒷목 잡게 만드는 그런 상황이었죠.

 제가 겪었던 문제의 본질,  LUW(Logical Unit of Work)  처리방안을 공유하겠습니다.

사건의 발단: 복합적인 STO 입고 취소 프로세스

먼저 저희 업무 시나리오를 이해하셔야 합니다. 플랜트 간 재고를 이전하는 STO(Stock Transport Order) 프로세스와 생산 오더가 얽혀있는 복잡한 구조입니다.

  1. STO 입고(MM): 입고 플랜트에서 해당 자재를 입고합니다. (이동유행 101)
  2. 생산(PP): 입고된 자재를 사용하여 생산 오더(Production Order)에 따라 자재를 출고(GI), 제품을 입고(GR)하고 작업을 확정(Confirm)합니다.

문제는 이 모든 과정을 되돌리는 **'STO 입고 취소'**에서 시작되었습니다.

비즈니스 요구사항은 'STO 입고 취소' 버튼 하나로, 이와 연결된 PP의 생산오더 취소MM의 STO 입고 취소가 한 번에 처리되어야 한다는 것이었습니다.

이를 위해 STO 입고 취소 CBO 프로그램은 다음과 같은 순서로 로직을 수행하도록 설계되었습니다.

* === STEP 1: 생산(PP) 관련 내역 전체 취소 ===
* PP팀에서 제공한 RFC. 생산 출고(GI), 제품 입고(GR), 확정(Confirm)을 모두 취소한다.
CALL FUNCTION 'Z_PPG_PROD_CANCEL'
  EXPORTING
    gi_mblnr   = ls_item-gi_mblnr
    gr_mblnr   = ls_item-gr_mblnr
  IMPORTING
    o_zresult  = lv_zresult
    o_zmessage = lv_zmessage.

* 정상적으로 처리되었다면, 이제 원래의 STO 입고를 취소할 차례...

* === STEP 2: STO(MM) 입고 전표 취소 ===
* 표준 BAPI를 사용하여 101 이동유형으로 입고된 전표를 취소(102)한다.
CALL FUNCTION 'BAPI_GOODSMVT_CANCEL'
  EXPORTING
    materialdocument    = ls_head-mat_doc
    matdocumentyear     = ls_head-doc_year
    goodsmvt_pstng_date = ls_head-pstng_date
  IMPORTING
    goodsmvt_headret    = ls_headret
  TABLES
    return              = lt_return.

미스터리: "유령 재고" 현상

여기서 기묘한 일이 벌어졌습니다.

  • STEP 1의 RFC는 완벽하게 실행되었습니다.
    로그를 확인해보니 생산에 투입되었던 자재가 정상적으로 재고로 반환된 것을 확인했습니다.
  • 하지만 바로 이어지는 STEP 2의 BAPI에서 "Deficit of Stock(재고 부족)" 오류가 발생하며 전표 취소가 실패했습니다.

MMBE나 MB51로 재고를 확인하면 분명 방금 전 PP 취소로 돌아온 재고가 멀쩡히 보였습니다.
시스템의 모든 표준 리포트는 재고가 있다고 말하는데, 왜 BAPI만 없다고 하는 걸까요? 마치 유령 재고를 보는 듯한 답답한 상황이었습니다.

가설과 검증: 범인을 찾아서

이 미스터리를 풀기 위해, 저는 몇 가지 가설을 세우고 하나씩 검증해 나갔습니다.

가설 1: 재고 유형(Stock Type)의 불일치

가장 먼저 의심한 것은 재고의 '상태'였습니다. PP에서 취소된 재고가 '가용재고(Unrestricted-Use)'가 아니라 '품질검사(Quality Inspection)'나 '보류재고(Blocked Stock)' 상태로 돌아온 것은 아닐까? 만약 그렇다면 BAPI_GOODSMVT_CANCEL이 가용재고를 찾지 못해 오류를 내는 것이 타당합니다. ▶ 검증 결과: 실패. 로그와 테이블을 분석한 결과, PP 취소로 반환된 재고는 명백히 '가용재고'였습니다. 첫 번째 가설은 기각되었습니다.

가설 2: 시간차(Timing) 문제

두 번째 가설은 DB 업데이트 타이밍 문제였습니다. STEP 1의 RFC가 실행되고 내부적으로 COMMIT을 날렸지만, 그 변경사항이 물리적인 DB에 완전히 반영되기 전에 STEP 2의 BAPI가 재고를 조회하는 것은 아닐까? ABAP 개발자라면 한 번쯤 겪어보는 흔한 시나리오입니다. ▶ 검증 결과: 실패.

  • WAIT UP TO '5' SECONDS. 구문으로 물리적인 대기 시간을 부여했지만, 결과는 동일했습니다.
  • BAPI 호출 로직을 별도의 Function Module로 감싸 IN UPDATE TASK 옵션으로 비동기 업데이트를 시도했습니다. 이 역시 문제를 해결하지 못했습니다. 타이밍 이슈가 원인의 전부는 아닌 것 같았습니다.

여러 시도가 실패로 돌아가자, 문제의 본질이 더 깊은 곳에 있음을 직감했습니다.
바로 SAP LUW(Logical Unit of Work), 즉 '논리적 작업 단위'의 문제였습니다.

SAP LUW란? 데이터베이스의 일관성을 보장하기 위한 개념입니다. 하나의 비즈니스 트랜잭션이 시작되어서 COMMIT WORK로 성공적으로 끝나거나, ROLLBACK WORK로 완전히 원상 복구되는 하나의 묶음을 의미합니다. 중간에 일부만 실행되고 일부는 실패하는 애매한 상태를 방지하는 것이 핵심입니다.


프로그램의 문제는 STEP 1(PP RFC 호출)과 STEP 2(MM BAPI 호출)가 하나의 거대한 LUW 안에서 실행되고 있다는 것이었습니다.

  • STEP 1의 RFC가 내부적으로 COMMIT을 포함하고 있더라도, 그것은 그저 '업데이트 요청'을 등록하는 것과 같습니다.
  • 메인 프로그램의 LUW가 COMMIT WORK를 만나기 전까지는,
    STEP 1에서 변경된 재고 상태가 STEP 2의 BAPI에게는 아직 확정되지 않은, 보이지 않는 상태였던 것입니다.

마치 한 은행 창구에서 두 가지 일을 처리하는데, 첫 번째 일(입금)의 전산 처리가 끝나기도 전에 두 번째 일(잔액 증명서 발급)을 요청하니 "아직 잔액이 반영되지 않았습니다"라고 답하는 것과 같은 이치입니다.

가장 이상적인 해결책은 PP RFC를 수정하여 COMMIT 로직을 빼거나 제어할 수 있도록 만드는 것이지만,
저는 MM 담당자이고 해당 RFC는 다른 수많은 프로그램에서 이미 잘 사용되고 있었습니다.
함부로 수정했다가는 더 큰 파장을 일으킬 수 있었죠.
저에게 주어진 조건 하에서 해결책을 찾아야 했습니다.

SUBMIT으로 트랜잭션을 완벽하게 분리하라

고민 끝에 제가 선택한 해결책은 ABAP의 고전적이면서도 강력한 기법, SUBMIT ... AND RETURN을 사용하는 것이었습니다.

핵심 아이디어: BAPI_GOODSMVT_CANCEL을 호출하고 COMMIT하는 부분을 별도의 독립적인 프로그램으로 만들고,
메인 프로그램에서는 이 프로그램을 호출(SUBMIT)만 한다.

SUBMIT 구문은 현재 프로그램의 세션과는 완전히 별개의 새로운 세션과 LUW를 생성합니다.
따라서 STEP 1의 RFC가 포함된 메인 프로그램의 LUW와 STEP 2의 BAPI가 실행될 프로그램의 LUW를 완벽하게 분리할 수 있습니다.

1. 메인 프로그램 수정

CALL FUNCTION 'BAPI_GOODSMVT_CANCEL' 부분을 과감히 삭제하고, 아래와 같이 수정했습니다.

" 기존 BAPI 호출 로직 대신...

" 1. ABAP Memory를 통해 파라미터 전달 준비
"    (새로운 세션에 데이터를 넘겨주기 위한 방법)
EXPORT p_mblnr = materialdocument
       p_mjahr = matdocumentyear
       p_budat = ls_head-pstng_date
  TO MEMORY ID 'Z_PO_CANCEL_BAPI'.

" 2. SUBMIT으로 BAPI 호출 전용 프로그램 실행
"    AND RETURN: 서브 프로그램 실행이 끝날 때까지 대기
"    이 순간, 새로운 LUW가 생성되고 실행된 후 종료된다.
SUBMIT ZTESTR5040 AND RETURN.

" 3. 서브 프로그램의 실행 결과를 다시 ABAP Memory에서 가져옴
IMPORT p_headret = ls_headret
       p_return  = lt_return
  FROM MEMORY ID 'Z_PO_CANCEL_RESULT'.

" 4. 사용한 메모리 정리 (필수!)
FREE MEMORY ID 'Z_PO_CANCEL_BAPI'.
FREE MEMORY ID 'Z_PO_CANCEL_RESULT'.

2. BAPI 호출 전용 프로그램 신규 생성 (ZTESTR5040)

이 프로그램의 역할은 오직 하나, 메인 프로그램이 넘겨준 데이터로 BAPI를 호출하고 자신의 LUW 안에서 COMMIT이나 ROLLBACK을 책임지는 것입니다.

ABAP
 
*&---------------------------------------------------------------------*
*& Report  ZTESTR5040
*& Description: 입고 취소 BAPI 호출 및 Commit/Rollback만 수행
*&---------------------------------------------------------------------*
REPORT ZTESTR5040 NO STANDARD PAGE HEADING.

DATA: gv_mblnr   TYPE mblnr,
      gv_mjahr   TYPE mjahr,
      gv_budat   TYPE budat,
      gs_headret TYPE bapi2017_gm_head_ret,
      gt_return  TYPE TABLE OF bapiret2.

START-OF-SELECTION.
  " STEP 1: 메인 프로그램이 숨겨둔 데이터를 꺼낸다.
  PERFORM get_data_from_memory.

  " STEP 2: BAPI 호출 및 트랜잭션 처리라는 핵심 임무 수행.
  PERFORM main_process.

  " STEP 3: 결과를 다시 메인 프로그램이 볼 수 있도록 숨겨둔다.
  PERFORM set_data_to_memory.

*&---------------------------------------------------------------------*
*& Form  GET_DATA_FROM_MEMORY
*&---------------------------------------------------------------------*
FORM get_data_from_memory.
  IMPORT p_mblnr = gv_mblnr
         p_mjahr = gv_mjahr
         p_budat = gv_budat
    FROM MEMORY ID 'Z_PO_CANCEL_BAPI'.
  FREE MEMORY ID 'Z_PO_CANCEL_BAPI'. "받았으면 바로 정리
ENDFORM.

*&---------------------------------------------------------------------*
*& Form  MAIN_PROCESS
*&---------------------------------------------------------------------*
FORM main_process.
  " === BAPI 호출 ===
  CALL FUNCTION 'BAPI_GOODSMVT_CANCEL'
    EXPORTING
      materialdocument    = gv_mblnr
      matdocumentyear     = gv_mjahr
      goodsmvt_pstng_date = gv_budat
    IMPORTING
      goodsmvt_headret    = gs_headret
    TABLES
      return              = gt_return.

  " === 이 프로그램의 LUW를 여기서 명시적으로 종료 ===
  IF gs_headret-mat_doc IS NOT INITIAL.
    CALL FUNCTION 'BAPI_TRANSACTION_COMMIT' EXPORTING wait = 'X'.
  ELSE.
    CALL FUNCTION 'BAPI_TRANSACTION_ROLLBACK'.
  ENDIF.
ENDFORM.

*&---------------------------------------------------------------------*
*& Form  SET_DATA_TO_MEMORY
*&---------------------------------------------------------------------*
FORM set_data_to_memory.
  EXPORT p_headret = gs_headret
         p_return  = gt_return
    TO MEMORY ID 'Z_PO_CANCEL_RESULT'.
ENDFORM.

이 방법을 적용하자, 거짓말처럼 STO 입고 취소가 정상적으로 처리되었습니다.

정답이 없다면, 나만의 길을 만든다

이번 이슈를 해결하면서 다시 한번 느꼈습니다.
개발의 세계에는 하나의 정답만 있는 것이 아니라는 것을요.
근본적인 원인을 수정하는 것이 베스트지만, 조직의 구조, 시스템의 영향도 등 현실적인 제약으로 인해 그것이 불가능할 때도 많습니다.

그럴 때 우리에게 필요한 것은 주어진 환경과 도구를 최대한 활용하여 문제를 해결하는 창의성입니다.
SUBMIT을 이용한 트랜잭션 분리는 우아하지는 않을지언정, 주어진 제약 하에서 가장 안정적이고 확실한 해결책이었습니다.

보이지 않는 벽에 부딪혔을 때, 잠시 뒤로 물러나 다른 길은 없는지 살펴보는 지혜.
그리고 마침내 그 길을 찾아내어 문제를 해결했을 때의 짜릿함.


이것이야말로 우리가 이 일을 계속 사랑하게 만드는 원동력이 아닐까요?