CAP for Node

cds.persistence.skip 언제 써야 할까? #shorts #SAP #CAPforNode

▶ YouTube에서 보기

이 글이 답하는 질문

CAP(Cloud Application Programming Model) 프로젝트를 운영하다 보면, DB 테이블로 만들면 안 되는 엔티티가 반드시 생깁니다. 외부 시스템에서 가져오는 데이터, 커스텀 핸들러로만 서빙하는 가상 엔티티가 대표적입니다. 이 글은 @cds.persistence.skip 어노테이션을 중심으로 다음 질문에 답합니다.

  • @cds.persistence.skip은 정확히 어느 시점(컴파일 vs 런타임)에 무엇을 막는가?
  • skip: trueskip: 'if-unused'는 언제 갈라 써야 하는가?
  • @cds.persistence.exists와 헷갈리면 어떤 사고가 나는가?
  • skip된 엔티티를 커스텀 핸들러로 서빙할 때 실무에서 자주 놓치는 설정 포인트는 무엇인가?

대상 환경은 @sap/cds 8.x(Node.js 20 기준)이며, 로컬은 @cap-js/sqlite, 운영은 SAP HANA Cloud 배포를 가정합니다. 난이도는 중급으로, CDS 모델링과 서비스 핸들러 기본기를 안다는 전제에서 진행합니다.

이 글을 보기 전에

  • CDS로 entity/service를 정의하고 cds watch로 띄워본 경험
  • srv.on / srv.before / srv.after 커스텀 핸들러의 기본 구조 이해
  • cds compile --to sql 같은 CLI 사용 경험이 있으면 이해가 훨씬 빠릅니다

왜 persistence.skip이 필요한가 — 배경

CAP의 기본 동작은 단순합니다. 모델에 정의된 모든 entity는 기본적으로 DB 테이블(또는 뷰)로 배포됩니다. cds deploy를 실행하면 컴파일러가 CSN 모델을 훑어 DDL을 생성하고, SQLite든 HANA든 실제 스키마를 만듭니다.

문제는 모든 엔티티가 "우리 DB에 살아야 하는 데이터"가 아니라는 점입니다. 비유하자면, 사무실 명부에 이름이 올라 있다고 해서 전원에게 책상을 배정할 필요는 없습니다. 외주 인력은 명부(모델)에는 있어야 하지만 책상(테이블)은 필요 없죠. @cds.persistence.skip이 바로 "명부에는 올리되 책상은 빼라"는 지시입니다. 실무에서 이 어노테이션이 등장하는 전형적인 자리는 다음과 같습니다.

  • 외부 시스템 연동 엔티티cds import로 외부 OData 서비스를 가져오면, 생성된 엔티티에 @cds.persistence.skip이 자동으로 붙습니다. 원본 데이터는 상대 시스템에 있으므로 우리 DB에 테이블을 만들면 안 되기 때문입니다.
  • 계산/집계 전용 가상 엔티티 — 커스텀 핸들러가 즉석에서 만들어 돌려주는 결과 집합.
  • 재사용 모델의 코드리스트 — 패키지로 배포하는 공용 모델에서, 소비 프로젝트가 실제로 쓸 때만 테이블이 생기길 원하는 경우('if-unused').

동작 원리 — 컴파일 타임에 지우고, 런타임엔 남는다

핵심은 두 문장으로 정리됩니다.

@cds.persistence.skipDDL 생성 단계에서 해당 엔티티를 제외한다. 그러나 서비스 모델(CSN)에는 그대로 남아 OData 메타데이터에 노출되고 요청도 들어온다.

즉 "테이블은 없는데 엔드포인트는 있는" 상태가 만들어집니다. 요청이 들어왔을 때 커스텀 핸들러가 없으면 런타임이 이를 DB로 위임하려다 실패합니다(일반적으로 로컬 SQLite에선 no such table 계열 오류, 외부 서비스 엔티티는 구현 없음 오류). 그래서 skip 어노테이션과 커스텀 핸들러는 사실상 세트입니다.

옵션 값에 따라 동작이 갈립니다.

어노테이션DDL 생성용도
@cds.persistence.skip (= true)무조건 제외외부 연동, 가상 엔티티
@cds.persistence.skip: 'if-unused'다른 영속 아티팩트(뷰 등)가 참조하지 않으면 제외재사용 모델의 선택적 코드리스트
@cds.persistence.existsDDL은 생성 안 하지만 런타임은 DB 객체가 이미 있다고 가정HANA 네이티브 테이블/뷰 매핑

