목표: 실무 수준의 데이터 모델을 설계하고, 다양한 OData 쿼리와 비즈니스 로직을 완성한다.
시나리오: 출장비 앱의 데이터 구조를 완성 — 신청, 카테고리, 승인 이력을 연결한다.
소요 시간: 이론 2시간 + 실습 3시간
이론:
CDS의 Associations vs Compositions 차이를 설명할 수 있다
Enum 타입으로 상태값을 안전하게 관리하는 방법을 안다
CAP의 4가지 이벤트 Hook (before/after/on, CRUD)을 활용할 수 있다
CDS View(가상 엔티티)로 집계 데이터를 노출하는 방법을 안다
실습:
Association(1:N, N:1)이 있는 멀티 엔티티 모델 완성
$expand로 연관 데이터 함께 조회
Custom Function으로 집계 API 구현
2일차 결과 Git 커밋
CDS에서 사용할 수 있는 타입들을 정리합니다.
| CDS 타입 | OData 타입 | DB 타입 | 사용 예 |
|---|---|---|---|
UUID | Edm.Guid | CHAR(36) | Primary Key |
String(n) | Edm.String | NVARCHAR(n) | 일반 텍스트 |
Integer | Edm.Int32 | INTEGER | 정수 |
Decimal(p,s) | Edm.Decimal | DECIMAL | 금액, 비율 |
Date | Edm.Date | DATE | 날짜 (시간 없음) |
DateTime | Edm.DateTimeOffset | TIMESTAMP | 날짜+시간 |
Boolean | Edm.Boolean | BOOLEAN | 참/거짓 |
LargeString | Edm.String | NCLOB | 긴 텍스트 |
// 잘못된 방법: String으로 상태값 관리
entity TravelRequests {
status : String; // "Draft"? "draft"? "DRAFT"? 혼란!
}
// 올바른 방법: Enum으로 상태값 강제
type TravelStatus : String enum {
Draft = 'Draft';
Submitted = 'Submitted';
Approved = 'Approved';
Rejected = 'Rejected';
Cancelled = 'Cancelled';
}
entity TravelRequests {
status : TravelStatus default 'Draft'; // 타입 안전성 확보!
}
// 여러 필드를 하나의 타입으로 묶기
type Address {
street : String(100);
city : String(50);
country : String(3); // ISO 국가 코드
}
entity Hotels {
key ID : UUID;
name : String;
address : Address; // 복합 타입 사용
}
// → DB에는 address_street, address_city, address_country 컬럼으로 생성
가장 많이 헷갈리는 개념입니다. 명확히 구분해봅니다.
독립적으로 존재하는 엔티티 간의 참조 관계
부모가 삭제되어도 자식은 살아있음
예: TravelRequest ─── refers to ─── Department
(출장 신청) (부서)
부서가 폐지되어도 출장 신청 기록은 남아있어야 함
→ Association 사용
entity TravelRequests {
...
department : Association to Departments; // FK만 저장
}
entity Departments {
key ID : UUID;
name : String;
}
부모 엔티티가 삭제되면 자식도 함께 삭제
자식 단독으로는 존재 의미 없음
예: TravelRequest ─── contains ─── ExpenseItems
(출장 신청) (비용 항목들)
출장 신청이 삭제되면 비용 항목도 의미 없어짐
→ Composition 사용
entity TravelRequests {
...
items : Composition of many ExpenseItems on items.request = $self;
}
// 독립적으로 생성/접근 불가 — TravelRequests를 통해서만 접근
entity ExpenseItems {
key ID : UUID;
key request : Association to TravelRequests; // 부모 참조
category: String;
amount : Decimal(10,2);
receipt : String; // 영수증 URL
}
실용적 판단 기준:
"이 데이터가 부모 없이 의미 있는가?"
├── YES → Association (독립 엔티티)
└── NO → Composition (자식 엔티티)
CAP은 CRUD 요청의 생명주기에 훅을 걸 수 있습니다.
요청 흐름:
HTTP Request
↓
[before] ← 검증, 데이터 변환, 권한 확인
↓
[on] ← 실제 처리 (기본: DB CRUD, 재정의 가능)
↓
[after] ← 후처리, 알림, 로깅
↓
HTTP Response
// 4가지 CRUD 이벤트
this.before('CREATE', Entity, handler); // 생성 전
this.before('READ', Entity, handler); // 조회 전
this.before('UPDATE', Entity, handler); // 수정 전
this.before('DELETE', Entity, handler); // 삭제 전
this.after('CREATE', Entity, handler); // 생성 후
this.after('READ', Entity, handler); // 조회 후 결과 가공
// ...
// Custom Action/Function
this.on('actionName', Entity, handler); // 커스텀 액션
this.on('functionName', handler); // 언바운드 함수
req 객체의 주요 속성:
req.data // 요청 바디 (POST/PATCH의 body)
req.params // URL 파라미터 (키 값)
req.query // OData 쿼리 옵션 ($filter 등) → CQN 객체
req.user // 인증된 사용자 정보 (4일차에 활성화)
req.headers // HTTP 헤더
req.error() // 에러 반환
req.warn() // 경고 메시지 추가 (200 응답 유지)
req.info() // 정보 메시지 추가
집계 데이터나 계산된 필드를 API로 노출할 때 사용합니다.
// 읽기 전용 집계 뷰 예시
define view TravelSummaryByStatus as
select from TravelRequests {
status,
count(*) as count : Integer,
sum(amount) as totalAmount : Decimal(15,2)
}
group by status;
서비스에서 노출:
service TravelService {
// 뷰는 자동으로 읽기 전용
entity TravelSummary as projection on TravelSummaryByStatus;
}
db/schema.cds를 아래로 교체합니다.
// db/schema.cds
namespace com.travel;
using { cuid, managed, sap.common.CodeList } from '@sap/cds/common';
// ── Enum 타입 정의 ────────────────────────────────────────
type TravelStatus : String(20) enum {
Draft = 'Draft';
Submitted = 'Submitted';
Approved = 'Approved';
Rejected = 'Rejected';
Cancelled = 'Cancelled';
}
type ExpenseCategory : String(30) enum {
Transportation = 'Transportation'; // 교통비
Accommodation = 'Accommodation'; // 숙박비
Meal = 'Meal'; // 식비
Conference = 'Conference'; // 컨퍼런스/행사비
Other = 'Other'; // 기타
}
// ── 부서 마스터 (독립 엔티티) ─────────────────────────────
entity Departments : cuid {
name : String(100) @mandatory;
code : String(10) @mandatory;
manager : String(100);
}
// ── 출장비 신청 (메인 엔티티) ─────────────────────────────
entity TravelRequests : cuid, managed {
title : String(100) @mandatory;
description : String(500);
destination : String(100) @mandatory;
departureDate : Date @mandatory;
returnDate : Date @mandatory;
totalAmount : Decimal(15,2) @readonly; // 자동 계산
currency : String(3) default 'KRW';
status : TravelStatus default 'Draft';
requester : String(100);
requesterEmail: String(200);
purpose : LargeString;
// Association: 부서는 독립적으로 존재
department : Association to Departments;
// Composition: 비용 항목은 신청 없이 존재 불가
items : Composition of many ExpenseItems
on items.request = $self;
// Composition: 승인 이력도 신청에 종속
approvalLogs : Composition of many ApprovalLogs
on approvalLogs.request = $self;
}
// ── 비용 항목 (TravelRequests의 자식) ────────────────────
entity ExpenseItems : cuid {
request : Association to TravelRequests; // 부모 참조
category : ExpenseCategory @mandatory;
itemTitle : String(200) @mandatory;
amount : Decimal(15,2) @mandatory;
receiptUrl : String(500);
expenseDate: Date;
note : String(300);
}
// ── 승인 이력 (TravelRequests의 자식) ────────────────────
entity ApprovalLogs : cuid, managed {
request : Association to TravelRequests;
action : String(20); // 'Submitted', 'Approved', 'Rejected'
comment : String(500);
actor : String(100); // 처리자
}
using com.travel from '../db/schema';
service TravelService @(path: '/travel') {
// ── 메인 엔티티들 ──────────────────────────────────────
entity TravelRequests as projection on travel.TravelRequests actions {
// 제출 (Draft → Submitted)
action submit() returns TravelRequests;
// 승인 (Submitted → Approved)
action approve(comment: String) returns TravelRequests;
// 반려 (Submitted → Rejected)
action reject(comment: String) returns TravelRequests;
};
entity ExpenseItems as projection on travel.ExpenseItems;
entity Departments as projection on travel.Departments;
// 승인 이력은 직접 수정 불가 (읽기 전용)
@readonly
entity ApprovalLogs as projection on travel.ApprovalLogs;
// ── Custom Function (읽기 전용) ─────────────────────────
// 상태별 통계 집계
function getStatusSummary() returns array of {
status: String; count: Integer; totalAmount: Decimal;
};
}
⚠️ Action vs Function 구분
action: 데이터를 변경할 수 있음 (POST 방식)function: 데이터를 읽기만 함 (GET 방식)
srv/travel-service.js를 완전히 재작성합니다.
// srv/travel-service.js
const cds = require('@sap/cds');
module.exports = cds.service.impl(async function () {
const { TravelRequests, ExpenseItems, ApprovalLogs } = this.entities;
// ════════════════════════════════════════════════════
// BEFORE Hooks — 검증 로직
// ════════════════════════════════════════════════════
// 출장 신청 생성 전 유효성 검사
this.before('CREATE', TravelRequests, async (req) => {
const { departureDate, returnDate, items } = req.data;
if (departureDate && returnDate && departureDate > returnDate) {
return req.error(400, '출발일은 귀국일보다 이전이어야 합니다.');
}
});
// 비용 항목 생성/수정 전 금액 검사
this.before(['CREATE', 'UPDATE'], ExpenseItems, async (req) => {
const { amount } = req.data;
if (amount !== undefined && amount <= 0) {
return req.error(400, '비용 금액은 0보다 커야 합니다.');
}
});
// ════════════════════════════════════════════════════
// AFTER Hooks — 자동 계산
// ════════════════════════════════════════════════════
// 비용 항목 변경 시 → 부모 신청의 totalAmount 재계산
this.after(['CREATE', 'UPDATE', 'DELETE'], ExpenseItems, async (result, req) => {
// 변경된 항목의 부모 TravelRequest ID 찾기
const item = req.data || result;
if (!item || !item.request_ID) return;
await recalculateTotalAmount(item.request_ID);
});
// totalAmount 재계산 헬퍼 함수
async function recalculateTotalAmount(requestId) {
// 해당 신청의 모든 비용 항목 합계
const result = await SELECT.one
.from(ExpenseItems)
.columns('sum(amount) as total')
.where({ request_ID: requestId });
const total = result?.total || 0;
await UPDATE(TravelRequests)
.set({ totalAmount: total })
.where({ ID: requestId });
console.log(`💰 totalAmount 재계산: ${requestId} → ${total}`);
}
// ════════════════════════════════════════════════════
// Custom Actions — 상태 전이
// ════════════════════════════════════════════════════
// 제출 Action
this.on('submit', TravelRequests, async (req) => {
const { ID } = req.params[0];
const request = await SELECT.one.from(TravelRequests).where({ ID });
if (!request) return req.error(404, '출장 신청을 찾을 수 없습니다.');
if (request.status !== 'Draft') {
return req.error(409, `현재 상태(${request.status})에서는 제출할 수 없습니다.`);
}
// 비용 항목이 하나도 없으면 제출 불가
const itemCount = await SELECT.one
.from(ExpenseItems)
.columns('count(*) as cnt')
.where({ request_ID: ID });
if (!itemCount || itemCount.cnt === 0) {
return req.error(400, '비용 항목을 최소 1개 이상 등록해야 합니다.');
}
// 상태 변경
await UPDATE(TravelRequests).set({ status: 'Submitted' }).where({ ID });
// 승인 이력 기록
await INSERT.into(ApprovalLogs).entries({
request_ID : ID,
action : 'Submitted',
comment : '출장 신청이 제출되었습니다.',
actor : request.requester || 'System'
});
return await SELECT.one.from(TravelRequests).where({ ID });
});
// 승인 Action
this.on('approve', TravelRequests, async (req) => {
const { ID } = req.params[0];
const { comment } = req.data;
const request = await SELECT.one.from(TravelRequests).where({ ID });
if (!request) return req.error(404, '출장 신청을 찾을 수 없습니다.');
if (request.status !== 'Submitted') {
return req.error(409, `제출된 신청만 승인할 수 있습니다. 현재: ${request.status}`);
}
await UPDATE(TravelRequests).set({ status: 'Approved' }).where({ ID });
await INSERT.into(ApprovalLogs).entries({
request_ID : ID,
action : 'Approved',
comment : comment || '승인되었습니다.',
actor : req.user?.id || 'Manager'
});
req.info(`출장 신청 "${request.title}"이 승인되었습니다.`);
return await SELECT.one.from(TravelRequests).where({ ID });
});
// 반려 Action
this.on('reject', TravelRequests, async (req) => {
const { ID } = req.params[0];
const { comment } = req.data;
const request = await SELECT.one.from(TravelRequests).where({ ID });
if (!request) return req.error(404, '출장 신청을 찾을 수 없습니다.');
if (request.status !== 'Submitted') {
return req.error(409, `제출된 신청만 반려할 수 있습니다.`);
}
if (!comment || comment.trim() === '') {
return req.error(400, '반려 사유를 입력해야 합니다.');
}
await UPDATE(TravelRequests).set({ status: 'Rejected' }).where({ ID });
await INSERT.into(ApprovalLogs).entries({
request_ID : ID,
action : 'Rejected',
comment : comment,
actor : req.user?.id || 'Manager'
});
return await SELECT.one.from(TravelRequests).where({ ID });
});
// ════════════════════════════════════════════════════
// Custom Function — 집계 조회
// ════════════════════════════════════════════════════
this.on('getStatusSummary', async (req) => {
const result = await SELECT
.from(TravelRequests)
.columns('status', 'count(*) as count', 'sum(totalAmount) as totalAmount')
.groupBy('status');
return result;
});
});
# 부서 데이터
cat > db/data/com.travel-Departments.csv << 'EOF'
ID,name,code,manager
"aaaa0001-0000-0000-0000-000000000001","개발팀","DEV","김개발"
"aaaa0002-0000-0000-0000-000000000002","영업팀","SALES","이영업"
"aaaa0003-0000-0000-0000-000000000003","마케팅팀","MKT","박마케"
EOF
db/data/com.travel-TravelRequests.csv 업데이트:
ID,title,description,destination,departureDate,returnDate,totalAmount,currency,status,requester,requesterEmail,department_ID
"11111111-1111-1111-1111-111111111111","도쿄 고객사 미팅","Q4 제품 협의","도쿄, 일본","2025-09-01","2025-09-03",850000,"KRW","Submitted","장용기","hong@company.com","aaaa0001-0000-0000-0000-000000000001"
"22222222-2222-2222-2222-222222222222","싱가포르 컨퍼런스","SAP TechEd 참가","싱가포르","2025-10-15","2025-10-18",2100000,"KRW","Draft","김연식","kim@company.com","aaaa0002-0000-0000-0000-000000000002"
"33333333-3333-3333-3333-333333333333","서울 본사 출장","월간 전략 회의","서울","2025-08-20","2025-08-20",50000,"KRW","Approved","이우철","lee@company.com","aaaa0001-0000-0000-0000-000000000001"
db/data/com.travel-ExpenseItems.csv:
ID,request_ID,category,itemTitle,amount,expenseDate
"eeee0001-0000-0000-0000-000000000001","11111111-1111-1111-1111-111111111111","Transportation","인천-도쿄 항공권",450000,"2025-09-01"
"eeee0002-0000-0000-0000-000000000002","11111111-1111-1111-1111-111111111111","Accommodation","도쿄 호텔 2박",320000,"2025-09-01"
"eeee0003-0000-0000-0000-000000000003","11111111-1111-1111-1111-111111111111","Meal","현지 식비",80000,"2025-09-01"
"eeee0004-0000-0000-0000-000000000004","22222222-2222-2222-2222-222222222222","Conference","컨퍼런스 등록비",1500000,"2025-10-15"
"eeee0005-0000-0000-0000-000000000005","22222222-2222-2222-2222-222222222222","Transportation","인천-싱가포르 항공권",600000,"2025-10-15"
"eeee0006-0000-0000-0000-000000000006","33333333-3333-3333-3333-333333333333","Transportation","KTX 서울행",50000,"2025-08-20"
오류수정: db/data/com.travel-TravelRequests.csv
- AS-IS: CSV 헤더에 'amount' 컬럼 포함 (schema.cds에 존재하지 않는 필드)
- TO-BE: 'amount' 컬럼 제거 (schema.cds의 totalAmount는 @readonly 자동계산 필드)
- 원인: SqliteError - table has no column named 'amount' (cds watch 서버 기동 실패)
-> csv에서 amount 컬럼 제거
test/travel.http를 업데이트합니다.
### travel.http — 2일차 추가 테스트
@baseUrl = http://localhost:4004/travel
### ── Association $expand 테스트 ──────────────────────
### 1. 출장 신청 + 비용 항목 함께 조회
GET {{baseUrl}}/TravelRequests?$expand=items
Accept: application/json
###
### 2. 출장 신청 + 부서 정보 + 비용 항목 함께 조회
GET {{baseUrl}}/TravelRequests?$expand=items,department
Accept: application/json
###
### 3. 특정 신청의 비용 항목만 조회
GET {{baseUrl}}/TravelRequests(11111111-1111-1111-1111-111111111111)/items
Accept: application/json
###
### 4. $expand + $select 조합 (필요한 필드만)
GET {{baseUrl}}/TravelRequests?$select=title,status,totalAmount&$expand=department($select=name,code)
Accept: application/json
###
### ── Composition 테스트 ────────────────────────────────
### 5. 비용 항목 추가 (신청서 하위에)
POST {{baseUrl}}/TravelRequests(22222222-2222-2222-2222-222222222222)/items
Content-Type: application/json
{
"category": "Accommodation",
"itemTitle": "싱가포르 호텔 3박",
"amount": 750000,
"expenseDate": "2025-10-15"
}
###
### ── Custom Action 테스트 ─────────────────────────────
### 6. 출장 신청 제출
POST {{baseUrl}}/TravelRequests(22222222-2222-2222-2222-222222222222)/submit
Content-Type: application/json
{}
###
### 7. 승인 처리
POST {{baseUrl}}/TravelRequests(22222222-2222-2222-2222-222222222222)/approve
Content-Type: application/json
{
"comment": "여행 목적이 명확하고 금액이 적정합니다. 승인합니다."
}
###
### 8. 승인 이력 확인
GET {{baseUrl}}/TravelRequests(22222222-2222-2222-2222-222222222222)/approvalLogs
Accept: application/json
###
### ── Custom Function 테스트 ──────────────────────────
### 9. 상태별 통계 조회
GET {{baseUrl}}/getStatusSummary()
Accept: application/json
###
### ── OData 고급 필터 ─────────────────────────────────
### 10. 날짜 범위 필터
GET {{baseUrl}}/TravelRequests?$filter=departureDate ge 2025-09-01 and departureDate le 2025-12-31
Accept: application/json
###
### 11. 금액 기준 필터 + 정렬
GET {{baseUrl}}/TravelRequests?$filter=totalAmount ge 500000&$orderby=totalAmount desc
Accept: application/json
###
### 12. contains (LIKE 검색)
GET {{baseUrl}}/TravelRequests?$filter=contains(title,'미팅')
Accept: application/json
###
### 13. 반려 테스트 (사유 없이 → 에러)
POST {{baseUrl}}/TravelRequests(11111111-1111-1111-1111-111111111111)/reject
Content-Type: application/json
{}
###
### 14. 반려 처리 (사유 있음)
POST {{baseUrl}}/TravelRequests(11111111-1111-1111-1111-111111111111)/reject
Content-Type: application/json
{
"comment": "예산 초과로 반려합니다. 항공권 등급을 낮추어 재신청해 주세요."
}
###
💡 왜 DBeaver인가?
CAP이 로컬에서 SQLite를 사용하기 때문에, DBeaver로 연결해서 실제 생성된 테이블을 눈으로 보면 CDS 모델이 어떻게 DB로 변환되는지 이해가 훨씬 빠릅니다.
# 로컬 SQLite DB 파일 생성 (in-memory 대신 파일로 저장)
# package.json의 cds 설정에 추가
package.json에 아래 설정 추가:
{
"cds": {
"requires": {
"db": {
"kind": "sqlite",
"credentials": {
"database": "db/travel.sqlite"
}
}
}
}
}
# DB 파일 생성
cds deploy --to sqlite:db/travel.sqlite
# cds watch 재시작
cds watch
프로젝트경로/db/travel.sqlite
-- 1. 생성된 테이블 목록
SELECT name FROM sqlite_master WHERE type='table';
-- 2. TravelRequests와 ExpenseItems 조인
SELECT
tr.title,
tr.status,
tr.totalAmount,
ei.category,
ei.itemTitle,
ei.amount as itemAmount
FROM com_travel_TravelRequests tr
LEFT JOIN com_travel_ExpenseItems ei ON ei.request_ID = tr.ID
ORDER BY tr.title, ei.category;
-- 3. 상태별 집계
SELECT
status,
COUNT(*) as count,
SUM(totalAmount) as total
FROM com_travel_TravelRequests
GROUP BY status;

