SAP OData 서비스로 외부 시스템 인터페이스 구축하기 — 초보자를 위한 완전 정복 가이드

목차

들어가며

ERP 프로젝트를 하다 보면 반드시 마주치는 요구사항이 있습니다.

“외부 시스템에서 SAP에 전표를 자동으로 생성해 주세요.” “우리 웹사이트에서 SAP 자재 재고를 실시간으로 조회하고 싶습니다.”

예전에는 이런 요구를 RFC, IDoc, 파일 전송(FTP)으로 풀었습니다. 하지만 상대방이 Python으로 개발한 물류 시스템이라면? PostgreSQL 프로시저에서 직접 호출해야 한다면? RFC 커넥터를 붙이는 순간 난이도가 급상승합니다.

OData는 이 문제를 HTTP 하나로 해결합니다. 상대방이 어떤 언어를 쓰든 curl 한 줄이면 SAP 데이터를 읽고 쓸 수 있죠.

이 글은 SAP OData를 한 번도 만들어보지 않은 분을 위한 처음부터 끝까지 따라 하는 가이드입니다. 개념 설명은 최소로 줄이고, 실제 화면에서 무엇을 클릭하고 어떤 코드를 작성하는지에 집중하겠습니다.


1. OData가 정확히 뭔가요?

한 문장으로 정리하면 이렇습니다.

OData(Open Data Protocol)는 데이터베이스 테이블을 HTTP URL로 노출하는 표준 규약이다.

여기서 중요한 포인트 세 가지입니다.

① SAP이 만든 게 아닙니다

OData는 Microsoft가 시작해서 OASIS 국제 표준(ISO/IEC 20802)으로 채택된 프로토콜입니다. SAP은 이 표준을 ABAP 스택에 구현했고, 그 구현체 이름이 SAP Gateway(컴포넌트명 SAP_GWFND)입니다.

그래서 “SAP OData”라는 말보다 “SAP Gateway가 제공하는 OData 서비스”가 더 정확한 표현입니다.

② 일반 REST API보다 규칙이 훨씬 엄격합니다

보통의 REST API는 개발자 마음대로 URL을 설계합니다. /api/getVendorList, /api/vendor/search 무엇이든 가능하죠. 하지만 OData는 URL 문법이 표준으로 정해져 있습니다.

GET /sap/opu/odata/sap/ZITG_VENDOR_SRV/VendorSet?$filter=Bukrs eq '1000'&$top=10

$filter, $top, $expand 같은 쿼리 옵션은 전 세계 모든 OData 서비스에서 동일하게 동작합니다. 한 번 배우면 SAP뿐 아니라 Microsoft Dynamics, Salesforce 등에서도 그대로 쓸 수 있습니다.

$metadata가 핵심 차별점입니다

OData 서비스에는 반드시 이 URL이 존재합니다.

GET /sap/opu/odata/sap/ZITG_VENDOR_SRV/$metadata

이걸 호출하면 이 서비스가 어떤 필드를 가지고 있고, 각 필드의 타입이 무엇이며, 어떤 작업이 가능한지를 XML(EDMX)로 알려줍니다. 스웨거(Swagger) 문서가 프로토콜 자체에 내장되어 있다고 보면 됩니다.

이 덕분에 SAP Fiori Elements는 코드 한 줄 없이 화면을 자동 생성할 수 있고, Postman이나 Excel 파워쿼리도 서비스 구조를 자동으로 인식합니다.


2. 전체 아키텍처 한눈에 보기

작업을 시작하기 전에 데이터가 어떤 경로로 흐르는지 이해해 둡시다.

💡 워드프레스 삽입 팁: 아래 SVG는 사용자 정의 HTML 블록에 붙여 넣으세요. 일반 문단 블록에 넣으면 태그가 제거됩니다.

외부 시스템 Python / Java PostgreSQL Proc 웹 / 모바일 Fiori 앱 SAP Gateway (SAP_GWFND) · 인증 / 권한 · URL 파싱 · JSON ↔ ABAP 변환 SAP 백엔드 DPC_EXT 클래스 BAPI / 표준 로직 DB 테이블 (BKPF / BSEG …) HTTP JSON ABAP 내부호출 OData 인터페이스 데이터 흐름 개발자가 작성하는 부분은 오른쪽 두 박스 — SEGW 모델링 + DPC_EXT 구현 Gateway는 표준 프레임워크가 처리 (직접 코딩 불필요)

핵심은 개발자가 직접 작성하는 코드는 백엔드 로직뿐이라는 점입니다. HTTP 파싱, JSON 변환, 인증 처리는 Gateway 프레임워크가 전부 알아서 합니다.


3. 시작 전 준비물

항목내용
시스템 접근SAP 개발 시스템(DEV) 접속 권한
필수 T-codeSEGW, /IWFND/MAINT_SERVICE, /IWFND/GW_CLIENT, /IWFND/ERROR_LOG
권한개발 키(Developer Key), 패키지 생성 권한
테스트 도구Postman 또는 curl
ABAP 지식SELECT 문, 내부 테이블, 클래스 메서드 재정의(Redefine) 수준

사전 점검 — Gateway가 활성화되어 있나요?

트랜잭션 SICF로 들어가서 아래 경로가 초록색인지 확인하세요.

default_host → sap → opu → odata

회색(비활성)이면 우클릭 → 서비스 활성화를 실행합니다. 이게 꺼져 있으면 아무리 서비스를 잘 만들어도 404가 납니다.


4. 어떤 방식으로 개발할까 — V2 vs V4

SAP OData 개발 방식은 크게 두 갈래입니다.