'if-unused'는 "누가 나를 뷰에서 select하면 그때만 책상을 놔라"는 조건부 지시입니다. 반면 exists는 skip과 자주 혼동되는데, 의미가 정반대에 가깝습니다. skip은 "DB에 없으니 건드리지 마라", exists는 "DB에 이미 있으니 만들지만 마라"입니다. 이 차이를 놓치면 뒤에서 볼 사고가 납니다.

직접 해보기 — 설비 수리 포털 시나리오

사내 설비 수리 요청을 관리하는 서비스를 만든다고 하겠습니다. 수리 요청(FacilityRepairRequests)은 우리 DB에 저장하지만, 부품 재고(SparePartStockRemote)는 외부 자재 시스템이 원본입니다.

1단계 — 기본 모델과 skip 적용

// db/schema.cds
namespace plant.maintenance;
using { cuid, managed } from '@sap/cds/common';

entity FacilityRepairRequests : cuid, managed {
  facilityCode : String(10);
  symptom      : String(500);
  urgency      : Integer enum { low = 1; normal = 2; critical = 3; };
  requiredPart : String(20);
}

// 원본이 외부 자재 시스템에 있는 재고 엔티티 — 테이블 생성 금지
@cds.persistence.skip
entity SparePartStockRemote {
  key partNo   : String(20);
  warehouse    : String(4);
  onHandQty    : Integer;
}

// 재사용 모델용 위험등급 코드리스트 — 참조될 때만 테이블 생성
@cds.persistence.skip: 'if-unused'
entity HazardGrades {
  key code : String(2);
  descr    : localized String(60);
}

정말 테이블이 빠지는지 눈으로 확인하는 습관이 중요합니다.

-- cds compile db/schema.cds --to sql 결과 (요지)
CREATE TABLE plant_maintenance_FacilityRepairRequests ( ... );
-- SparePartStockRemote 에 대한 CREATE 문은 존재하지 않음
-- HazardGrades 도 아무 뷰가 참조하지 않으므로 생성되지 않음

2단계 — skip 엔티티를 커스텀 핸들러로 서빙 (에러/로깅 포함)

skip만 붙이고 끝내면 READ 요청이 그대로 실패합니다. 외부 API를 호출해 결과를 채워주는 핸들러를 답니다.

// srv/repair-service.js
const cds = require('@sap/cds')
const LOG = cds.log('spare-stock')

module.exports = class RepairService extends cds.ApplicationService {
  async init () {
    const { SparePartStockRemote } = this.entities

    this.on('READ', SparePartStockRemote, async (req) => {
      try {
        const stockApi = await cds.connect.to('MaterialStockRemoteApi')
        // 요청의 쿼리를 그대로 전달 — $filter, $top 등이 유지됨
        return await stockApi.run(req.query)
      } catch (e) {
        LOG.error('외부 재고 조회 실패', { cause: e.message })
        // 외부 장애를 502로 변환해 호출자에게 원인을 구분해 줌
        return req.reject(502, '자재 시스템 응답 없음 — 잠시 후 다시 시도하세요')
      }
    })

    // 수리 요청 생성 시 재고를 교차 확인
    this.before('CREATE', 'FacilityRepairRequests', async (req) => {
      const { requiredPart } = req.data
      if (!requiredPart) return
      const [stock] = await this.read(SparePartStockRemote)
        .where({ partNo: requiredPart })
      if (stock && stock.onHandQty === 0)
        req.warn(`부품 ${requiredPart} 재고 없음 — 조달 지연 가능`)
    })

    return super.init()
  }
}

포인트는 두 가지입니다. 첫째, req.query를 그대로 위임하면 OData 쿼리 옵션이 외부 호출까지 살아서 전달됩니다. 둘째, skip 엔티티를 내부 로직(this.read(...))에서 읽어도 결국 같은 on-핸들러를 타므로, 핸들러 하나로 외부/내부 소비가 통일됩니다.

3단계 — 운영 배포 관점 (성능/테스트/보안)

개발 단계에서는 외부 시스템 없이 돌아가야 합니다. cds watch의 mock 기능을 쓰면, 필요 서비스가 mock으로 기동될 때 해당 엔티티에 임시 인메모리 저장소가 제공되어 skip이 사실상 무력화됩니다. 프로파일로 갈라 두는 것이 일반적입니다.

// package.json (발췌)
{
  "cds": {
    "requires": {
      "MaterialStockRemoteApi": {
        "kind": "odata-v4",
        "model": "srv/external/MaterialStockRemoteApi",
        "[production]": { "credentials": { "destination": "MAT-STOCK-PRD" } }
      },
      "db": { "[development]": { "kind": "sqlite" }, "[production]": { "kind": "hana" } }
    }
  }
}
// test/spare-stock.test.js — 핸들러 단위 테스트
const cds = require('@sap/cds')
const { GET, expect } = cds.test(__dirname + '/..')

