RAP

RAP 파일 첨부 Stream 없이 구현 가능? — 3단계 #shorts #SAP #RAP

개요 및 이 글에서 다룰 것

ABAP RESTful Application Programming Model(RAP)에서 비즈니스 오브젝트(BO)에 파일 첨부 기능을 얹는 작업은 겉으로 보면 단순해 보이지만, Stream 어노테이션과 content type 처리를 놓치면 Fiori Elements 화면에서 다운로드 버튼이 사라지거나, 업로드는 되지만 MIME 타입이 application/octet-stream으로 고정되어 브라우저가 PDF를 열지 못하는 문제가 반복된다. 이 글은 판매 견적서(SalesQuote) BO에 견적서 PDF를 첨부하는 시나리오를 통해 CDS Entity 설계, Behavior Definition, Fiori 노출까지 3단계로 정리한다.

  • RAP Managed BO에 미디어 리소스(Stream) 필드를 안전하게 추가한다
  • @Semantics.largeObject와 mimeType 속성 연결 규칙을 이해한다
  • Fiori Elements List Report에서 첨부파일 업로드/다운로드 UX를 노출한다
  • 파일 크기 제한, 바이러스 스캔 훅, 트랜잭션 롤백 케이스를 처리한다

이 글을 읽기 전에 알고 있으면 좋은 것

이 글은 RAP Managed 시나리오 구성 경험, CDS View Entity 및 Behavior Definition(BDEF) 문법, SADL 기반 OData V4 서비스 노출 절차를 이미 알고 있다고 가정한다. 또한 SAP GUI의 GOS(Generic Object Services) 첨부 모델이 왜 SAP S/4HANA Cloud 환경에서 대체되었는지, 그리고 ABAP Cloud의 릴리스 컨트랙트(Released API)에서 SO_DOCUMENT_SEND_API1 같은 클래식 API가 왜 사용 불가한지에 대한 배경 지식이 있으면 이해가 빠르다.

실습 환경 및 준비물

이 글의 코드는 다음 환경을 기준으로 검증했다. 온프레미스 스택과 Cloud 스택은 @Semantics.largeObject의 지원 형태에 미묘한 차이가 있으므로 자신의 릴리스를 확인해야 한다.

  • SAP BTP ABAP Environment 2026 릴리스 또는 SAP S/4HANA 2023 FPS02 이상
  • ABAP Development Tools(ADT) for Eclipse 2026-06 버전 이상 권장
  • Fiori Elements Preview(Feature Toggle: ui-fe-preview) 활성화
  • Draft 활성 RAP BO 1개(예: ZR_SALESQUOTE) 사전 준비
  • 업로드 테스트용 파일: 100KB 미만 PDF, 5MB 이상 대용량 케이스 별도
일반적으로 온프레미스에서는 RAW 타입 필드에 바이너리를 저장하지만, Cloud 환경에서는 별도의 스트림 저장 테이블을 분리 설계하는 것이 권장된다.

Stream 어노테이션의 동작 원리

RAP에서 파일 첨부는 "필드 하나가 파일이 된다"가 아니라 "엔티티에 미디어 세만틱을 부여한다"라는 개념으로 접근해야 한다. OData V4 규격상 엔티티는 media entity가 될 수 있으며, 이 엔티티의 $value URL이 바이너리 스트림을 반환한다. RAP는 이 규격을 CDS 어노테이션 3종으로 매핑한다.

  • @Semantics.largeObject — 해당 필드가 바이너리 대용량 오브젝트임을 선언하고, MIME 타입 소스, 파일명 소스, 최대 크기를 함께 지정
  • @Semantics.mimeType: true — content type 문자열을 담는 문자 필드에 부여
  • @Semantics.fileName: true — 다운로드 시 브라우저에 노출될 원본 파일명 필드에 부여

비유하자면 Stream 필드는 편지 봉투 안의 종이, mimeType은 봉투 겉면의 "PDF" 스티커, fileName은 수신자에게 보여줄 봉투의 라벨과 같다. 세 개가 모두 붙어야 Fiori Elements가 "이건 다운로드 가능한 첨부다"라고 인식한다. 하나라도 빠지면 필드는 CDS에는 존재하지만 UI에는 나타나지 않거나, 나타나더라도 다운로드 시 파일명이 GUID로 저장되는 사고가 발생한다.