OData V2 (SEGW)OData V4 (RAP)
개발 도구SAP GUI의 SEGWEclipse ADT
구조프로젝트 → 엔티티 → DPC 클래스CDS View → Behavior Definition → Service Binding
난이도낮음 (절차가 명확)중간 (개념 학습 필요)
등록 T-code/IWFND/MAINT_SERVICE/IWBEP/V4_ADMIN + /IWFND/V4_ADMIN
현재 위치레거시 시스템 대부분SAP 공식 권장 (신규 개발)

이 글은 SEGW(V2) 기준으로 진행합니다. 이유는 세 가지입니다.

  1. 국내 대부분의 ECC / S/4HANA 온프레미스 현장이 아직 V2 서비스로 돌아갑니다.
  2. 절차가 눈에 보여서 초보자가 “무슨 일이 일어나는지” 이해하기 쉽습니다.
  3. 외부 시스템 인터페이스(비-UI 목적)에서는 V2로도 충분합니다.

RAP/V4는 글 마지막 부록에서 맛보기로 다루겠습니다.

⚠️ 주의: V4 서비스는 /IWFND/MAINT_SERVICE 화면에 나타나지 않습니다. V4를 만들었는데 목록에 없다고 당황하는 분이 많은데, V4는 /IWFND/V4_ADMIN에서 관리합니다.


5. STEP 1 — 노출할 데이터 준비하기

실습 시나리오는 이렇게 잡겠습니다.

외부 물류 시스템이 SAP의 구매처(Vendor) 정보를 조회하고, 비용 전표를 생성한다.

먼저 테이블 구조를 확인합니다. 실습용으로 커스텀 테이블 ZITGM_VENDOR를 사용한다고 가정하겠습니다.

필드타입설명
MANDTCLNT(3)클라이언트 (키)
LIFNRCHAR(10)구매처 코드 (키)
BUKRSCHAR(4)회사 코드 (키)
NAME1CHAR(35)구매처명
KTOKKCHAR(4)계정 그룹
ZTERMCHAR(4)지급 조건
ERDATDATS(8)생성일

여기서 첫 번째 설계 판단이 필요합니다.

테이블 필드를 그대로 노출하면 안 됩니다. 이유는 이렇습니다.

  • MANDT는 외부에 노출할 필요가 없습니다 (Gateway가 자동 처리).
  • SAP 내부 필드명(LIFNR, KTOKK)은 외부 개발자에게 암호나 다름없습니다.
  • 나중에 테이블 구조가 바뀌면 인터페이스가 통째로 깨집니다.

그래서 인터페이스 전용 구조체를 따로 만드는 것을 권장합니다. 트랜잭션 SE11에서 구조체 ZST_ITG_VENDOR를 생성합니다.

OData 필드명타입매핑
VendorIdCHAR(10)LIFNR
CompanyCodeCHAR(4)BUKRS
VendorNameCHAR(35)NAME1
AccountGroupCHAR(4)KTOKK
PaymentTermCHAR(4)ZTERM
CreatedOnDATS(8)ERDAT

이렇게 해두면 나중에 SAP 내부 필드가 바뀌어도 매핑만 수정하면 되고, 외부 시스템은 아무 영향을 받지 않습니다. 인터페이스 계약(Contract)과 내부 구현을 분리하는 것이 실무의 기본입니다.


6. STEP 2 — SEGW 프로젝트 만들기

트랜잭션 **SEGW**를 실행합니다.

6-1. 프로젝트 생성

  1. 왼쪽 상단 Create Project(하얀 종이 아이콘) 클릭
  2. 입력값:
항목설명
ProjectZITG_VENDOR프로젝트명 (Z로 시작)
DescriptionITG 구매처 인터페이스설명
Project TypeService with SAP Annotations기본값 유지
Package$TMP 또는 실제 패키지이관 필요 시 실제 패키지
  1. 저장하면 왼쪽 트리에 네 개의 폴더가 생깁니다.
ZITG_VENDOR
├── Data Model          ← 여기서 구조를 정의
├── Service Implementation  ← 여기서 로직을 구현
├── Runtime Artifacts   ← 자동 생성됨 (건드리지 않음)
└── Service Maintenance ← 등록 상태 확인

Data Model에서 무엇을 노출할지 정의하고, Service Implementation에서 어떻게 가져올지 구현한다 — 이 두 문장만 기억하면 SEGW의 절반은 이해한 것입니다.


7. STEP 3 — 엔티티 타입 정의하기

7-1. DDIC 구조에서 가져오기 (권장)

가장 빠른 방법은 앞서 만든 구조체를 그대로 임포트하는 것입니다.

  1. Data Model 우클릭 → ImportDDIC Structure
  2. 입력:
    • Entity Type Name: Vendor
    • ABAP Structure: ZST_ITG_VENDOR
  3. Next → 노출할 필드 체크 (전부 선택)
  4. NextKey 컬럼에서 VendorId, CompanyCode 체크
  5. Finish

💡 네이밍 팁: 엔티티 타입은 단수형(Vendor), 엔티티 셋은 복수형+Set(VendorSet)으로 짓는 것이 OData 관례입니다. Fiori나 외부 도구가 이 규칙을 전제로 동작하는 경우가 있습니다.

7-2. 엔티티 셋 생성

엔티티 타입만 만들면 URL로 접근할 수 없습니다. 엔티티 셋이 있어야 컬렉션 조회가 가능합니다.

  1. Data ModelEntity Sets 우클릭 → Create
  2. 입력:
    • Name: VendorSet
    • Entity Type Name: Vendor
  3. 저장

이제 이런 URL이 동작할 준비가 된 것입니다.

