이 글이 답하는 질문
SD 영역에서 "이 청구서가 어느 수주에서 시작됐는지" 또는 반대로 "이 수주가 지금 어디까지 진행됐는지"를 프로그램으로 추적해야 하는 요건은 끊임없이 나옵니다. 대부분의 개발자가 VBFA 테이블을 재귀적으로 따라가는 코드를 작성하는데, 이 방식은 운영 데이터에서 세 가지 방식으로 무너집니다. 무한 루프에 빠지거나, 레벨마다 SELECT를 날려 성능이 붕괴되거나, 필요 없는 문서 분기까지 전부 끌고 옵니다.
이 글에서는 VBFA의 실제 구조를 먼저 정리하고, 순환 참조가 왜 발생하는지, 재귀 대신 레벨 단위로 조회를 묶는 방법은 무엇인지, 그리고 S/4HANA에서 표준 VDM으로 대체할 수 있는 경우는 언제인지까지 코드와 함께 다룹니다.
- VBFA의 선행/후속 문서 연결 구조와 VBTYP 문서 카테고리의 역할
- 반품·취소 문서가 만드는 순환 참조와 방문 집합으로 차단하는 방법
- 레벨별 배치 조회로 SELECT 횟수를 깊이만큼으로 줄이는 구현
- 깊이 제한과 카테고리 필터로 불필요한 분기를 잘라내는 기준
- 표준 문서흐름 CDS 뷰를 쓸 수 있는 상황과 직접 구현이 필요한 상황
미리 알고 있으면 좋은 것
판매 프로세스의 기본 흐름(수주 → 납품 → 출고전기 → 청구)과 Open SQL의 SELECT 문법 정도면 충분합니다. 재귀 알고리즘을 몰라도 따라올 수 있도록 반복문 기반 구현으로 설명하며, 그래프 탐색 용어는 최소한으로만 사용합니다. 실행 환경은 SAP S/4HANA 2022 이상 또는 ECC 6.0 기준이며, 두 환경에서 VBFA 구조는 동일합니다.
VBFA 테이블 구조 — 무엇이 어디에 들어가나
VBFA는 판매 문서 사이의 연결선을 한 줄씩 저장하는 테이블입니다. 하나의 행이 "선행 문서 한 라인 → 후속 문서 한 라인"이라는 화살표 하나를 의미합니다. 핵심 필드는 다음과 같습니다.
| 필드 | 의미 | 주의점 |
|---|---|---|
| VBELV / POSNV | 선행 문서 번호 / 라인 | 수주라면 수주번호와 수주 품목번호 |
| VBELN / POSNN | 후속 문서 번호 / 라인 | 납품·청구 등 뒤이어 생성된 문서 |
| VBTYP_V | 선행 문서 카테고리 | C=수주, J=납품, M=청구 등 |
| VBTYP_N | 후속 문서 카테고리 | 필터링의 핵심 기준 |
| RFMNG / MEINS | 참조 수량 / 단위 | 부분 납품이면 수량이 나뉘어 여러 행 |
| ERDAT / ERZET | 생성일 / 시각 | 같은 레벨 정렬에 사용 |
여기서 가장 자주 놓치는 사실은 연결이 1:1이 아니라는 점입니다. 수주 한 라인이 세 번에 나눠 납품되면 VBFA에는 세 행이 생기고, 반대로 여러 수주를 묶어 한 번에 납품하면 여러 선행이 하나의 후속으로 모입니다. 즉 문서흐름은 단순한 사슬이 아니라 갈라지고 합쳐지는 그래프입니다. 재귀 코드가 무너지는 근본 원인이 여기에 있습니다.
함정 1 — 순환 참조로 인한 무한 루프
가장 위험한 함정입니다. 정상적인 정방향 흐름만 생각하면 순환은 불가능해 보이지만, 실제 운영 데이터에는 되돌아오는 연결이 존재합니다. 대표적으로 반품 프로세스가 그렇습니다. 원 청구서를 참조해 반품 수주가 만들어지고, 반품 수주에서 반품 납품과 대변 메모가 이어집니다. 취소 문서 역시 원 문서를 참조합니다. 여기에 클레임 처리나 재청구가 얽히면 결과적으로 A → B → C → A 형태의 경로가 만들어질 수 있습니다.
아래는 실무에서 흔히 보이는, 그리고 위험한 구현입니다.
" 위험한 구현 — 방문 이력이 없어 순환에 걸리면 멈추지 않는다
METHOD trace_forward_bad.
DATA lt_next TYPE STANDARD TABLE OF vbfa.
SELECT vbeln, posnn, vbtyp_n
FROM vbfa
INTO CORRESPONDING FIELDS OF TABLE @lt_next
WHERE vbelv = @iv_docnum.
LOOP AT lt_next INTO DATA(ls_next).
APPEND ls_next-vbeln TO rt_result.
" 매 노드마다 다시 SELECT — 방문 여부 확인 없음
trace_forward_bad( EXPORTING iv_docnum = ls_next-vbeln
CHANGING ct_result = rt_result ).
ENDLOOP.
ENDMETHOD.
이 코드는 개발 시스템의 깨끗한 테스트 데이터에서는 잘 돌아갑니다. 문제는 반품 이력이 쌓인 운영 데이터에서 특정 문서를 넣는 순간 스택이 계속 쌓이며 단기 덤프로 끝난다는 점입니다. 더 나쁜 경우는 순환은 없지만 분기가 넓어 수만 번 호출되며 응답이 사실상 멈추는 상황인데, 원인을 찾기가 훨씬 어렵습니다.
해결의 핵심은 단순합니다. 이미 방문한 문서를 다시 따라가지 않는 것입니다. 해시 테이블에 방문 이력을 담고 진입 시점에 확인하면 순환은 즉시 차단됩니다.
함정 2 — 노드마다 SELECT를 날리는 N+1 조회
두 번째 함정은 성능입니다. 재귀 구현은 구조상 노드 하나당 SELECT 한 번을 발생시킵니다. 수주 하나가 30개 라인이고 각 라인이 납품과 청구로 이어지면 노드는 금세 100개를 넘고, 데이터베이스 왕복도 그만큼 발생합니다. 개별 쿼리는 밀리초 단위라 ST05 트레이스에서 눈에 잘 띄지 않지만, 합치면 응답 시간의 대부분을 차지합니다.
올바른 접근은 재귀 호출을 없애고 레벨 단위로 묶어서 조회하는 것입니다. 현재 레벨의 문서 번호를 전부 모아 한 번에 조회하고, 그 결과가 다음 레벨이 됩니다. 이렇게 하면 SELECT 횟수는 노드 수가 아니라 흐름의 깊이만큼으로 줄어듭니다. 수주에서 청구까지는 보통 3~4단계이므로 조회는 서너 번이면 끝납니다.
" 권장 구현 — 레벨 단위 배치 조회 + 방문 집합 + 깊이 제한
METHOD trace_forward.
TYPES: BEGIN OF ty_node,
vbeln TYPE vbfa-vbeln,
posnn TYPE vbfa-posnn,
vbtyp TYPE vbfa-vbtyp_n,
level TYPE i,
END OF ty_node.
DATA: lt_current TYPE SORTED TABLE OF vbeln_von WITH UNIQUE KEY table_line,
lt_visited TYPE HASHED TABLE OF vbeln_von WITH UNIQUE KEY table_line,
lv_level TYPE i VALUE 0.
INSERT iv_docnum INTO TABLE lt_current.
INSERT iv_docnum INTO TABLE lt_visited.
WHILE lt_current IS NOT INITIAL AND lv_level < iv_max_level.
lv_level = lv_level + 1.
SELECT vbelv, posnv, vbeln, posnn, vbtyp_n, rfmng
FROM vbfa
FOR ALL ENTRIES IN @lt_current
WHERE vbelv = @lt_current-table_line
AND vbtyp_n IN @it_doc_category
INTO TABLE @DATA(lt_flow).
CLEAR lt_current.
LOOP AT lt_flow INTO DATA(ls_flow).
" 이미 방문한 문서는 건너뛴다 — 순환 차단 지점
IF NOT line_exists( lt_visited[ table_line = ls_flow-vbeln ] ).
INSERT ls_flow-vbeln INTO TABLE lt_visited.
INSERT ls_flow-vbeln INTO TABLE lt_current.
APPEND VALUE ty_node( vbeln = ls_flow-vbeln
posnn = ls_flow-posnn
vbtyp = ls_flow-vbtyp_n
level = lv_level ) TO rt_nodes.
ENDIF.
ENDLOOP.
ENDWHILE.
ENDMETHOD.
이 구현에는 세 가지 안전장치가 함께 들어 있습니다. 방문 집합이 순환을 막고, 레벨 반복문이 조회 횟수를 깊이로 제한하며, iv_max_level이 예상치 못한 깊이 폭주를 잘라냅니다. 세 가지 중 하나라도 빠지면 운영에서 문제가 재현됩니다.
함정 3 — 문서 카테고리를 필터링하지 않는 것
세 번째 함정은 조용히 결과를 오염시킵니다. VBFA에는 우리가 관심 있는 정방향 흐름만 들어 있는 것이 아닙니다. 견적, 계약, 반품, 취소, 대변/차변 메모, 심지어 무상 공급 문서까지 모두 같은 테이블에 연결됩니다. 조건 없이 전부 따라가면 "이 수주의 청구 현황"을 묻는 리포트에 반품과 취소 문서가 섞여 들어옵니다.
그래서 VBTYP_N 필터는 선택이 아니라 필수입니다. 자주 쓰는 값은 다음과 같습니다.
- C — 수주(Order). 흐름의 출발점인 경우가 많습니다.
- J — 납품(Delivery). 출고 진행 여부 판단에 사용합니다.
- R — 자재이동(Goods Movement). 출고전기 완료 여부를 확인합니다.
- M — 청구(Invoice). 매출 인식 시점 확인에 사용합니다.
- H — 반품(Returns). 정방향 집계에서는 보통 제외합니다.
- O / P — 대변 메모 / 차변 메모. 정산 분석에서만 포함합니다.
리포트 목적에 따라 포함할 카테고리를 명시적으로 정하고, 이를 SELECT 조건에 넣어야 결과가 안정됩니다. 위 코드에서 it_doc_category를 레인지로 받은 이유가 여기에 있습니다.
역방향 추적은 조건만 뒤집으면 되는가
"이 청구서의 원 수주 찾기" 같은 역방향 추적은 WHERE vbeln = ...으로 조건만 바꾸면 될 것 같지만, 한 가지 차이가 있습니다. 역방향은 합쳐지는 방향이라 선행 문서가 여러 개일 수 있습니다. 여러 수주를 묶어 한 번에 납품한 경우가 대표적입니다. 따라서 결과를 단일 값으로 가정하고 SELECT SINGLE로 받으면 임의의 한 건만 잡히고 나머지는 조용히 사라집니다.
" 역방향 — 선행이 여러 건일 수 있으므로 테이블로 받는다
SELECT vbelv, posnv, vbtyp_v, rfmng
FROM vbfa
FOR ALL ENTRIES IN @lt_current
WHERE vbeln = @lt_current-table_line
AND vbtyp_v IN @it_source_category
INTO TABLE @DATA(lt_source).
정방향과 역방향 로직을 하나의 메서드에 플래그로 합치는 것보다, 방향별로 메서드를 나누고 방문 집합과 깊이 제한만 공통 유틸로 빼는 편이 유지보수에 유리합니다. 두 방향은 필터 기준과 결과 해석이 서로 다르기 때문입니다.
S/4HANA에서는 표준 뷰를 먼저 검토한다
직접 구현에 들어가기 전에 표준 VDM으로 해결되는지 확인하는 편이 좋습니다. S/4HANA는 판매 문서흐름을 노출하는 CDS 뷰를 제공하며, 단순히 "직전 선행" 또는 "직후 후속"만 필요한 경우라면 뷰의 Association을 따라가는 것으로 충분합니다. 이 경우 순환이나 깊이 문제를 직접 다루지 않아도 됩니다.
반면 다음과 같은 요건에서는 여전히 직접 구현이 필요합니다.
- 임의 깊이까지 전체 계보를 한 번에 펼쳐야 하는 경우
- 레벨별로 수량을 누적해 부분 납품 잔량을 계산해야 하는 경우
- 특정 카테고리만 선택적으로 따라가는 커스텀 규칙이 있는 경우
즉 판단 기준은 "몇 단계를 보느냐"입니다. 한두 단계면 표준 뷰, 전체 계보면 위에서 다룬 레벨 배치 방식이 적합합니다.
수량을 누적할 때 생기는 별도의 함정
문서흐름을 추적하는 목적이 단순히 "어떤 문서가 이어졌는가"가 아니라 "얼마가 아직 납품되지 않았는가"인 경우가 많습니다. 이때 VBFA의 RFMNG(참조 수량)를 더하게 되는데, 여기서 두 가지를 놓치기 쉽습니다.
첫째, 단위가 행마다 다를 수 있습니다. 수주는 박스 단위인데 납품은 낱개 단위로 처리되는 경우 MEINS가 서로 다른 값으로 저장됩니다. 이를 확인하지 않고 숫자만 더하면 잔량이 실제와 크게 어긋납니다. 기준 단위로 환산한 뒤 누적해야 하며, 환산 계수를 직접 나눗셈으로 계산하지 말고 표준 변환을 사용해야 반올림 오차가 쌓이지 않습니다.
둘째, 취소된 연결도 행으로 남아 있습니다. 납품을 생성했다가 취소하면 원래 행이 사라지는 것이 아니라 반대 부호의 연결이 추가되는 형태로 정리됩니다. 따라서 취소 이력이 있는 문서에서 RFMNG를 단순 합산하면 이미 취소된 수량까지 납품된 것으로 잡힙니다.
" 잔량 계산 — 단위 확인 후 누적
LOOP AT lt_flow INTO DATA(ls_flow).
IF ls_flow-meins <> lv_base_unit.
" 기준 단위로 환산 후 누적 (직접 나눗셈 금지)
CALL FUNCTION 'UNIT_CONVERSION_SIMPLE'
EXPORTING input = ls_flow-rfmng
unit_in = ls_flow-meins
unit_out = lv_base_unit
IMPORTING output = DATA(lv_qty).
ELSE.
lv_qty = ls_flow-rfmng.
ENDIF.
lv_delivered = lv_delivered + lv_qty.
ENDLOOP.
lv_open = lv_order_qty - lv_delivered.
잔량 계산 로직은 사용자가 숫자를 직접 눈으로 검증하는 영역이라 오류가 곧바로 신뢰 문제로 이어집니다. 부분 납품과 취소가 모두 포함된 문서를 테스트 케이스로 반드시 확보해 두는 것이 좋습니다.
운영 반영 전 점검 목록
문서흐름 추적 코드를 리뷰할 때 다음 항목을 확인하면 앞서 다룬 세 가지 함정을 대부분 걸러낼 수 있습니다.
- 방문한 문서를 담는 해시 테이블이 있고, 진입 시점에 확인하는가
- 재귀 호출 대신 레벨 단위 반복으로 조회 횟수가 깊이에 비례하는가
- 최대 깊이 상한이 있고, 상한 도달 시 로그를 남기는가
- VBTYP 필터가 명시돼 있고, 반품·취소 포함 여부가 요건과 일치하는가
- 역방향 조회에서 선행이 여러 건인 경우를 테이블로 받는가
- 반품 이력이 있는 실제 문서로 테스트했는가 — 깨끗한 신규 문서만으로는 순환이 재현되지 않는다
마지막 항목이 특히 중요합니다. 이 유형의 장애는 개발·품질 시스템에서는 거의 재현되지 않고, 이력이 누적된 운영에서 처음 나타납니다. 반품이나 취소가 얽힌 문서 번호를 미리 확보해 테스트 케이스로 고정해 두면 재발을 막을 수 있습니다.
댓글 0
아직 댓글이 없습니다.