목표: BTP가 왜 존재하는지 이해하고, 실제로 OData API가 동작하는 것을 눈으로 확인한다.
시나리오: 5일간 만들 "출장비 승인 앱"의 뼈대를 세운다.
소요 시간: 이론 2.5시간 + 실습 2.5시간
이론:
SAP의 두 가지 세계(온프레미스 vs 클라우드)와 BTP의 위치 이해
Clean Core 전략이 왜 중요한지 설명할 수 있다
BTP 4대 기둥과 오늘 배울 영역의 위치를 파악한다
Cloud Foundry와 Kyma의 차이를 한 줄로 설명할 수 있다
실습:
CAP 프로젝트 생성 및 CDS Hello World 작성
cds watch로 로컬 서버 실행
OData 엔드포인트 브라우저에서 확인
Git에 1일차 결과 커밋
SAP S/4HANA를 기업에서 도입하면 거의 반드시 커스터마이징이 필요합니다.
전통적인 방법은 ABAP으로 S/4HANA 내부를 직접 수정하는 것이었습니다.
[전통적 방법 — In-System Customization]
S/4HANA Core
├── 표준 모듈 (FI, MM, SD...)
├── 커스텀 ABAP 코드 (Z-program, User Exit, BAdI...)
│ ↑
│ 여기에 사업 요구사항을 직접 구현
└── 표준 + 커스텀 코드가 뒤섞임
이 방법의 문제:
| 문제 | 설명 |
|---|---|
| 업그레이드 공포 | SAP 버전 업그레이드 시 커스텀 코드 전체 재검증 필요 |
| 복잡도 폭발 | 10년 된 시스템은 누가 뭘 만들었는지 아무도 모름 |
| 기술 종속성 | ABAP 개발자만 유지보수 가능 → 인력 확보 어려움 |
| 클라우드 전환 불가 | 수정된 시스템을 SaaS로 마이그레이션 불가 |
[BTP 방식 — Side-by-Side Extension]
S/4HANA Core (표준 유지 — Clean!)
├── 표준 모듈 (FI, MM, SD...)
└── 표준 API만 노출 (OData, RFC)
↕ (API 통신)
SAP BTP (확장 레이어)
├── 커스텀 비즈니스 로직
├── 커스텀 UI
└── 워크플로우 자동화
핵심 메시지
"S/4HANA는 건드리지 않는다. 확장은 BTP 위에서 API로 연결한다."
이것이 Clean Core 전략입니다.
Clean Core의 장점:

이 강좌에서 다루는 영역:
BTP 위에서 앱을 실행하는 두 가지 환경이 있습니다.
| 구분 | Cloud Foundry (CF) | Kyma (Kubernetes 기반) |
|---|---|---|
| 패러다임 | PaaS (Platform as a Service) | Container Orchestration |
| 배포 단위 | Buildpack 기반 앱 | Docker 컨테이너 / Helm Chart |
| 진입 장벽 | 낮음 (cf push 한 줄) | 높음 (K8s 지식 필요) |
| 유연성 | 중간 | 매우 높음 |
| 비용 효율 | 소규모에 적합 | 대규모에 유리 |
| 이 강좌 | 사용 | 개념만 소개 |
오늘의 결론: Cloud Foundry로 개발을 시작하고, Kyma는 "더 알고 싶을 때"의 다음 단계 강좌를 고민해 보겠습니다.
CAP = Cloud Application Programming Model
SAP이 만든 오픈소스 풀스택 개발 프레임워크입니다.
// OData API를 직접 구현한다면... (실제로는 수백 줄)
const express = require('express');
const app = express();
// OData 메타데이터 수동 정의
app.get('/TravelRequests/$metadata', (req, res) => {
res.set('Content-Type', 'application/xml');
res.send(`<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx" Version="4.0">
<edmx:DataServices>
<Schema Namespace="TravelService" ...>
<!-- 수동으로 모든 엔티티, 타입, 관계 정의 -->
...약 200줄...
</Schema>
</edmx:DataServices>
</edmx:Edmx>`);
});
// 각 엔드포인트 수동 구현
app.get('/TravelRequests', (req, res) => { ... });
app.get('/TravelRequests(:id)', (req, res) => { ... });
app.post('/TravelRequests', (req, res) => { ... });
// ... 계속
// CAP을 사용하면... (entities.cds — 단 7줄!)
entity TravelRequests {
key ID : UUID;
title : String;
amount : Decimal;
status : String;
}
// service.js — CRUD 자동 완성, 추가 로직만 작성
module.exports = cds.service.impl(async function() {
// 별도 구현 없이도 CRUD 전부 동작!
});
CAP의 핵심 가치: 반복 코드(Boilerplate)를 제거하고 비즈니스 로직에만 집중하게 해줍니다.
CAP이 자동으로 해주는 것:
BAS Terminal을 열고 아래를 실행합니다.
# 프로젝트 디렉토리로 이동
cd /home/user/projects/sap-btp-travel-expense
# CAP 프로젝트 초기화
cds init travel-expense-app
cd travel-expense-app
# 의존성 설치
npm install
# 생성된 구조 확인
ls -la
생성된 폴더 구조:
travel-expense-app/
├── app/ ← Fiori UI가 들어갈 곳 (3일차에 사용)
├── db/ ← 데이터 모델 (CDS 파일)
├── srv/ ← 서비스 레이어 (API 정의, 비즈니스 로직)
├── package.json
└── .cdsrc.json ← CAP 설정 파일