/VendorSet              → 전체 목록 (GET_ENTITYSET)
/VendorSet('0000100001') → 단건 조회 (GET_ENTITY)

7-3. 속성 상세 설정

엔티티 타입을 더블클릭하면 속성 목록이 나옵니다. 여기서 체크박스 몇 개를 조정합니다.

체크박스의미설정 권장
Key기본키VendorId, CompanyCode
NullableNULL 허용필수 항목은 해제
Creatable생성 시 입력 가능자동 생성 필드는 해제
Updatable수정 가능키 필드는 해제
Filterable$filter 사용 가능검색 조건 필드만 체크
Sortable$orderby 사용 가능필요한 필드만

Filterable을 무분별하게 켜두면 안 됩니다. 외부에서 인덱스 없는 필드로 필터링하면 풀 스캔이 발생해 시스템 전체가 느려집니다. 실제로 이 때문에 운영 장애가 난 사례가 적지 않습니다.


8. STEP 4 — 런타임 오브젝트 생성

이제 SEGW가 클래스를 자동으로 만들어 줄 차례입니다.

  1. 상단 툴바의 Generate Runtime Objects(빨간 공 아이콘) 클릭
  2. 팝업에 네 개의 오브젝트명이 제안됩니다.
오브젝트이름 예시역할
Model Provider ClassZCL_ITG_VENDOR_MPC메타데이터 정의 (자동 생성, 수정 금지)
Model Provider Ext.ZCL_ITG_VENDOR_MPC_EXT메타데이터 확장 (필요 시 수정)
Data Provider ClassZCL_ITG_VENDOR_DPC데이터 처리 기본 (자동 생성, 수정 금지)
Data Provider Ext.ZCL_ITG_VENDOR_DPC_EXT실제 로직 구현 (여기에 코딩)
Technical Service NameZITG_VENDOR_SRV외부 노출 서비스명
  1. 패키지와 이관 요청(TR)을 지정하고 실행

⚠️ 절대 하면 안 되는 것: _MPC_DPC(EXT 없는 것)를 직접 수정하지 마세요. 모델을 재생성하면 전부 덮어써집니다. 모든 커스텀 코드는 반드시 _EXT 클래스에만 작성합니다.

생성이 끝나면 Service Maintenance 폴더에 시스템 목록이 나타납니다. 이건 다음 단계에서 사용합니다.


9. STEP 5 — 조회(GET) 로직 구현하기

드디어 코딩입니다.

9-1. 메서드 재정의

  1. Service ImplementationVendorSetGetEntitySet (Query) 우클릭
  2. Go to ABAP Workbench 선택
  3. ZCL_ITG_VENDOR_DPC_EXT 클래스가 열립니다
  4. 메서드 VENDORSET_GET_ENTITYSET을 찾아 우클릭 → Redefine

9-2. 기본 조회 구현

abap

METHOD vendorset_get_entityset.

  DATA: lr_bukrs TYPE RANGE OF bukrs,
        lr_lifnr TYPE RANGE OF lifnr.

  " ── 1. $filter 조건 추출 ────────────────────────────
  LOOP AT it_filter_select_options INTO DATA(ls_filter).
    CASE ls_filter-property.
      WHEN 'CompanyCode'.
        lr_bukrs = CORRESPONDING #( ls_filter-select_options ).
      WHEN 'VendorId'.
        lr_lifnr = CORRESPONDING #( ls_filter-select_options ).
    ENDCASE.
  ENDLOOP.

  " ── 2. 필수 조건 검증 ──────────────────────────────
  IF lr_bukrs IS INITIAL.
    DATA(lo_msg) = me->mo_context->get_message_container( ).
    lo_msg->add_message(
      iv_msg_type   = 'E'
      iv_msg_id     = 'ZITG'
      iv_msg_number = '001'      " 회사코드는 필수 조건입니다
    ).
    RAISE EXCEPTION TYPE /iwbep/cx_mgw_busi_exception
      EXPORTING message_container = lo_msg.
  ENDIF.

  " ── 3. 조회 건수 제한 ($top 반영) ──────────────────
  DATA(lv_max) = COND i(
    WHEN is_paging-top > 0 AND is_paging-top <= 1000
    THEN is_paging-top
    ELSE 1000 ).

  " ── 4. 데이터 조회 ────────────────────────────────
  SELECT lifnr AS vendorid,
         bukrs AS companycode,
         name1 AS vendorname,
         ktokk AS accountgroup,
         zterm AS paymentterm,
         erdat AS createdon
    FROM zitgm_vendor
    WHERE bukrs IN @lr_bukrs
      AND lifnr IN @lr_lifnr
    ORDER BY lifnr
    INTO CORRESPONDING FIELDS OF TABLE @et_entityset
    UP TO @lv_max ROWS.

  " ── 5. 전체 건수 반환 ($inlinecount 지원) ──────────
  IF io_tech_request_context->has_inlinecount( ) = abap_true.
    SELECT COUNT(*) FROM zitgm_vendor
      WHERE bukrs IN @lr_bukrs
        AND lifnr IN @lr_lifnr
      INTO @es_response_context-inlinecount.
  ENDIF.

ENDMETHOD.

코드에서 짚고 넘어갈 부분

it_filter_select_options의 정체

OData의 $filter=CompanyCode eq '1000'이 ABAP의 RANGE 테이블(SIGN/OPTION/LOW/HIGH)로 자동 변환되어 들어옵니다. 즉 SELECT-OPTIONS와 완전히 동일한 구조입니다. 기존 ABAP 리포트 개발 경험이 그대로 통하는 지점이죠.

② 조회 건수 제한은 선택이 아니라 필수