발견할 것들:
- CDS의
departmentAssociation은 DB에서department_ID컬럼으로 변환됨managedAspect는createdAt,createdBy,modifiedAt,modifiedBy컬럼 자동 생성cuidAspect는 UUID 형식의ID컬럼 자동 생성
아래 상태 흐름이 코드에서 어떻게 구현되는지 연결해서 확인합니다.
상태 전이도:
┌─────────┐
│ Draft │──── submit() ────→ ┌───────────┐
└─────────┘ │ Submitted │
↑ └───────────┘
(초기 상태) ↙ ↘
approve() reject()
↙ ↘
┌──────────┐ ┌──────────┐
│ Approved │ │ Rejected │
└──────────┘ └──────────┘
모든 상태에서:
→ Cancelled 가능 (5일차에 추가)
# 전체 시나리오 순서 테스트
# 1) 새 신청 생성 (POST)
# 2) 비용 항목 추가 (POST items)
# 3) totalAmount 자동 계산 확인 (GET)
# 4) submit Action 실행
# 5) approve Action 실행
# 6) approvalLogs 확인
# .gitignore 업데이트
cat >> .gitignore << 'EOF'
db/travel.sqlite
node_modules/
*.db
EOF
git add .
git commit -m "day2: 멀티 엔티티 모델(Departments, ExpenseItems, ApprovalLogs) + 상태 전이 로직"
git push origin main
git checkout -b day3-start
git push origin day3-start
git checkout main
items를 Composition 대신 Association으로 바꾸면?
// 잘못된 코드
items : Association to many ExpenseItems on items.request = $self;
$expand=items로 조회해 보면 데이터가 나오지 않거나, 신청 삭제 시 orphan 데이터가 남습니다.
올바른 코드: Composition of many로 복원
// 잘못된 코드 — params 구조체 이해 오류
this.on('submit', TravelRequests, async (req) => {
const ID = req.params; // ❌ 배열이 아닌 것으로 착각
const { ID } = req.data; // ❌ data는 body, params는 URL 파라미터
// 올바른 코드
this.on('submit', TravelRequests, async (req) => {
const { ID } = req.params[0]; // ✅ params는 배열, 첫 번째 요소에서 키 추출
확인 항목:
[ ] schema.cds에 Enum, Association, Composition 모두 포함됨
[ ] $expand=items,department 쿼리가 정상 동작
[ ] 비용 항목 추가 시 totalAmount 자동 계산 확인
[ ] submit → approve 전체 플로우 테스트 완료
[ ] reject에 사유 없으면 에러 반환 확인
[ ] approvalLogs에 이력이 기록되는지 확인
[ ] DBeaver에서 테이블 구조 확인
[ ] Git 커밋 완료
Q&A 주제:
[ ] CDS의 SELECT.one vs SELECT 차이?
[ ] req.user가 비어있는 이유 (4일차 XSUAA 연동 후 채워짐)
[ ] Composition 삭제 시 Cascade Delete 동작 확인
오늘 배운 것:
┌─────────────────────────────────────────────┐
│ Association: 독립적 참조 (FK만 저장) │
│ Composition: 생명주기 공유 (부모 삭제 시 │
│ 자식도 삭제) │
│ │
│ Enum: 상태값의 타입 안전성 확보 │
│ │
│ Before/After Hook: │
│ 요청 전후에 검증/계산 로직 삽입 │
│ │
│ $expand: 연관 엔티티를 한 번에 조회 │
└─────────────────────────────────────────────┘
내일 할 것:
→ Fiori Elements로 UI 연결
→ Annotation으로 화면 레이아웃 제어
→ 로컬에서 풀스택 앱 완성
다음: [3일차] Fiori Elements UI + Annotation 마스터