구조 이해 포인트
db/= 무엇을 저장하나 (테이블 정의)
srv/= 무엇을 API로 노출하나 (서비스 정의)
app/= 사용자에게 어떻게 보여주나 (UI)
db/schema.cds 파일을 새로 만듭니다. (없으면 생성)
// db/schema.cds
namespace com.travel;
using { cuid, managed } from '@sap/cds/common';
// ──────────────────────────────────────────
// 출장비 신청 엔티티
// ──────────────────────────────────────────
entity TravelRequests : cuid, managed {
title : String(100) @mandatory;
description : String(500);
destination : String(100) @mandatory;
departureDate : Date @mandatory;
returnDate : Date @mandatory;
amount : Decimal(10,2) @mandatory;
currency : String(3) default 'KRW';
status : String(20) default 'Draft';
requester : String(100);
}
코드 해설:
cuid: 자동으로 UUID를 Primary Key로 추가하는 Aspectmanaged:createdAt,createdBy,modifiedAt,modifiedBy자동 추가@mandatory: null 불가 제약 어노테이션Decimal(10,2): 소수점 2자리까지의 숫자 (금액에 적합)
srv/travel-service.cds 파일을 만듭니다.
// srv/travel-service.cds
using com.travel from '../db/schema';
// 외부에 노출할 서비스 정의
service TravelService @(path: '/travel') {
// TravelRequests 엔티티를 API로 노출
entity TravelRequests as projection on travel.TravelRequests;
}
코드 해설:
service TravelService @(path: '/travel'):/travel경로로 OData 서비스 생성projection on: DB 엔티티를 그대로 노출 (필요 시 컬럼 선택/제외 가능)
# 프로젝트 루트에서
cds watch
예상 출력:
cds serve all --with-mocks --in-memory?
...
[cds] - connect to db > sqlite { database: ':memory:' }
[cds] - serving TravelService { path: '/travel', impl: 'srv/travel-service.js' }
[cds] - server listening on { url: 'http://localhost:4004' }
[cds] - launched at 01/01, 11:00:00, version: 7.x.x, in: 1.234s
[cds] - [ terminate with ^C ]

cds watch의 마법
파일을 저장할 때마다 서버를 자동으로 재시작합니다.
개발 중에는 이 명령어를 항상 켜두세요.
BAS에서 실행 중인 서버에 접속합니다.
방법 1 (BAS):
오른쪽 하단 팝업 "A service is listening to port 4004" → "Open in New Tab" 클릭
방법 2 (VS Code 로컬):
브라우저에서 http://localhost:4004 직접 접속
확인할 화면:
Welcome to cds.services
─────────────────────────────────
TravelService
- /travel/$metadata ← OData 메타데이터
- /travel/TravelRequests ← 데이터 목록 API

각 링크를 클릭하여 응답을 확인해보세요.
/travel/TravelRequests 응답 (데이터 없음):
{
"@odata.context": "$metadata#TravelRequests",
"value": []
}

/travel/$metadata 응답 (OData 메타데이터 XML):
<EntityType Name="TravelRequests">
<Key><PropertyRef Name="ID"/></Key>
<Property Name="ID" Type="Edm.Guid" Nullable="false"/>
<Property Name="title" Type="Edm.String" MaxLength="100"/>
<Property Name="amount" Type="Edm.Decimal" .../>
...
</EntityType>