외부 시스템이 실수로 조건 없이 호출하면 수백만 건을 SELECT 하려다 시스템이 멈춥니다. UP TO n ROWS는 반드시 넣으세요. 아울러 필수 조건(회사코드 등)을 강제하는 것도 좋은 방어책입니다.

③ 예외는 반드시 메시지 컨테이너로

RAISE EXCEPTION만 던지면 외부에서는 “알 수 없는 오류”만 보게 됩니다. 메시지 컨테이너를 사용하면 HTTP 400 응답 본문에 구체적인 에러 메시지가 담겨 상대 개발자가 원인을 파악할 수 있습니다.

9-3. 단건 조회 구현

VENDORSET_GET_ENTITY도 재정의합니다.

abap

METHOD vendorset_get_entity.

  " 키 값 추출
  io_tech_request_context->get_converted_keys(
    IMPORTING es_key_values = DATA(ls_key) ).

  DATA(ls_input) = CAST zst_itg_vendor( REF #( ls_key ) )->*.

  SELECT SINGLE
         lifnr AS vendorid,
         bukrs AS companycode,
         name1 AS vendorname,
         ktokk AS accountgroup,
         zterm AS paymentterm,
         erdat AS createdon
    FROM zitgm_vendor
    WHERE lifnr = @ls_input-vendorid
      AND bukrs = @ls_input-companycode
    INTO CORRESPONDING FIELDS OF @er_entity.

  IF sy-subrc <> 0.
    RAISE EXCEPTION TYPE /iwbep/cx_mgw_busi_exception
      EXPORTING textid = /iwbep/cx_mgw_busi_exception=>resource_not_found.
  ENDIF.

ENDMETHOD.

데이터가 없을 때 RESOURCE_NOT_FOUND 예외를 던지면 HTTP 404가 반환됩니다. 빈 결과를 200으로 돌려주는 것보다 훨씬 명확합니다.


10. STEP 6 — 서비스 등록 및 활성화

코드를 만들었어도 등록하지 않으면 URL로 접근할 수 없습니다.

방법 A: SEGW에서 바로 등록

  1. SEGW → Service Maintenance 폴더 확장
  2. 해당 시스템(예: LOCAL) 더블클릭
  3. Register 버튼 클릭
  4. 시스템 별칭(System Alias) 선택 → 보통 LOCAL
  5. 패키지 / TR 지정 후 확인

방법 B: /IWFND/MAINT_SERVICE에서 등록

  1. 트랜잭션 /IWFND/MAINT_SERVICE 실행
  2. Add Service 클릭
  3. System Alias에 LOCAL 입력 후 Get Services
  4. 목록에서 ZITG_VENDOR_SRV 선택 → Add Selected Services
  5. 패키지 지정 후 확인

등록 확인

/IWFND/MAINT_SERVICE 목록에서 서비스를 선택하고 하단 ICF Nodes 영역을 봅니다. 상태가 **Active(초록)**여야 합니다. 회색이면 우클릭 → Activate.

이제 이 URL이 살아 있습니다.

http://<host>:<port>/sap/opu/odata/sap/ZITG_VENDOR_SRV/

11. STEP 7 — Gateway Client로 테스트

외부 도구를 붙이기 전에 SAP 내부에서 먼저 검증합니다. 여기서 문제를 잡아야 나중에 “SAP 문제냐 네트워크 문제냐”로 싸우지 않습니다.

트랜잭션 **/IWFND/GW_CLIENT**를 실행합니다.

테스트 1: 메타데이터 확인

Request URI: /sap/opu/odata/sap/ZITG_VENDOR_SRV/$metadata
HTTP Method: GET

Execute(F8)를 누르면 EDMX XML이 나옵니다. 여기에 VendorSet과 모든 속성이 보이면 모델링이 정상입니다.

테스트 2: 데이터 조회

Request URI: /sap/opu/odata/sap/ZITG_VENDOR_SRV/VendorSet?$filter=CompanyCode eq '1000'&$top=5&$format=json
HTTP Method: GET

응답은 이런 형태입니다.

json

{
  "d": {
    "results": [
      {
        "VendorId": "0000100001",
        "CompanyCode": "1000",
        "VendorName": "한국물산㈜",
        "AccountGroup": "KRED",
        "PaymentTerm": "0001",
        "CreatedOn": "/Date(1735689600000)/"
      }
    ]
  }
}

여기서 반드시 알아둘 두 가지 함정

함정 ①: d.results 래핑

OData V2는 실제 데이터를 d.results 안에 감싸서 반환합니다. 외부 개발자가 “배열이 안 나온다”고 하면 십중팔구 이걸 모르는 경우입니다.

  • 컬렉션 조회 → d.results (배열)
  • 단건 조회 → d (객체, results 없음)

참고로 V4는 이 래핑이 사라지고 value 배열로 단순해집니다.

함정 ②: 날짜 형식 /Date(1735689600000)/

숫자는 **1970-01-01 기준 밀리초(epoch)**입니다. Python이라면 이렇게 변환합니다.

python

import re
from datetime import datetime, timezone

def parse_sap_date(s):
    ms = int(re.search(r"/Date\((-?\d+)", s).group(1))
    return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).date()

parse_sap_date("/Date(1735689600000)/")   # → datetime.date(2025, 1, 1)

반대로 SAP에 날짜를 보낼 때도 같은 형식을 써야 합니다. "2025-01-01" 문자열을 보내면 파싱 에러가 납니다.


12. STEP 8 — 생성(POST) 로직 구현하기

조회만으로는 인터페이스라 부르기 어렵습니다. 이제 외부에서 SAP에 데이터를 쓰는 기능을 만듭니다.

