CAP for Node

CDS Draft 활성화 실수 3가지 #shorts #SAP #CAPforNode

📖 개요: Draft 저장 로직, 왜 자꾸 오해가 생길까

SAP CAP(Cloud Application Programming Model) Node.js 런타임에서 Fiori Elements 앱을 만들 때 가장 자주 마주치는 기능이 바로 Draft(초안)입니다. 어노테이션 한 줄이면 켜지지만, 그 뒤에서 움직이는 저장 메커니즘을 오해하면 "검증이 안 걸린다", "핸들러가 두 번 돈다", "활성화 시점에 데이터가 없다" 같은 문제로 이어집니다. 이 글에서는 실무에서 반복적으로 관찰되는 실수 3가지를 원리부터 해결 코드까지 다룹니다.

  • ✅ Draft 활성화 어노테이션의 올바른 위치와 오배치 사례 구분
  • ✅ 드래프트 엔티티(.drafts)와 활성 엔티티의 이벤트 흐름 차이 이해
  • ✅ PATCH·SAVE·CANCEL 등 드래프트 전용 이벤트에 검증/부수효과를 올바르게 배치
  • ✅ draft-to-active 전환 시 커스텀 핸들러 타이밍 문제 해결

📚 미리 알고 있으면 좋은 내용

CDS 모델링 기본(entity, projection, composition), CAP Node.js 서비스 핸들러(before/on/after) 등록 경험, OData V4와 Fiori Elements List Report/Object Page의 기본 동작을 알고 있다는 전제로 진행합니다. Draft를 처음 접해도 괜찮지만, cds watch로 로컬 서비스를 띄워본 경험은 필요합니다.

🔧 환경 구성과 준비물

이 글의 예제는 다음 환경을 기준으로 작성되었습니다.

  • @sap/cds 8.x (7.x에서도 동일 개념 적용, 이벤트 표기 일부 상이)
  • Node.js 20 LTS
  • SAP BTP Cloud Foundry 환경 (로컬은 SQLite in-memory, 운영은 SAP HANA Cloud 권장)
  • UI: SAP Fiori Elements V4 (List Report + Object Page)
{
  "dependencies": { "@sap/cds": "^8" },
  "devDependencies": { "@cap-js/sqlite": "^1" },
  "cds": { "requires": { "db": { "kind": "sqlite" } } }
}

cds watch 실행 시 draft-enabled 엔티티마다 *_drafts 섀도 테이블과 DraftAdministrativeData가 자동 생성됩니다. 별도 테이블을 직접 모델링할 필요가 없다는 점을 먼저 기억해 두세요.

💡 핵심 개념: 드래프트는 "임시 저장용 그림자 테이블"이다

Draft를 이해하는 가장 쉬운 비유는 문서 편집기의 자동 저장입니다. 사용자가 Object Page에서 필드 하나를 고칠 때마다 원본(활성 엔티티)이 바뀌는 게 아니라, 원본 옆에 놓인 사본(드래프트 엔티티)에만 기록됩니다. "저장" 버튼을 누르는 순간에야 사본이 원본으로 승격(activate)됩니다.

활성 테이블(SalesOrders) ← [SAVE 시점에만 반영] ← 드래프트 테이블(SalesOrders.drafts) ← [PATCH가 수시로 기록]

이 구조 때문에 CAP는 드래프트 전용 이벤트를 별도로 제공합니다. 일반적으로 다음과 같이 매핑됩니다.

사용자 행동발생 이벤트대상 엔티티
Create 버튼 클릭NEWEntity.drafts
기존 데이터 Edit 시작EDIT활성 엔티티
필드 입력(수시 저장)UPDATE(PATCH)Entity.drafts
Save(활성화)SAVE활성 엔티티
Discard(취소)CANCELEntity.drafts

여기서 결정적인 포인트가 하나 있습니다. SAVE는 활성 엔티티에 대한 CREATE와 UPDATE를 모두 포괄합니다. 즉 "새 드래프트 → 저장"이면 내부적으로 CREATE, "기존 레코드 Edit → 저장"이면 UPDATE가 실행되며, 두 경우 모두 SAVE 훅으로 한 번에 잡을 수 있습니다. 반대로 편집 중 필드 입력은 활성 엔티티가 아닌 .drafts에만 흘러가므로, 활성 엔티티에 등록한 UPDATE 핸들러는 이 시점에 전혀 호출되지 않습니다. 실수 3가지가 전부 이 지점의 오해에서 출발합니다.

💻 실전 예제: 실수 3가지를 코드로 재현하고 고치기

1단계 — 실수 (a): 어노테이션 오배치