it('재고 조회가 partNo 필터를 외부로 위임한다', async () => {
  const { data } = await GET(
    '/odata/v4/repair/SparePartStockRemote?$filter=partNo eq \'VLV-8821\'')
  expect(data.value.every(r => r.partNo === 'VLV-8821')).to.be.true
})

운영 관점 체크리스트: 외부 호출에는 타임아웃과 재시도 상한을 두고, 자주 읽히는 재고는 짧은 TTL 캐시를 검토하세요. 보안 측면에서는 skip 엔티티도 @requires/@restrict 대상이므로 영속 엔티티와 동일하게 권한 어노테이션을 붙이는 것이 권장됩니다. 테이블이 없다고 인가 대상이 아닌 것은 아닙니다.

실무에서 자주 놓치는 설정 포인트

  • exists를 써야 할 자리에 skip을 쓴 경우 — HANA에 이미 존재하는 네이티브 뷰를 매핑하려고 skip을 붙이면, 런타임이 쿼리를 위임할 대상 자체를 잃습니다. 이때는 @cds.persistence.exists가 맞습니다. 반대로 외부 OData 엔티티에 exists를 붙이면 배포는 조용히 넘어가고 런타임에서 없는 테이블을 조회하다 터집니다. 조용히 넘어가는 쪽이 더 위험합니다.
  • skip 엔티티 위에 영속 뷰를 얹는 실수 — 테이블이 없는 엔티티를 select하는 DB 뷰는 배포 시점에 깨집니다. skip 엔티티에서 파생되는 projection은 그 projection에도 skip이 전파되는지 cds compile --to sql로 반드시 확인하세요.
  • mock에 익숙해진 채 운영 배포 — 로컬에서 mock이 테이블을 만들어주니 잘 돌다가, production 프로파일에서 destination 설정 누락으로 첫 요청부터 실패하는 패턴이 흔합니다. hybrid 프로파일로 실제 연동을 최소 한 번은 태워보는 것이 권장됩니다.
  • 'if-unused'의 뒤늦은 발동 — 공용 코드리스트에 'if-unused'를 붙여 배포했는데, 나중에 누군가 그 엔티티를 참조하는 뷰를 추가하면 그때부터 테이블이 생성 대상이 됩니다. 스키마 diff에 갑자기 새 테이블이 나타나면 이 어노테이션부터 의심하세요.
  • draft와의 조합 — 그리는 순간 draft 테이블이 필요해지므로, skip 엔티티에 @odata.draft.enabled를 붙이는 구성은 성립하지 않습니다.

삽질 노트

Q1. skip을 붙였는데 로컬에서 여전히 테이블이 생깁니다.

해당 외부 서비스가 mock으로 기동 중일 가능성이 큽니다. cds watch 기동 로그에서 "mocked" 표시를 확인하세요. mock 모드의 인메모리 테이블은 의도된 동작입니다.

Q2. skip 엔티티 READ가 501/500으로 실패합니다.

커스텀 핸들러 미등록이 원인 1순위입니다. on-핸들러가 있는데도 실패한다면 핸들러가 값을 return하는지, 엔티티 참조를 this.entities에서 가져왔는지(네임스페이스 불일치) 확인하세요.

Q3. HANA 배포에서 "invalid object name" 오류가 납니다.

skip 엔티티를 참조하는 영속 뷰가 남아 있거나, exists로 선언한 객체가 실제 스키마에 없는 경우입니다. cds build 산출물(gen/db)에서 hdbtable/hdbview 목록을 열어 실제 생성 대상을 대조하는 것이 가장 빠릅니다.

Q4. 'if-unused'와 true 중 뭘 기본으로 잡아야 하나요?

앱 프로젝트라면 의도가 명확한 true, 여러 프로젝트가 소비하는 재사용 패키지라면 'if-unused'가 일반적입니다.

핵심 한 줄

@cds.persistence.skip은 "모델에는 남기고 테이블만 지운다" — 그래서 커스텀 핸들러(또는 mock)와 반드시 짝이며, "이미 있는 DB 객체"라면 skip이 아니라 exists가 정답이다.

더 파볼 주제

  • @cds.persistence.exists + HANA 네이티브 아티팩트(hdbview) 매핑 패턴
  • cds import로 외부 서비스 모델을 가져올 때의 프로젝션 축소 전략
  • skip 엔티티 서빙 성능을 위한 캐싱과 resilience 조합
  • 재사용 CDS 패키지 설계 시 코드리스트 배포 전략

댓글 0

아직 댓글이 없습니다.