12-1. 헤더-아이템 구조 설계

비용 전표는 헤더 1건 + 아이템 N건 구조입니다. OData에서는 이를 **엔티티 2개 + 연관관계(Association)**로 표현합니다.

SEGW에서 추가 작업:

  1. 엔티티 타입 Invoice(헤더), InvoiceItem(아이템) 생성
  2. 엔티티 셋 InvoiceSet, InvoiceItemSet 생성
  3. Data ModelAssociations 우클릭 → Create
항목
Association NameInvoiceToItem
Principal EntityInvoice (Cardinality 1)
Dependent EntityInvoiceItem (Cardinality 1..n)
Navigation PropertyToItem
Association SetInvoiceToItemSet
  1. 다음 화면에서 키 매핑: Invoice.DocumentNoInvoiceItem.DocumentNo

이렇게 하면 Deep Insert(헤더+아이템 한 번에 생성)가 가능해집니다.

12-2. CREATE_ENTITY 구현 (Deep Insert)

Service ImplementationInvoiceSetCreateEntity 재정의:

abap

METHOD invoiceset_create_entity.

  TYPES: BEGIN OF ty_deep,
           documentno   TYPE char10,
           companycode  TYPE bukrs,
           documenttype TYPE blart,
           postingdate  TYPE budat,
           reference    TYPE xblnr,
           toitem       TYPE STANDARD TABLE OF zst_itg_inv_item
                             WITH DEFAULT KEY,
         END OF ty_deep.

  DATA: ls_deep TYPE ty_deep.

  " ── 1. 요청 본문 읽기 (헤더 + 아이템) ──────────────
  io_data_provider->read_entry_data( IMPORTING es_data = ls_deep ).

  " ── 2. 유효성 검증 ────────────────────────────────
  DATA(lo_msg) = me->mo_context->get_message_container( ).
  DATA(lv_error) = abap_false.

  IF ls_deep-companycode IS INITIAL.
    lo_msg->add_message( iv_msg_type = 'E' iv_msg_id = 'ZITG'
                         iv_msg_number = '010' ).   " 회사코드 필수
    lv_error = abap_true.
  ENDIF.

  IF ls_deep-toitem IS INITIAL.
    lo_msg->add_message( iv_msg_type = 'E' iv_msg_id = 'ZITG'
                         iv_msg_number = '011' ).   " 아이템 최소 1건 필요
    lv_error = abap_true.
  ENDIF.

  " 차대변 균형 검증
  DATA(lv_balance) = REDUCE wrbtr(
    INIT s = 0
    FOR ls IN ls_deep-toitem
    NEXT s = COND #( WHEN ls-debitcredit = 'S'
                     THEN s + ls-amount
                     ELSE s - ls-amount ) ).

  IF lv_balance <> 0.
    lo_msg->add_message( iv_msg_type = 'E' iv_msg_id = 'ZITG'
                         iv_msg_number = '012' ).   " 차대변 불일치
    lv_error = abap_true.
  ENDIF.

  IF lv_error = abap_true.
    RAISE EXCEPTION TYPE /iwbep/cx_mgw_busi_exception
      EXPORTING message_container = lo_msg.
  ENDIF.

  " ── 3. BAPI 호출 ──────────────────────────────────
  DATA: ls_header  TYPE bapiache09,
        lt_gl      TYPE TABLE OF bapiacgl09,
        lt_amount  TYPE TABLE OF bapiaccr09,
        lt_return  TYPE TABLE OF bapiret2,
        ls_key     TYPE bapiache09.

  ls_header-comp_code  = ls_deep-companycode.
  ls_header-doc_type   = ls_deep-documenttype.
  ls_header-pstng_date = ls_deep-postingdate.
  ls_header-ref_doc_no = ls_deep-reference.
  ls_header-username   = sy-uname.

  LOOP AT ls_deep-toitem INTO DATA(ls_item).
    APPEND VALUE #( itemno_acc = sy-tabix
                    gl_account = ls_item-glaccount
                    comp_code  = ls_deep-companycode
                    costcenter = ls_item-costcenter
                  ) TO lt_gl.
    APPEND VALUE #( itemno_acc = sy-tabix
                    currency   = ls_item-currency
                    amt_doccur = COND #( WHEN ls_item-debitcredit = 'S'
                                         THEN ls_item-amount
                                         ELSE ls_item-amount * -1 )
                  ) TO lt_amount.
  ENDLOOP.

  CALL FUNCTION 'BAPI_ACC_DOCUMENT_POST'
    EXPORTING documentheader = ls_header
    IMPORTING obj_key        = ls_key-obj_key
    TABLES    accountgl      = lt_gl
              currencyamount = lt_amount
              return         = lt_return.

  " ── 4. BAPI 결과 확인 ─────────────────────────────
  IF line_exists( lt_return[ type = 'E' ] )
  OR line_exists( lt_return[ type = 'A' ] ).

    CALL FUNCTION 'BAPI_TRANSACTION_ROLLBACK'.

    LOOP AT lt_return INTO DATA(ls_ret) WHERE type CA 'EA'.
      lo_msg->add_message(
        iv_msg_type   = ls_ret-type
        iv_msg_id     = ls_ret-id
        iv_msg_number = ls_ret-number
        iv_msg_v1     = ls_ret-message_v1
        iv_msg_v2     = ls_ret-message_v2 ).
    ENDLOOP.

    RAISE EXCEPTION TYPE /iwbep/cx_mgw_busi_exception
      EXPORTING message_container = lo_msg.
  ENDIF.

  CALL FUNCTION 'BAPI_TRANSACTION_COMMIT'
    EXPORTING wait = 'X'.

  " ── 5. 생성 결과 반환 ─────────────────────────────
  er_entity = CORRESPONDING #( ls_deep ).
  er_entity-documentno = ls_key-obj_key(10).