내부적으로는 게이트웨이 레이어가 GET /SalesQuote(1)/quoteDocument/$value 요청을 받으면, Behavior Implementation의 READ 오퍼레이션이 quoteDocument, quoteMimeType, quoteFileName 세 필드를 함께 조회한 뒤 HTTP 응답의 Content-Type과 Content-Disposition 헤더를 각각 quoteMimeTypequoteFileName에서 채워준다.

실전 예제 1단계 — 기본 CDS 엔티티 구성

먼저 판매 견적서 첨부를 저장할 DDIC 테이블을 정의한다. 실무에서는 원본 BO와 첨부 테이블을 1:N으로 분리하는 것이 확장성에 유리하지만, 이 글에서는 개념 파악을 위해 1:1 통합 저장 방식으로 시작한다.

@EndUserText.label : 'Sales Quote Header with Attachment'
@AbapCatalog.enhancement.category : #EXTENSIBLE_CHARACTER
define table zsalesquote_hdr {
  key client         : abap.clnt not null;
  key quote_uuid     : sysuuid_x16 not null;
  quote_number       : abap.char(10);
  customer_id        : abap.char(10);
  total_amount       : abap.curr(15,2);
  currency_code      : abap.cuky;
  quote_document     : abap.rawstring;
  quote_mime_type    : abap.char(128);
  quote_file_name    : abap.char(255);
  created_by         : abap.syuname;
  created_at         : timestampl;
  last_changed_at    : timestampl;
}

다음으로 CDS View Entity에 세만틱 어노테이션을 부여한다. 여기서 흔히 놓치는 지점이 contentType 파라미터의 지정 방식이다.

@AccessControl.authorizationCheck: #CHECK
@EndUserText.label: 'Sales Quote Root Entity'
define root view entity ZR_SalesQuote
  as select from zsalesquote_hdr
{
  key quote_uuid                              as QuoteUUID,
      quote_number                            as QuoteNumber,
      customer_id                             as CustomerId,
      total_amount                            as TotalAmount,
      currency_code                           as CurrencyCode,

      @Semantics.largeObject: {
        mimeType: 'QuoteMimeType',
        fileName: 'QuoteFileName',
        contentDispositionPreference: #ATTACHMENT,
        acceptableMimeTypes: [ 'application/pdf', 'image/png' ]
      }
      quote_document                          as QuoteDocument,

      @Semantics.mimeType: true
      quote_mime_type                         as QuoteMimeType,

      @Semantics.fileName: true
      quote_file_name                         as QuoteFileName,

      @Semantics.user.createdBy: true
      created_by                              as CreatedBy,
      @Semantics.systemDateTime.createdAt: true
      created_at                              as CreatedAt,
      @Semantics.systemDateTime.lastChangedAt: true
      last_changed_at                         as LastChangedAt
}

실전 예제 2단계 — Behavior 정의와 업로드 검증 로직

Behavior Definition에서는 첨부 필드가 자동으로 매핑되도록 mapping for zsalesquote_hdr를 명시하고, 파일 크기·MIME 검증을 위한 결정(Determination)을 추가한다. Managed 시나리오 기준 코드는 다음과 같다.

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

define behavior for ZR_SalesQuote alias SalesQuote
persistent table zsalesquote_hdr
lock master
authorization master ( instance )
etag master LastChangedAt
{
  field ( readonly, numbering : managed ) QuoteUUID;
  field ( mandatory ) QuoteNumber, CustomerId;

  create;
  update;
  delete;

  determination validateAttachment on modify { field QuoteDocument; }

  mapping for zsalesquote_hdr
  {
    QuoteUUID       = quote_uuid;
    QuoteNumber     = quote_number;
    CustomerId      = customer_id;
    TotalAmount     = total_amount;
    CurrencyCode    = currency_code;
    QuoteDocument   = quote_document;
    QuoteMimeType   = quote_mime_type;
    QuoteFileName   = quote_file_name;
    CreatedBy       = created_by;
    CreatedAt       = created_at;
    LastChangedAt   = last_changed_at;
  }
}

