📖 개요 — 이 글에서 다루는 것
SAP Fiori elements 기반 앱에서 사용자가 입력하다 만 데이터를 안전하게 임시 저장해 주는 Draft(초안) 패턴은, CAP Node.js에서는 어노테이션 한 줄(@odata.draft.enabled)로 켤 수 있습니다. 그런데 "한 줄로 켜진다"는 말 뒤에는 활성 엔티티와 드래프트 엔티티의 이중 구조, 검증 시점의 변화, 락(lock)과 동시성 처리라는 복잡한 메커니즘이 숨어 있습니다. 이 글은 Draft 모드를 켠 뒤 실무에서 자주 겪는 실수와 그 회피법을 원리 수준에서 정리합니다.
- Draft 패턴의 내부 구조(드래프트 테이블, DraftAdministrativeData, ETag) 이해
@odata.draft.enabled활성화와 조합(Composition) 모델링 요건 확인- 검증 핸들러를 올바른 이벤트(PATCH / SAVE / CREATE·UPDATE)에 배치
- 드래프트 락 타임아웃·동시성 충돌·번호 채번 문제를 프로덕션 수준으로 처리
📚 미리 알고 있으면 좋은 것
CDS 모델링 기초(엔티티, 서비스 프로젝션, Composition/Association 차이), CAP Node.js 이벤트 핸들러(srv.before / on / after) 등록 경험, OData V4 기본 개념(엔티티 키, ETag, $filter)이 있으면 수월합니다. Fiori elements List Report / Object Page를 한 번이라도 띄워 본 경험이 있다면 Draft UI 흐름을 바로 연결해서 이해할 수 있습니다.
🔧 환경과 버전, 준비물
이 글의 예제는 아래 환경을 기준으로 작성했습니다. 버전별로 드래프트 이벤트 이름과 기본 설정값이 달라질 수 있으므로, 실제 프로젝트에서는 @sap/cds 버전을 먼저 확인하는 것이 좋습니다.
- Node.js 20 또는 22 LTS
@sap/cds8.x 이상 (새 드래프트 구현 기준 — 9.x에서도 동일한 패턴 적용 가능)- 로컬 개발: SQLite 인메모리 DB, 배포: SAP HANA Cloud (SAP BTP, Cloud Foundry 환경)
- UI 테스트: SAP Fiori elements(List Report/Object Page), SAP Fiori tools 확장
새 프로젝트는 cds init draft-demo && cd draft-demo && npm add @sap/cds로 만들고, cds watch로 실행하면서 따라오면 됩니다. Draft는 Fiori elements가 소비하는 것을 전제로 설계된 프로토콜이므로, 테스트 시 순수 REST 클라이언트만 쓰면 흐름 절반을 놓치기 쉽다는 점도 미리 기억해 두세요.
💡 Draft 패턴의 핵심 동작 원리
Draft를 비유하자면 "문서 결재 전 임시 저장함"입니다. 사용자가 작성 중인 문서는 결재함(활성 테이블)에 들어가지 않고 개인 서랍(드래프트 테이블)에 보관되며, "저장(Activate)" 버튼을 누르는 순간에만 결재함으로 옮겨집니다. CAP는 이를 위해 드래프트가 켜진 엔티티마다 그림자 테이블을 하나 더 만듭니다.
- 활성 엔티티 vs 드래프트 엔티티 —
PurchaseRequests를 드래프트로 켜면 런타임이PurchaseRequests.drafts라는 별도 저장 공간을 관리합니다. OData 관점에서는 하나의 엔티티셋에IsActiveEntity가 키의 일부로 추가되어, 같은 UUID의 활성본과 초안본이 공존합니다. - DraftAdministrativeData — 각 초안에는 관리 메타데이터(작성자
CreatedByUser, 현재 편집 중인 사용자InProcessByUser, 마지막 변경 시각 등)가 붙습니다. Fiori UI가 "다른 사용자가 편집 중" 잠금 안내를 띄우는 근거가 바로 이 데이터입니다. - 라이프사이클 액션 — 새 초안 생성(NEW), 기존 활성 데이터 편집 시작(EDIT), 필드 단위 임시 저장(PATCH), 활성화(SAVE/Activate), 폐기(CANCEL/Discard)라는 이벤트 흐름으로 움직입니다. 일반 CRUD와 이벤트 지형이 완전히 달라진다는 것이 핵심입니다.
- 낙관적 잠금(ETag) — 활성 엔티티에
@odata.etag를 걸면 EDIT 시점의 버전과 활성화 시점의 버전을 비교해, 그 사이 다른 트랜잭션이 데이터를 바꿨다면 412 오류로 충돌을 알립니다. 드래프트 락(비관적 성격)과 ETag(낙관적 성격)가 이중으로 동작하는 구조입니다.
가장 중요한 사고 전환은 이것입니다. 드래프트 상태의 데이터는 "아직 유효하지 않아도 되는" 데이터라는 점입니다. 필수 필드가 비어 있어도 초안은 저장돼야 하므로, 검증은 기본적으로 활성화 시점으로 미뤄집니다. 이 원리를 모르면 아래에서 다룰 실수 대부분이 발생합니다.
💻 실전 코드 3단계
1단계 — 기본 예제: 구매요청(PurchaseRequest) 모델에 Draft 켜기
드래프트는 문서의 헤더-아이템 구조 전체가 하나의 초안 단위로 묶여야 하므로, 자식 엔티티는 반드시 Composition으로 모델링해야 합니다. Association으로 연결하면 자식이 초안 흐름에 포함되지 않아 UI가 깨집니다.
// db/schema.cds
namespace demo.pr;
using { cuid, managed } from '@sap/cds/common';
entity PurchaseRequests : cuid, managed {
requestNo : String(12); // 활성화 시점에 채번
requester : String(80);
totalAmount : Decimal(15,2);
status : String(2) default 'N';
items : Composition of many RequestItems on items.parent = $self;
}
entity RequestItems : cuid {
parent : Association to PurchaseRequests;
material : String(40) @mandatory;
quantity : Integer;
unitPrice : Decimal(15,2);
}
// srv/pr-service.cds
using demo.pr from '../db/schema';
service ProcurementService {
@odata.draft.enabled // 헤더에만 선언 — 자식은 Composition으로 자동 포함
entity PurchaseRequests as projection on pr.PurchaseRequests;
}
cds watch 후 메타데이터를 보면 IsActiveEntity, HasActiveEntity, HasDraftEntity 프로퍼티와 DraftAdministrativeData 연관이 자동으로 추가된 것을 확인할 수 있습니다.
2단계 — 실무 시나리오: 검증을 "올바른 시점"에 배치하고 로깅하기
드래프트 환경에서는 검증 핸들러를 어디에 다느냐가 곧 UX입니다. 입력 즉시 피드백은 drafts에 대한 PATCH에, 최종 정합성 검증은 활성화 흐름(활성 엔티티의 CREATE/UPDATE)에 겁니다.
// srv/pr-service.js
const cds = require('@sap/cds');
const LOG = cds.log('procurement');
module.exports = class ProcurementService extends cds.ApplicationService {
init() {
const { PurchaseRequests } = this.entities;
// (a) 필드 단위 즉시 검증 — 초안 입력 도중 실행됨
this.before('UPDATE', PurchaseRequests.drafts, (req) => {
if (req.data.totalAmount != null && req.data.totalAmount < 0) {
// target을 지정하면 Fiori UI가 해당 입력 필드에 오류를 표시
req.error(400, '금액은 0 이상이어야 합니다', 'totalAmount');
}
});
// (b) 최종 정합성 검증 — 활성화(Save) 시점에 실행됨
// 활성 엔티티의 CREATE(신규 초안 활성화)와 UPDATE(편집 초안 활성화) 모두 커버
this.before(['CREATE', 'UPDATE'], PurchaseRequests, async (req) => {
const items = req.data.items ?? [];
if (items.length === 0) {
return req.error(400, '품목이 최소 1건 필요합니다', 'items');
}
const sum = items.reduce((s, i) => s + (i.quantity ?? 0) * (i.unitPrice ?? 0), 0);
if (Math.abs(sum - (req.data.totalAmount ?? 0)) > 0.005) {
LOG.warn('금액 불일치', { id: req.data.ID, sum, header: req.data.totalAmount });
return req.error(400, '품목 합계와 헤더 금액이 일치하지 않습니다');
}
});
return super.init();
}
};
커스텀 로직에서 초안 데이터를 조회해야 한다면 반드시 .drafts를 대상으로 SELECT해야 합니다. 활성 엔티티를 조회하면 아직 활성화되지 않은 값은 보이지 않습니다.
// 초안 데이터 읽기 — 활성 테이블이 아니라 drafts를 조회해야 함
const draft = await SELECT.one.from(PurchaseRequests.drafts)
.where({ ID: req.data.ID });
3단계 — 프로덕션: 채번·동시성·락 설정·테스트
문서 번호 채번을 초안 생성 시점에 하면, 사용자가 초안을 폐기할 때마다 번호가 결번됩니다. 채번·타임스탬프 확정 같은 부수 효과는 활성화 시점으로 미루는 것이 일반적으로 안전합니다.
// 활성화 시점 채번 + 낙관적 잠금
this.before('CREATE', PurchaseRequests, async (req) => {
if (!req.data.requestNo) {
const { maxNo } = await SELECT.one`max(requestNo) as maxNo`
.from(PurchaseRequests); // 실제로는 시퀀스/번호범위 서비스 권장
req.data.requestNo = `PR${String(Number(maxNo?.slice(2) ?? 0) + 1).padStart(6, '0')}`;
}
});
// ETag로 활성 데이터 충돌 감지
annotate ProcurementService.PurchaseRequests with { modifiedAt @odata.etag; }
// package.json — 드래프트 락/보존 기간 조정 (값·기본값은 버전별 확인 권장)
{ "cds": { "fiori": {
"draft_lock_timeout": "15min",
"draft_deletion_timeout": "30d"
} } }
// test/draft-flow.test.js — 드래프트 전체 흐름 통합 테스트
const cds = require('@sap/cds');
const { GET, POST, PATCH } = cds.test(__dirname + '/..');
it('초안 생성 → 수정 → 활성화', async () => {
const { data: d } = await POST('/odata/v4/procurement/PurchaseRequests', {});
await PATCH(
`/odata/v4/procurement/PurchaseRequests(ID=${d.ID},IsActiveEntity=false)`,
{ requester: 'Jinju', totalAmount: 100 });
// draftActivate: 검증 실패(품목 없음)를 기대
const res = await POST(
`/odata/v4/procurement/PurchaseRequests(ID=${d.ID},IsActiveEntity=false)/ProcurementService.draftActivate`,
{}, { validateStatus: () => true });
expect(res.status).toBe(400);
});
보안 측면에서는 @restrict로 역할 기반 접근을 걸더라도 드래프트는 작성자 본인만 보게 하는 것이 기본 동작이라는 점, 그리고 서비스 간(S2S) 기술 사용자 호출은 드래프트 흐름을 거치지 않도록 별도 프로젝션을 두는 패턴이 일반적으로 권장된다는 점을 기억하세요.
⚠️ 흔한 실수와 트러블슈팅 FAQ
Q1. @mandatory를 걸었는데 초안 저장 중에는 오류가 안 납니다.
정상 동작입니다. 초안은 미완성 상태를 허용하는 것이 존재 이유라서, 필수 필드 검증은 활성화 시점에 실행됩니다. 입력 즉시 피드백이 필요하면 2단계처럼
Entity.drafts의 UPDATE(PATCH)에 별도 핸들러를 다세요.
Q2. before('UPDATE', Entity) 핸들러가 사용자가 타이핑할 때 호출되지 않습니다.
입력 중 필드 저장은 활성 엔티티가 아니라 드래프트 엔티티에 대한 PATCH입니다. 활성 엔티티의 CREATE/UPDATE는 활성화(Save) 순간에만 발생합니다. 핸들러 대상(
EntityvsEntity.drafts)을 먼저 확인하세요.
Q3. 다른 사용자가 편집 중이라며 Edit 버튼이 잠깁니다.
드래프트 락 때문입니다.
DraftAdministrativeData.InProcessByUser를 조회해 누가 잡고 있는지 확인하고, 락 타임아웃(draft_lock_timeout, 기본 15분 수준 — 버전별 상이 가능)이 지나면 다른 사용자가 이어받을 수 있습니다. 방치된 초안은draft_deletion_timeout으로 정리하세요.
Q4. 커스텀 핸들러에서 SELECT하면 방금 입력한 값이 없다고 나옵니다.
활성 테이블을 조회했기 때문입니다. 활성화 전 데이터는
Entity.drafts에만 있습니다. 반대로 활성화 이후 로직에서 drafts를 읽으면 이미 비어 있으니, 시점에 맞는 소스를 선택해야 합니다.
Q5. 저장 시 412 Precondition Failed가 발생합니다.
ETag 충돌입니다. 편집 시작 후 다른 트랜잭션(배치, 다른 앱)이 활성 데이터를 바꾼 경우로, 사용자에게 새로고침 후 재편집을 안내하는 것이 정석입니다. 오류를 무시하고 덮어쓰도록 우회하면 데이터 유실로 이어집니다.
이 밖에 자식 엔티티를 Association으로 걸어 초안에서 품목 편집이 안 되는 문제, UUID가 아닌 키를 써서 초안 생성이 어색해지는 문제도 자주 보이는 실수입니다.
🚀 여기서 더 나아가기
드래프트 기본기를 잡았다면 다음 주제로 확장해 보세요. 활성화 전 사용자 확인을 위한 side effects와 커스텀 액션(@Common.SideEffects), 드래프트와 워크플로 승인 프로세스 연동, CAP Java 드래프트 구현과의 차이 비교, 그리고 대량 데이터 시나리오에서 드래프트 테이블이 성능에 미치는 영향(인덱스, 가비지 컬렉션 주기)까지 다루면 실무 커버리지가 완성됩니다. Fiori elements의 ObjectPage 매니페스트 설정과 드래프트 UX 옵션도 함께 살펴보면 좋습니다.
📚 더 읽어볼 자료
댓글 0
아직 댓글이 없습니다.