ENDMETHOD.

이 코드의 실무 포인트

① 검증은 BAPI 호출 전에 몰아서

에러가 하나 발견될 때마다 예외를 던지면 외부 개발자는 고치고-호출하고를 반복해야 합니다. 메시지 컨테이너에 모든 에러를 모아서 한 번에 반환하면 훨씬 친절한 인터페이스가 됩니다.

BAPI_TRANSACTION_COMMIT을 잊지 마세요

BAPI는 호출만으로 커밋되지 않습니다. OData 요청이 끝나도 자동 커밋되지 않으므로 명시적으로 호출해야 합니다. 반대로 에러 시에는 반드시 ROLLBACK을 불러 부분 저장을 막습니다.

③ 생성된 키를 응답에 담기

er_entity에 생성된 전표번호를 채워 넣으면 외부 시스템이 HTTP 201 응답 본문에서 바로 받아볼 수 있습니다. 이걸 빼먹으면 상대방이 “전표가 생성됐는지 어떻게 아느냐”고 묻게 됩니다.


13. STEP 9 — 외부에서 호출하기 (CSRF 토큰의 함정)

이제 진짜 인터페이스입니다. 그런데 여기서 90%의 초보자가 막힙니다.

GET은 잘 되는데 POST를 하면 이런 응답이 옵니다.

HTTP/1.1 403 Forbidden
x-csrf-token: Required

CSRF 토큰이 뭔가요?

CSRF(Cross-Site Request Forgery)는 사용자가 로그인한 상태를 악용해 의도하지 않은 요청을 보내게 만드는 공격입니다. SAP Gateway는 이를 막기 위해 데이터를 변경하는 모든 요청(POST/PUT/PATCH/DELETE)에 토큰을 요구합니다.

절차는 이렇습니다.

CSRF 토큰 처리 흐름 외부 시스템 SAP Gateway ① GET /$metadata + X-CSRF-Token: Fetch ② 200 OK + x-csrf-token 헤더 + Set-Cookie ③ POST /InvoiceSet + X-CSRF-Token: (받은값) + Cookie (반드시 함께!) ④ 201 Created + 생성된 전표번호 핵심: 토큰과 쿠키는 한 세트 — 쿠키를 빼먹으면 토큰이 유효해도 403 그래서 Session 객체를 써서 쿠키를 자동 유지해야 합니다

13-1. Python 구현

python

import requests
from requests.auth import HTTPBasicAuth

BASE = "http://sapdev.company.com:8000/sap/opu/odata/sap/ZITG_INVOICE_SRV"
AUTH = HTTPBasicAuth("IF_USER", "password")

# Session 객체가 쿠키를 자동으로 유지해 줍니다 ★핵심★
session = requests.Session()
session.auth = AUTH

# ── 1) 토큰 발급 ────────────────────────────────
resp = session.get(
    f"{BASE}/$metadata",
    headers={"X-CSRF-Token": "Fetch"},
    timeout=30,
)
resp.raise_for_status()
token = resp.headers.get("x-csrf-token")

if not token:
    raise RuntimeError("CSRF 토큰을 받지 못했습니다. 인증 정보를 확인하세요.")

# ── 2) 전표 생성 ────────────────────────────────
payload = {
    "CompanyCode":  "1000",
    "DocumentType": "KR",
    "PostingDate":  "/Date(1756166400000)/",
    "Reference":    "ITG-20260826-001",
    "ToItem": [
        {"ItemNo": "001", "GlAccount": "0000400000",
         "CostCenter": "1000100", "Amount": "1000000",
         "Currency": "KRW", "DebitCredit": "S"},
        {"ItemNo": "002", "GlAccount": "0000200100",
         "Amount": "1000000", "Currency": "KRW", "DebitCredit": "H"},
    ],
}

resp = session.post(
    f"{BASE}/InvoiceSet",
    headers={
        "X-CSRF-Token": token,          # 발급받은 토큰
        "Content-Type": "application/json",
        "Accept":       "application/json",
    },
    json=payload,
    timeout=60,
)

# ── 3) 결과 처리 ────────────────────────────────
if resp.status_code == 201:
    doc_no = resp.json()["d"]["DocumentNo"]
    print(f"✅ 전표 생성 완료: {doc_no}")
else:
    err = resp.json().get("error", {})
    msg = err.get("message", {}).get("value", resp.text)
    print(f"❌ 실패 [{resp.status_code}] {msg}")

13-2. curl로 테스트

bash

# 1) 토큰과 쿠키를 파일에 저장
curl -s -u "IF_USER:password" \
     -c cookies.txt \
     -D headers.txt \
     -H "X-CSRF-Token: Fetch" \
     "http://sapdev:8000/sap/opu/odata/sap/ZITG_INVOICE_SRV/\$metadata" \
     -o /dev/null

TOKEN=$(grep -i "^x-csrf-token" headers.txt | awk '{print $2}' | tr -d '\r')

# 2) 저장한 쿠키와 함께 POST
curl -s -u "IF_USER:password" \
     -b cookies.txt \
     -H "X-CSRF-Token: $TOKEN" \
     -H "Content-Type: application/json" \
     -d @payload.json \
     "http://sapdev:8000/sap/opu/odata/sap/ZITG_INVOICE_SRV/InvoiceSet"

-c(쿠키 저장)와 -b(쿠키 전송)를 세트로 쓰는 것이 포인트입니다.

