SAP BTP - Day2. CDS 고급 모델링

이우철·2026년 4월 25일

SAP_BTP

목록 보기
3/11

[2일차] CDS 고급 모델링 + 완전한 데이터 레이어 구축

목표: 실무 수준의 데이터 모델을 설계하고, 다양한 OData 쿼리와 비즈니스 로직을 완성한다.
시나리오: 출장비 앱의 데이터 구조를 완성 — 신청, 카테고리, 승인 이력을 연결한다.
소요 시간: 이론 2시간 + 실습 3시간


2일차 학습 목표

이론:
  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 커밋

이론 세션 (2시간)


이론 1. CDS 타입 시스템 완전 정리

CDS에서 사용할 수 있는 타입들을 정리합니다.

기본 타입

CDS 타입OData 타입DB 타입사용 예
UUIDEdm.GuidCHAR(36)Primary Key
String(n)Edm.StringNVARCHAR(n)일반 텍스트
IntegerEdm.Int32INTEGER정수
Decimal(p,s)Edm.DecimalDECIMAL금액, 비율
DateEdm.DateDATE날짜 (시간 없음)
DateTimeEdm.DateTimeOffsetTIMESTAMP날짜+시간
BooleanEdm.BooleanBOOLEAN참/거짓
LargeStringEdm.StringNCLOB긴 텍스트

Enum 타입 — 상태값 관리

// 잘못된 방법: 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'; // 타입 안전성 확보!
}

Structured Type (복합 타입)

// 여러 필드를 하나의 타입으로 묶기
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 컬럼으로 생성

이론 2. Associations vs Compositions — 핵심 차이

가장 많이 헷갈리는 개념입니다. 명확히 구분해봅니다.

Association (연관) — 독립적 관계

독립적으로 존재하는 엔티티 간의 참조 관계
부모가 삭제되어도 자식은 살아있음

예: TravelRequest ─── refers to ─── Department
    (출장 신청)                      (부서)

    부서가 폐지되어도 출장 신청 기록은 남아있어야 함
    → Association 사용
entity TravelRequests {
  ...
  department   : Association to Departments; // FK만 저장
}

entity Departments {
  key ID   : UUID;
      name : String;
}

Composition (구성) — 부모-자식 생명주기 공유

부모 엔티티가 삭제되면 자식도 함께 삭제
자식 단독으로는 존재 의미 없음

예: 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 (자식 엔티티)

이론 3. CAP 이벤트 훅 완전 이해

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()    // 정보 메시지 추가

이론 4. CDS View — DB 뷰를 코드로

집계 데이터나 계산된 필드를 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;
}

실습 세션 (3시간)


실습 1. 멀티 엔티티 데이터 모델 완성

1-1. 전체 스키마 재설계

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); // 처리자
}

1-2. 서비스 정의 업데이트

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 방식)

1-3. 서비스 로직 완성

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;
  });

});

1-4. Mock 데이터 추가

# 부서 데이터
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 컬럼 제거 

1-5. $expand로 연관 데이터 조회 테스트

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": "예산 초과로 반려합니다. 항공권 등급을 낮추어 재신청해 주세요."
}

###

실습 2. DB에서 직접 쿼리 확인 (DBeaver 연동)

💡 왜 DBeaver인가?
CAP이 로컬에서 SQLite를 사용하기 때문에, DBeaver로 연결해서 실제 생성된 테이블을 눈으로 보면 CDS 모델이 어떻게 DB로 변환되는지 이해가 훨씬 빠릅니다.

2-1. SQLite 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

2-2. DBeaver에서 SQLite 연결

  1. DBeaver 실행 → 새 연결 (SQLite 선택)
  2. Database File: 프로젝트경로/db/travel.sqlite
  3. 연결 후 테이블 탐색:

-- 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의 department Association은 DB에서 department_ID 컬럼으로 변환됨
  • managed Aspect는 createdAt, createdBy, modifiedAt, modifiedBy 컬럼 자동 생성
  • cuid Aspect는 UUID 형식의 ID 컬럼 자동 생성

실습 3. 상태 전이 다이어그램 확인

아래 상태 흐름이 코드에서 어떻게 구현되는지 연결해서 확인합니다.

상태 전이도:

  ┌─────────┐
  │  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 확인

실습 4. Git 커밋

# .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

의도적 에러 실습

에러 1: Association vs Composition 혼동

itemsComposition 대신 Association으로 바꾸면?

// 잘못된 코드
items : Association to many ExpenseItems on items.request = $self;

$expand=items로 조회해 보면 데이터가 나오지 않거나, 신청 삭제 시 orphan 데이터가 남습니다.

올바른 코드: Composition of many로 복원


에러 2: Bound Action 파라미터 오류

// 잘못된 코드 — 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는 배열, 첫 번째 요소에서 키 추출

2일차 마무리 체크

확인 항목:
[ ] 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 동작 확인

2일차 핵심 정리

오늘 배운 것:
┌─────────────────────────────────────────────┐
│  Association: 독립적 참조 (FK만 저장)        │
│  Composition: 생명주기 공유 (부모 삭제 시    │
│               자식도 삭제)                  │
│                                             │
│  Enum: 상태값의 타입 안전성 확보             │
│                                             │
│  Before/After Hook:                         │
│  요청 전후에 검증/계산 로직 삽입             │
│                                             │
│  $expand: 연관 엔티티를 한 번에 조회         │
└─────────────────────────────────────────────┘

내일 할 것:
→ Fiori Elements로 UI 연결
→ Annotation으로 화면 레이아웃 제어
→ 로컬에서 풀스택 앱 완성

다음: [3일차] Fiori Elements UI + Annotation 마스터

profile
개발 정리 공간 - 업무일때도 있고, 공부일때도 있고...

0개의 댓글