@odata.draft.enabled서비스 프로젝션의 루트 엔티티에만 붙여야 합니다. 흔한 오배치는 두 가지입니다. 첫째, DB 스키마 엔티티에 직접 붙이는 경우 — 해당 엔티티를 노출하는 모든 서비스에 드래프트가 전파되어 관리용 API까지 드래프트 흐름을 강제받습니다. 둘째, Composition 자식에 붙이는 경우 — 컴파일 단계에서 거부됩니다. 자식은 루트의 드래프트 트리에 자동 포함되므로 별도 어노테이션이 필요 없습니다.

// db/schema.cds — DB 레벨에는 드래프트 어노테이션을 두지 않는다
namespace com.acme.sales;
using { cuid, managed } from '@sap/cds/common';

entity SalesOrders : cuid, managed {
  orderNo   : String(20);
  buyer     : String(80);
  netAmount : Decimal(15,2);
  status    : String enum { open; approved; rejected; } default 'open';
  items     : Composition of many OrderItems on items.parent = $self;
}
entity OrderItems : cuid {
  parent   : Association to SalesOrders;
  material : String(40);
  quantity : Integer;
}
// srv/sales-service.cds — 드래프트는 서비스 프로젝션의 루트에만
using { com.acme.sales as db } from '../db/schema';

service SalesService {
  @odata.draft.enabled            // ✅ 루트 프로젝션에 배치
  entity SalesOrders as projection on db.SalesOrders;
  entity OrderItems  as projection on db.OrderItems;  // ❌ 여기엔 붙이지 않음
}

2단계 — 실수 (b): 검증 로직을 엉뚱한 엔티티에 거는 문제

가장 빈번한 사례입니다. "금액은 0보다 커야 한다"는 검증을 before('UPDATE', SalesOrders)에 걸어두고, Fiori 화면에서 마이너스 금액을 입력해도 통과되는 것을 보고 당황하는 패턴입니다. 편집 중 PATCH는 SalesOrders.drafts로 가기 때문에 활성 엔티티 핸들러는 침묵합니다. 추가 함정도 있습니다. 드래프트 PATCH의 req.data에는 변경된 필드만 담기므로, 다른 필드까지 읽어 검증하면 undefined를 잘못 거부하게 됩니다.

// srv/sales-service.js
const cds = require('@sap/cds')

module.exports = class SalesService extends cds.ApplicationService {
  init() {
    const { SalesOrders } = this.entities

    // ✅ 즉시 피드백: 드래프트 엔티티의 필드 변경 시점에 검증
    this.before('UPDATE', SalesOrders.drafts, (req) => {
      // PATCH에는 변경된 필드만 온다 → 존재 여부부터 확인
      if ('netAmount' in req.data && req.data.netAmount <= 0) {
        // target을 지정하면 Fiori가 해당 입력 필드 옆에 메시지를 표시
        req.error(400, '주문 금액은 0보다 커야 합니다.', 'in/netAmount')
      }
    })

    // ✅ 최종 게이트: 활성화(SAVE) 시점의 전체 검증
    this.before('SAVE', SalesOrders, async (req) => {
      const { items, buyer } = req.data   // SAVE에는 드래프트 전체 데이터가 담긴다
      if (!buyer) req.error(400, '구매처는 필수입니다.', 'in/buyer')
      if (!items?.length) req.error(400, '품목이 최소 1건 필요합니다.')
      console.log(`[audit] activate 시도: order=${req.data.orderNo}, items=${items?.length ?? 0}`)
    })

    return super.init()
  }
}

정리하면 "즉시 피드백은 .drafts에, 최종 무결성은 SAVE에"라는 이중 배치가 일반적으로 권장되는 패턴입니다. 단순 규칙이라면 코드 대신 @mandatory, @assert.range 같은 선언적 어노테이션이 더 깔끔하며, 이들은 활성화 시점에 CAP 런타임이 자동 실행합니다.

3단계 — 실수 (c): 활성화 타이밍과 부수효과(프로덕션 관점)

두 가지 타이밍 사고가 잦습니다. 첫째, 채번을 NEW에서 수행하면 사용자가 드래프트를 버릴 때마다(CANCEL) 번호가 소모되어 결번이 생깁니다. 채번은 SAVE 시점으로 미루는 것이 안전합니다. 둘째, 이메일 발송·외부 API 호출 같은 부수효과를 before('SAVE')에서 실행하면, 이후 DB 트랜잭션이 롤백되어도 이미 메일은 나가버립니다. 커밋 성공이 확정된 뒤에 실행되도록 req.on('succeeded')로 미뤄야 합니다.