13-3. 자주 나오는 CSRF 오류 3가지

증상원인해결
403 CSRF token validation failed쿠키를 안 보냄Session 객체 사용 / -b cookies.txt
토큰이 응답 헤더에 없음인증 실패(401을 200으로 오인)사용자/비밀번호 확인
한동안 잘 되다가 갑자기 403토큰 만료 (세션 타임아웃)403 감지 시 토큰 재발급 후 1회 재시도

세 번째는 특히 배치 작업에서 자주 터집니다. 재시도 로직을 넣어두는 것이 안전합니다.

python

def post_with_retry(session, url, payload, token_url, max_retry=1):
    for attempt in range(max_retry + 1):
        token = fetch_token(session, token_url)
        r = session.post(url, headers={"X-CSRF-Token": token,
                                       "Content-Type": "application/json"},
                         json=payload, timeout=60)
        if r.status_code != 403:
            return r
    return r

14. STEP 10 — 여러 건을 한 번에: $batch

전표 100건을 보내야 한다면? POST를 100번 호출하면 네트워크 왕복이 100번 발생하고, 중간에 실패하면 일부만 저장된 상태로 남습니다.

$batch는 여러 요청을 하나로 묶고, Change Set은 그 묶음을 하나의 트랜잭션(LUW)으로 처리합니다.

요청 형식

http

POST /sap/opu/odata/sap/ZITG_INVOICE_SRV/$batch HTTP/1.1
Content-Type: multipart/mixed; boundary=batch_001
X-CSRF-Token: {token}

--batch_001
Content-Type: multipart/mixed; boundary=changeset_001

--changeset_001
Content-Type: application/http
Content-Transfer-Encoding: binary

POST InvoiceSet HTTP/1.1
Content-Type: application/json

{"CompanyCode":"1000","DocumentType":"KR", ... }

--changeset_001
Content-Type: application/http
Content-Transfer-Encoding: binary

POST InvoiceSet HTTP/1.1
Content-Type: application/json

{"CompanyCode":"1000","DocumentType":"KR", ... }

--changeset_001--
--batch_001--

구조 이해하기

$batch (전체 묶음)
├── ChangeSet A  ← 여기 안의 요청들은 "전부 성공 or 전부 롤백"
│   ├── POST 전표1
│   └── POST 전표2
├── ChangeSet B  ← A와는 독립된 트랜잭션
│   └── POST 전표3
└── GET 조회      ← ChangeSet 밖의 GET은 개별 처리

같은 ChangeSet 안에 넣으면 원자성(Atomicity)이 보장됩니다. 전표1이 실패하면 전표2도 롤백됩니다. 반대로 건별로 독립 처리하고 싶다면 ChangeSet을 각각 분리하세요.

ABAP 측 구현

_DPC_EXT에서 두 메서드를 재정의합니다.

abap

METHOD /iwbep/if_mgw_appl_srv_runtime~changeset_begin.
  " 이 서비스는 defer 모드를 지원한다고 선언
  cv_defer_mode = abap_true.
ENDMETHOD.

METHOD /iwbep/if_mgw_appl_srv_runtime~changeset_end.
  " ChangeSet의 모든 요청이 성공했을 때만 호출됨
  CALL FUNCTION 'BAPI_TRANSACTION_COMMIT'
    EXPORTING wait = 'X'.
ENDMETHOD.

cv_defer_mode = abap_true로 설정하면 개별 CREATE_ENTITY에서는 커밋하지 않고, CHANGESET_END에서 한 번에 커밋합니다. 이때 개별 메서드의 BAPI_TRANSACTION_COMMIT 호출은 제거해야 합니다.


15. 트러블슈팅 — 에러 코드별 대응표

HTTP메시지원인확인 위치
401Unauthorized사용자/비밀번호 오류, 계정 잠김SU01에서 계정 상태
403CSRF token required토큰 미첨부 또는 쿠키 누락요청 헤더 확인
403No authorization권한 오브젝트 부족SU53 (실패 직후 실행)
404Resource not found서비스 미등록, ICF 비활성/IWFND/MAINT_SERVICE
400Bad RequestJSON 형식 오류, 필수값 누락응답 본문 메시지
500Internal Server ErrorABAP 덤프ST22
502Bad GatewayRFC 연결 실패 (허브 구성 시)SM59

필수 디버깅 도구

/IWFND/ERROR_LOG — 첫 번째로 볼 곳

Gateway 허브에서 발생한 오류가 기록됩니다. 오류 항목을 더블클릭하면 요청 본문, 응답, 스택 트레이스까지 모두 볼 수 있습니다.

/IWBEP/ERROR_LOG — 백엔드 오류

허브와 백엔드가 분리된 구성(Hub Deployment)이라면 백엔드 오류는 여기에 남습니다. 임베디드 구성이면 /IWFND/ERROR_LOG만 봐도 됩니다. 이 둘을 헷갈려서 원인을 못 찾는 경우가 정말 많습니다.

③ 외부 호출 디버깅 방법

Postman에서 보낸 요청을 ABAP 디버거로 잡으려면:

  1. SE80에서 _DPC_EXT 클래스 열고 브레이크포인트 설정
  2. 반드시 External Breakpoint(외부 중단점)로 설정 — 아이콘이 다릅니다
  3. 상단 메뉴 → 유틸리티 → 설정 → ABAP 편집기 → 디버깅 탭에서 사용자명을 인터페이스 계정(IF_USER)으로 변경
  4. 이제 외부에서 호출하면 디버거가 뜹니다