Behavior Implementation 클래스에 결정 메서드를 작성해 업로드된 파일의 크기와 MIME 타입을 재검증한다. 클라이언트가 acceptable MIME을 우회해 application/x-msdownload를 보내는 케이스를 서버에서 다시 막아야 하기 때문이다.

METHOD validateAttachment.
  DATA(max_bytes) = 10 * 1024 * 1024. " 10 MB

  READ ENTITIES OF zr_salesquote IN LOCAL MODE
    ENTITY SalesQuote
      FIELDS ( QuoteDocument QuoteMimeType QuoteFileName )
      WITH CORRESPONDING #( keys )
    RESULT DATA(quotes).

  LOOP AT quotes ASSIGNING FIELD-SYMBOL(<quote>).
    IF xstrlen( <quote>-QuoteDocument ) > max_bytes.
      APPEND VALUE #(
        %tky = <quote>-%tky
        %msg = new_message(
          id       = 'ZSALESQUOTE'
          number   = '010'
          severity = if_abap_behv_message=>severity-error
          v1       = |{ xstrlen( <quote>-QuoteDocument ) / 1024 } KB| )
        %element-QuoteDocument = if_abap_behv=>mk-on
      ) TO reported-salesquote.

      APPEND VALUE #( %tky = <quote>-%tky ) TO failed-salesquote.
    ENDIF.

    IF <quote>-QuoteMimeType IS NOT INITIAL
       AND <quote>-QuoteMimeType NP 'application/pdf'
       AND <quote>-QuoteMimeType NP 'image/*'.
      APPEND VALUE #(
        %tky = <quote>-%tky
        %msg = new_message_with_text(
          severity = if_abap_behv_message=>severity-error
          text     = |Unsupported MIME type: { <quote>-QuoteMimeType }| )
      ) TO reported-salesquote.
      APPEND VALUE #( %tky = <quote>-%tky ) TO failed-salesquote.
    ENDIF.
  ENDLOOP.
ENDMETHOD.

실전 예제 3단계 — 프로덕션 확장(감사 로그, 바이러스 스캔, 성능)

실제 운영에서는 첨부 업로드가 감사(Audit) 대상이 되고, 대용량 파일이 트랜잭션 로그를 부풀리며, 악성 파일이 스토리지에 남을 위험이 있다. 아래 코드는 Cloud 환경에서 활용 가능한 파일 시그니처 검사, VSI(Virus Scan Interface) 대체 훅, 감사 로그 적재를 함께 배치한다.

CLASS lhc_salesquote IMPLEMENTATION.
  METHOD save_modified.
    DATA log_entries TYPE STANDARD TABLE OF zaudit_attach.

    LOOP AT update-salesquote ASSIGNING FIELD-SYMBOL(<upd>)
         WHERE %control-QuoteDocument = if_abap_behv=>mk-on.

      " 1) 파일 시그니처 검사 (magic bytes)
      DATA(first_bytes) = <upd>-QuoteDocument+0(4).
      IF first_bytes(4) <> cl_abap_conv_codepage=>create_out( )->convert( '%PDF' )
         AND <upd>-QuoteMimeType = 'application/pdf'.
        RAISE SHORTDUMP TYPE cx_abap_behv_processing_error
          EXPORTING textid = cx_abap_behv_processing_error=>attach_signature_mismatch.
      ENDIF.

      " 2) 바이러스 스캔 프로파일 호출
      TRY.
          DATA(scanner) = cl_vsi=>get_instance_for_vscan_profile(
            profile = '/SAP/ZATTACH_DEFAULT' ).
          scanner->if_vscan_instance~scan_bytes(
            EXPORTING data = <upd>-QuoteDocument
            IMPORTING scanrc = DATA(rc) ).
          IF rc <> 0.
            APPEND VALUE #(
              %tky = <upd>-%tky
              %msg = new_message_with_text(
                severity = if_abap_behv_message=>severity-error
                text     = 'Virus detected in uploaded document' )
            ) TO reported-salesquote.
          ENDIF.
        CATCH cx_vsi INTO DATA(lx_vsi).
          " 스캔 서비스 장애 시 정책상 업로드 차단 vs 통과 결정
      ENDTRY.

      " 3) 감사 로그 적재
      APPEND VALUE #(
        audit_uuid   = cl_system_uuid=>create_uuid_x16_static( )
        quote_uuid   = <upd>-QuoteUUID
        file_size    = xstrlen( <upd>-QuoteDocument )
        mime_type    = <upd>-QuoteMimeType
        uploaded_by  = cl_abap_context_info=>get_user_technical_name( )
        uploaded_at  = cl_abap_context_info=>get_system_date( )
      ) TO log_entries.
    ENDLOOP.

    IF log_entries IS NOT INITIAL.
      INSERT zaudit_attach FROM TABLE @log_entries.
    ENDIF.
  ENDMETHOD.