축하합니다! CDS 파일 몇 줄로 완전한 OData V4 API가 만들어졌습니다.
db/data/ 폴더를 만들고 CSV 파일을 추가합니다.
mkdir -p db/data
db/data/com.travel-TravelRequests.csv 파일 생성:
ID,title,description,destination,departureDate,returnDate,amount,currency,status,requester
"11111111-1111-1111-1111-111111111111","도쿄 고객사 미팅","Q4 제품 협의","도쿄, 일본","2025-09-01","2025-09-03",850000,"KRW","Draft","장용기"
"22222222-2222-2222-2222-222222222222","싱가포르 컨퍼런스","SAP TechEd 참가","싱가포르","2025-10-15","2025-10-18",2100000,"KRW","Submitted","김연식"
"33333333-3333-3333-3333-333333333333","서울 본사 출장","월간 전략 회의","서울","2025-08-20","2025-08-20",50000,"KRW","Approved","이우철"
파일명 규칙:
[namespace]-[EntityName].csv형식이어야 CAP이 자동으로 인식합니다.
cds watch가 실행 중이면 파일 저장 시 자동으로 데이터를 로드합니다.
브라우저에서 /travel/TravelRequests 재접속 결과:
{
"@odata.context": "$metadata#TravelRequests",
"value": [
{
"ID": "11111111-...",
"title": "도쿄 고객사 미팅",
"destination": "도쿄, 일본",
"amount": 850000,
"status": "Draft",
...
},
...
]
}

test/travel.http 파일을 만듭니다.
mkdir -p test
### travel.http
### ──────────────────────────────────────────
### 변수 설정
@baseUrl = http://localhost:4004/travel
### 1. 전체 목록 조회
GET {{baseUrl}}/TravelRequests
Accept: application/json
###
### 2. 특정 항목 조회
GET {{baseUrl}}/TravelRequests(11111111-1111-1111-1111-111111111111)
Accept: application/json
###
### 3. 필터 조회 ($filter)
GET {{baseUrl}}/TravelRequests?$filter=status eq 'Draft'
Accept: application/json
###
### 4. 정렬 및 페이징 ($orderby, $top, $skip)
GET {{baseUrl}}/TravelRequests?$orderby=amount desc&$top=2&$skip=0
Accept: application/json
###
### 5. 특정 컬럼만 조회 ($select)
GET {{baseUrl}}/TravelRequests?$select=title,amount,status
Accept: application/json
###
### 6. 새 출장 신청 등록 (POST)
POST {{baseUrl}}/TravelRequests
Content-Type: application/json
{
"title": "부산 파트너사 미팅",
"destination": "부산",
"departureDate": "2025-11-01",
"returnDate": "2025-11-01",
"amount": 120000,
"currency": "KRW",
"requester": "박민준"
}
###
### 7. 항목 수정 (PATCH)
PATCH {{baseUrl}}/TravelRequests(11111111-1111-1111-1111-111111111111)
Content-Type: application/json
{
"status": "Submitted"
}
###
### 8. 항목 삭제 (DELETE)
DELETE {{baseUrl}}/TravelRequests(11111111-1111-1111-1111-111111111111)
###
BAS에서 실행 방법:
각 ### 구분 블록 위의 "Send Request" 버튼 클릭
VS Code에서 실행 방법:
REST Client 익스텐션 설치 후 동일하게 "Send Request" 클릭