3번을 빼먹으면 브레이크포인트가 걸리지 않습니다. 외부 시스템은 IF_USER로 접속하는데 브레이크포인트는 내 계정에 걸려 있기 때문입니다.

/IWFND/TRACES — 성능 분석

응답이 느릴 때 어느 구간에서 시간이 걸리는지 측정합니다. Payload Trace를 켜면 실제 주고받은 JSON도 확인할 수 있습니다.


16. 운영 전 체크리스트

인터페이스를 오픈하기 전에 아래 항목을 점검하세요.

보안

  • 인터페이스 전용 계정 생성 (SYSTEM 또는 SERVICE 유형, DIALOG 아님)
  • 최소 권한만 부여 (S_SERVICE + 해당 업무 권한 오브젝트)
  • 운영 환경은 HTTPS 필수 (SMICM에서 포트 확인)
  • 비밀번호 만료 정책 확인 (인터페이스 계정이 갑자기 잠기는 사고 방지)

성능

  • 모든 GET 메서드에 UP TO n ROWS 적용
  • $filter 대상 필드에 인덱스 존재 여부 확인
  • 필수 필터 조건 강제 (회사코드, 기간 등)
  • 대량 처리는 $batch 사용 유도

운영

  • 인터페이스 로그 테이블 설계 (요청/응답/처리시각/결과)
  • 에러 메시지가 외부 개발자에게 이해 가능한 수준인지 검토
  • $metadata URL을 상대 개발자에게 전달 (문서 대신 이걸 주면 됩니다)
  • 타임아웃 값 협의 (기본 60초 — 대량 처리 시 부족할 수 있음)

문서

  • 엔드포인트 URL, 인증 방식, 샘플 요청/응답 정리
  • 에러 코드 체계 정의 및 공유
  • 재처리 절차 (실패 건 재전송 방법)

17. 부록 — RAP/OData V4는 어떻게 다른가

신규 프로젝트라면 RAP을 고려하게 됩니다. SEGW와 비교하면 이런 차이가 있습니다.

개발 흐름

[SEGW / V2]
프로젝트 생성 → 엔티티 정의 → 런타임 생성 → DPC_EXT 코딩 → 등록

[RAP / V4]
CDS View 작성 → Behavior Definition → Behavior Implementation
             → Service Definition → Service Binding → Publish

Service Definition 예시

abap

@EndUserText.label: 'ITG 구매처 서비스'
define service ZITG_VENDOR_SRV {
  expose ZC_ITG_Vendor  as Vendor;
  expose ZC_ITG_Invoice as Invoice;
  expose I_Currency     as Currency;
}

무엇이 좋아지나

  • 코드량 감소: 단순 CRUD는 Behavior Definition에 create; update; delete; 몇 줄이면 끝납니다
  • 응답 경량화: d.results 래핑이 사라지고 value 배열로 단순해집니다
  • 날짜 형식 정상화: /Date(...)/ 대신 ISO 8601(2026-08-26)을 씁니다
  • Fiori 자동 생성: UI 어노테이션만 붙이면 화면이 만들어집니다

주의할 점

  • V4 서비스는 /IWFND/MAINT_SERVICE가 아니라 /IWBEP/V4_ADMIN(백엔드 등록)과 /IWFND/V4_ADMIN(허브 게시)에서 관리합니다
  • ADT에서 Service Binding을 직접 Publish하는 것은 로컬 테스트용입니다. 운영 배포는 /IWFND/V4_ADMIN으로 하는 것이 SAP 권장 방식입니다
  • S/4HANA 2020 FPS1 이상이어야 RAP 기반 V4 개발이 제대로 지원됩니다

마무리

정리하면 SAP OData 인터페이스 구축은 다음 흐름입니다.

  1. 데이터 준비 — 인터페이스 전용 구조체로 내부/외부 분리
  2. SEGW 모델링 — 엔티티 타입 → 엔티티 셋 → 연관관계
  3. 런타임 생성_EXT 클래스에만 코딩
  4. 로직 구현 — GET은 건수 제한, POST는 검증 후 BAPI + 명시적 COMMIT
  5. 서비스 등록/IWFND/MAINT_SERVICE + ICF 활성화
  6. 내부 테스트/IWFND/GW_CLIENT로 먼저 검증
  7. 외부 연동 — CSRF 토큰 + 쿠키를 세트로
  8. 운영 준비 — 로그, 권한, 성능, 문서

특히 초보자가 반드시 기억할 세 가지를 다시 강조하겠습니다.

첫째, _EXT가 붙지 않은 클래스는 절대 수정하지 마세요. 재생성 시 사라집니다. 둘째, CSRF 토큰과 쿠키는 한 세트입니다. 하나만 보내면 403입니다. 셋째, V2의 날짜 형식은 /Date(밀리초)/입니다. 문자열 날짜를 보내면 파싱 에러가 납니다.

다음 글에서는 인터페이스 로그 테이블 설계와 실패 건 재처리 프로세스를 다루겠습니다. 실무에서 인터페이스가 개발보다 어려운 이유는 “실패했을 때 어떻게 복구하느냐”에 있으니까요.

궁금한 점이나 막히는 부분이 있으면 댓글로 남겨주세요.


함께 읽으면 좋은 글

  • SAP CDS View 완전 정복 — 기본 문법부터 어노테이션까지
  • ABAP RAP 입문 — Managed 시나리오로 첫 앱 만들기
  • SAP BAPI 호출 시 COMMIT 처리 완전 정리

참고 자료

  • SAP Help Portal — SAP Gateway Foundation Developer Guide
  • SAP Business Accelerator Hub (api.sap.com) — 표준 API 카탈로그
  • SAP Community — OData 관련 기술 블로그

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