ENDCLASS.

Service Definition에는 스트림 필드를 명시적으로 노출해야 하며, Fiori Elements Object Page에 첨부 UI를 붙이려면 Metadata Extension에 @UI.lineItem 및 관련 어노테이션을 배치한다.

@Metadata.layer: #CORE
annotate view ZC_SalesQuote with
{
  @UI.hidden: false
  @UI.lineItem: [{ position: 40, label: 'Quote PDF' }]
  @UI.fieldGroup: [{ qualifier: 'Attachment', position: 10 }]
  QuoteDocument;
}

흔한 실수와 트러블슈팅 FAQ

Q1. Fiori 화면에 업로드 버튼이 아예 안 보인다. 대부분 @Semantics.largeObjectmimeType/fileName 파라미터가 참조하는 필드명이 CDS 실제 element 이름과 대소문자까지 정확히 일치하지 않아 발생한다. 어노테이션 파서는 케이스 센시티브하게 element를 찾기 때문에 quoteMimeTypeQuoteMimeType은 다른 것으로 취급된다. 또한 Draft 활성 BO의 경우 Metadata Extension 재활성화를 잊으면 캐시된 어노테이션이 유지된다.

Q2. 업로드는 성공하는데 다운로드 시 파일이 download.bin으로 저장된다. Content-Disposition 헤더에 파일명이 실리지 않는 경우로, @Semantics.fileName: true가 붙은 필드가 저장 시점에 비어 있기 때문이다. Determination에서 파일명이 initial이면 quote_&timestamp&.pdf 형태로 자동 채워주는 fallback 로직을 넣거나, 클라이언트 업로드 시 slug 헤더를 매핑하도록 처리한다.

Q3. GOS 첨부에서 마이그레이션 중인데 기존 SOFFCONT1 데이터를 어떻게 옮기나요? ABAP Cloud에서는 SO_* API가 릴리스되지 않으므로 온프레미스 릴리스 브릿지에서 CDS Custom Entity와 RFC로 데이터를 뽑아 새로운 RAP 스트림 테이블에 INSERT ... FROM TABLE로 적재하는 방식을 일반적으로 사용한다. 이때 SOOD-OBJTP를 MIME 매핑 테이블로 변환하는 작업이 필요하다.

Q4. 10MB 이상 파일 업로드 시 게이트웨이가 408 타임아웃을 반환한다. OData V4의 PUT $value는 기본적으로 스트리밍이지만, RAP Managed의 기본 저장 방식은 in-memory이므로 대용량 시 청크 업로드(Prefer: continue-on-error)와 함께 별도 스트림 리포지토리를 구성해야 한다. 파라미터 icm/HTTP/max_request_size_KB도 함께 확인한다.

확장하며 볼 만한 주제

이 글의 1:1 통합 저장 모델은 첨부가 소수일 때 유효하지만, 견적서 개정 이력, 계약서, 이메일 원본 등 다중 첨부가 필요해지면 Child Entity 기반 Attachment BO 패턴으로 리팩터링해야 한다. 또한 SAP Document Management Service(DMS, BTP CF 서비스)와 연동해 실제 파일 바이트는 오브젝트 스토리지에 두고 RAP에는 링크만 관리하는 하이브리드 패턴, 그리고 Fiori Elements Attachment Facet의 @UI.attachments 어노테이션을 함께 학습하면 실무 완성도가 크게 올라간다. RAP Draft 시나리오와 첨부의 트랜잭션 경계 처리는 별도의 심화 주제로 이어진다.

더 읽어볼 문서

댓글 0

아직 댓글이 없습니다.