OData 쿼리 옵션 실습 포인트
$filter: SQL의 WHERE와 동일$orderby: SQL의 ORDER BY와 동일$top/$skip: 페이징 처리$select: SQL의 SELECT 컬럼 지정이 옵션들은 Fiori UI가 자동으로 생성해서 보내는 것들입니다.
srv/travel-service.js 파일을 만들어 간단한 유효성 검사를 추가합니다.
// srv/travel-service.js
const cds = require('@sap/cds');
module.exports = cds.service.impl(async function () {
const { TravelRequests } = this.entities;
// ── Before Hook: 생성 전 유효성 검사 ──────────────────
this.before('CREATE', TravelRequests, async (req) => {
const { departureDate, returnDate, amount } = req.data;
// 출발일이 귀국일보다 늦으면 에러
if (departureDate > returnDate) {
return req.error(400, '출발일은 귀국일보다 이전이어야 합니다.');
}
// 금액이 0 이하면 에러
if (amount <= 0) {
return req.error(400, '출장비 금액은 0보다 커야 합니다.');
}
});
// ── After Hook: 생성 후 처리 ───────────────────────────
this.after('CREATE', TravelRequests, (result, req) => {
console.log(`✅ 새 출장 신청 등록: ${result.title} (신청자: ${result.requester})`);
});
// ── Custom Action: 상태 변경 (제출) ───────────────────
this.on('submit', async (req) => {
const { ID } = req.data;
const request = await SELECT.one.from(TravelRequests).where({ ID });
if (!request) {
return req.error(404, `ID가 ${ID}인 출장 신청을 찾을 수 없습니다.`);
}
if (request.status !== 'Draft') {
return req.error(409, `현재 상태(${request.status})에서는 제출할 수 없습니다.`);
}
await UPDATE(TravelRequests).set({ status: 'Submitted' }).where({ ID });
return { message: '제출이 완료되었습니다.', status: 'Submitted' };
});
});
서비스 정의에 Action 추가 (srv/travel-service.cds):
// srv/travel-service.cds
using com.travel from '../db/schema';
service TravelService @(path: '/travel') {
entity TravelRequests as projection on travel.TravelRequests;
// 상태를 'Submitted'로 변경하는 Custom Action
action submit(ID: UUID) returns { message: String; status: String; };
}
테스트 — test/travel.http에 추가:
### 9. Custom Action: 제출 처리
POST {{baseUrl}}/TravelRequests(22222222-2222-2222-2222-222222222222)/submit
Content-Type: application/json
{}
###
### 10. 유효성 검사 실패 테스트 (날짜 역전)
POST {{baseUrl}}/TravelRequests
Content-Type: application/json
{
"title": "잘못된 날짜 테스트",
"destination": "서울",
"departureDate": "2025-11-05",
"returnDate": "2025-11-01",
"amount": 100000
}
###
# 프로젝트 루트에서
git add .
git commit -m "day1: CAP 프로젝트 초기 설정 및 TravelRequests 엔티티/서비스 정의"
git push origin main
# 다음 날을 위한 브랜치 생성
git checkout -b day2-start
git push origin day2-start
git checkout main
이 섹션은 에러를 직접 만들고 고치는 훈련입니다.
db/schema.cds에서 세미콜론을 하나 지워보세요.
// 잘못된 코드 — 세미콜론 없음
entity TravelRequests : cuid, managed {
title : String(100) @mandatory // ← 여기 세미콜론 없음
amount : Decimal(10,2);
}
에러 메시지 읽기:
[ERROR] db/schema.cds:5:3: Extraneous input 'amount' expecting...
5:3 = 5번 줄, 3번째 열에서 에러 발생고치기: 세미콜론 복원
db/data/ 의 CSV 파일명을 TravelRequest.csv (s 없음)로 바꾸면?
[WARN] can't find a match for TravelRequest.csv
데이터가 로드되지 않고 목록이 비어있습니다.
고치기: 파일명을 com.travel-TravelRequests.csv 로 복원
확인 항목:
[ ] cds watch 실행 시 에러 없이 서버 시작됨
[ ] 브라우저에서 /travel/TravelRequests 로 3개 Mock 데이터 확인
[ ] HTTP 파일로 CRUD 테스트 성공
[ ] submit Action 호출 성공
[ ] 유효성 검사 실패 시 에러 메시지 반환 확인
[ ] Git 커밋 완료
Q&A 주제 (강사에게 물어보기):
[ ] cuid vs UUID를 직접 정의하는 것의 차이?
[ ] Aspect(cuid, managed)를 직접 만들려면?
[ ] OData V2와 V4의 실질적 차이?
오늘 배운 것:
┌─────────────────────────────────────────────┐
│ BTP = S/4HANA 외부에서 API로 확장하는 플랫폼│
│ │
│ CAP = CDS(데이터 모델) + JS(로직) 조합으로 │
│ OData API를 자동 생성하는 프레임워크 │
│ │
│ cds watch = 저장 즉시 반영되는 개발 루프 │
│ │
│ OData 쿼리 = $filter, $orderby, $select... │
└─────────────────────────────────────────────┘
내일 할 것:
→ CDS 모델을 더 정교하게 (Association, Enum)
→ Mock 데이터로 복잡한 API 쿼리 마스터
→ 로컬 SQLite → HANA Cloud 연결 준비
다음: [2일차] CDS 고급 모델링 + 완전한 데이터 레이어 구축