this.before('SAVE', SalesOrders, async (req) => {
  // ✅ 채번은 활성화가 확실해진 시점에 — CANCEL로 인한 결번 방지
  if (!req.data.orderNo) {
    const { maxNo } = await SELECT.one`max(orderNo) as maxNo`.from(SalesOrders)
    req.data.orderNo = `SO-${String(Number(maxNo?.slice(3) ?? 0) + 1).padStart(6, '0')}`
  }

  // ✅ 부수효과는 트랜잭션 커밋 성공 이후로 지연
  req.on('succeeded', () => {
    notifyApprover(req.data.orderNo)          // 커밋 확정 후에만 실행됨
      .catch(e => console.error('[notify] 실패 — 재시도 큐 적재', e.message))
  })
})

// ⚠️ on('CREATE')를 직접 가로챌 때는 반드시 next() 체인을 유지할 것
this.on('CREATE', SalesOrders, async (req, next) => {
  const result = await next()   // 이 호출을 누락하면 드래프트 활성화 체인이 끊긴다
  return result
})

마지막 스니펫이 특히 중요합니다. 드래프트 활성화는 내부적으로 활성 엔티티 CREATE/UPDATE로 이어지는데, on 핸들러에서 next()를 호출하지 않으면 프레임워크의 드래프트 정리(드래프트 삭제, 활성 레코드 기록)가 실행되지 않아 유령 드래프트가 남습니다. 테스트는 cds.test로 NEW → PATCH → SAVE 전체 흐름을 검증하는 것이 좋습니다.

// test/draft-flow.test.js
const cds = require('@sap/cds')
const { GET, POST, expect } = cds.test(__dirname + '/..')

it('드래프트 생성 → 활성화 전체 흐름', async () => {
  const { data: draft } = await POST('/odata/v4/sales/SalesOrders', { buyer: 'ACME' })
  expect(draft.IsActiveEntity).to.be.false
  await POST(`/odata/v4/sales/SalesOrders(ID=${draft.ID},IsActiveEntity=false)/SalesService.draftActivate`, {})
  const { data } = await GET(`/odata/v4/sales/SalesOrders(ID=${draft.ID},IsActiveEntity=true)`)
  expect(data.orderNo).to.match(/^SO-/)
})

⚠️ 트러블슈팅 FAQ

Q1. 검증이 아예 안 타는데 핸들러 등록은 맞습니다.
엔티티 이름을 문자열로 등록했다면 'SalesOrders.drafts'처럼 .drafts 접미사를 붙였는지 확인하세요. this.entities에서 가져온 객체라면 SalesOrders.drafts 속성을 사용해야 합니다. 활성 엔티티명만 쓰면 편집 중 PATCH는 절대 잡히지 않습니다.

Q2. 다른 사용자가 "다른 사용자가 편집 중"이라며 Edit을 못 합니다.
드래프트 잠금(draft lock)이 걸린 상태입니다. 누군가 Edit 후 저장/취소 없이 이탈하면 잠금이 유지되며, DraftAdministrativeData에서 소유자를 확인할 수 있습니다. cds.fiori.draft_deletion_timeout 설정으로 오래된 드래프트 정리 주기를 조정하는 방식이 일반적으로 사용됩니다.

Q3. 같은 검증이 두 번 실행됩니다.
.drafts UPDATE와 SAVE 양쪽에 동일 로직을 등록한 경우입니다. 의도된 이중 게이트라면 정상이지만, 비용이 큰 검증(외부 조회 등)은 SAVE 한 곳에만 두는 편이 좋습니다.

Q4. OData로 직접 조회하면 데이터가 안 보입니다.
draft-enabled 엔티티는 키에 IsActiveEntity가 포함됩니다. 목록 조회 시 $filter=IsActiveEntity eq true를 붙이거나, 드래프트를 보려면 false로 필터링해야 합니다. 통합 테스트 실패의 단골 원인입니다.

🚀 이어서 살펴볼 주제

드래프트 이벤트 흐름을 잡았다면, 다음으로는 Side Effects 어노테이션으로 필드 변경 시 화면 재계산을 트리거하는 패턴, @sap/cds의 선언적 입력 검증(@assert.*) 심화, 그리고 RAP의 Draft 처리와 CAP의 차이 비교를 살펴보면 이해가 한층 깊어집니다. 다중 인스턴스 배포 시 드래프트 잠금과 세션 처리도 프로덕션 전에 점검해 볼 가치가 있는 주제입니다.

📚 더 읽어볼 자료

댓글 0

아직 댓글이 없습니다.