개요: 이 글에서 다루는 내용
SAP BTP에서 CAP(Cloud Application Programming Model) Node.js 프로젝트를 시작할 때 가장 먼저 마주치는 고민은 "폴더를 어떻게 나누고, 어떤 파일을 어디에 두어야 하는가"입니다. CAP는 관례 기반(convention over configuration) 프레임워크이므로, 구조를 제대로 잡으면 설정 코드가 크게 줄어듭니다. 이 글은 가상의 물류 재고 도메인을 예제로 삼아 실무형 프로젝트 골격을 처음부터 만들어 봅니다.
- db / srv / app 3계층 폴더가 각각 담당하는 역할 이해
- cds 모델 파일과 서비스 핸들러의 배치 규칙 파악
- package.json의 cds 섹션과 .cdsrc.json으로 환경별 profile 분리
- 에러 처리·로깅·테스트까지 포함한 프로덕션 지향 구조 완성
시작 전에 알아두면 좋은 것
Node.js 기본 문법(모듈, async/await)과 npm 사용법을 알고 있으면 충분합니다. OData나 SAP HANA 경험은 없어도 따라올 수 있도록 구성했습니다. SQL의 기본 개념(테이블, 키, 관계)을 알고 있다면 CDS 모델 정의가 훨씬 빠르게 읽힙니다.
테스트 환경
이 예제는 @sap/cds 8.x와 Node.js 20 LTS 이상을 기준으로 합니다. 로컬 개발에는 SQLite(인메모리)를 쓰고, 배포 대상은 SAP BTP Cloud Foundry 환경 + SAP HANA Cloud를 가정합니다. 일반적으로 다음 순서로 도구를 준비합니다.
# 준비물 체크
node: ">=20 LTS"
npm: "10.x"
cds-dk: "npm i -g @sap/cds-dk" # cds 커맨드라인 도구
editor: "VS Code + SAP CDS Language Support 확장 권장"
db(local): "@cap-js/sqlite (devDependency)"
db(cloud): "SAP HANA Cloud + @sap/cds-hana"
cds --version으로 설치를 확인한 뒤 진행하세요. Business Application Studio를 쓰는 경우 cds-dk가 이미 포함된 dev space 템플릿을 선택하면 됩니다.
핵심 개념: CAP가 폴더를 읽는 방식
CAP 프로젝트를 3층 건물에 비유하면 이해가 쉽습니다. 지하(db/)는 데이터가 저장되는 창고, 1층(srv/)은 손님을 응대하는 카운터, 2층(app/)은 손님이 보는 전시 공간입니다. 손님(클라이언트)은 창고에 직접 들어갈 수 없고 반드시 카운터를 거칩니다. 이것이 CAP의 서비스 노출 원칙입니다.
db/— 도메인 모델(엔티티, 타입, 관계)을 담는.cds파일. 배포 시 테이블/뷰로 변환됩니다.srv/— 서비스 정의(.cds)와 커스텀 로직(.js). 파일 이름이 같으면(예:inventory-service.cds↔inventory-service.js) 핸들러가 자동으로 연결됩니다.app/— Fiori Elements 앱, 어노테이션 파일 등 UI 자산.test/— 통합/단위 테스트. CAP 관례 폴더는 아니지만 실무에서는 사실상 표준처럼 씁니다.
중요한 동작 원리는 모델 로딩 순서입니다. cds watch를 실행하면 CAP는 db → srv → app 순으로 .cds 파일을 전부 수집해 하나의 통합 모델(CSN)로 컴파일합니다. 즉 폴더 이름 자체가 설정입니다. 폴더 경로를 바꾸고 싶다면 package.json의 cds.folders로 재정의할 수 있지만, 일반적으로 기본 관례를 따르는 편이 팀 협업과 도구 호환성 면에서 유리합니다. 또 하나의 축은 profile입니다. 같은 프로젝트가 개발(SQLite), 하이브리드(로컬 코드 + 클라우드 서비스), 프로덕션(HANA)에서 다르게 동작하도록 [development], [hybrid], [production] profile로 설정을 분기합니다. 이 두 가지(관례 폴더 + profile)만 잡아도 프로젝트 구조화의 80%는 끝난 셈입니다.
실전 예제 1단계: 골격 생성과 도메인 모델
cds init stockflow로 프로젝트를 만들면 db/srv/app 폴더가 생성됩니다. 가상의 창고 재고 이동 시나리오로 모델을 정의해 봅니다.
// db/schema.cds
namespace nova.stockflow;
using { cuid, managed } from '@sap/cds/common';
entity Depots : cuid, managed {
depotCode : String(10);
region : String(40);
items : Composition of many StockLines on items.depot = $self;
}
entity StockLines : cuid {
depot : Association to Depots;
material : String(30);
quantity : Integer;
minLevel : Integer default 10;
}
entity MoveRequests : cuid, managed {
fromDepot : Association to Depots;
toDepot : Association to Depots;
material : String(30);
quantity : Integer;
status : String(12) default 'OPEN';
}
// srv/stock-service.cds
using { nova.stockflow as db } from '../db/schema';
service StockService @(path: '/stock') {
entity Depots as projection on db.Depots;
entity MoveRequests as projection on db.MoveRequests;
@readonly entity StockLines as projection on db.StockLines;
action approveMove(requestId : UUID) returns String;
}
cds watch를 실행하면 인메모리 SQLite로 즉시 OData v4 서비스가 뜹니다. 초기 데이터는 db/data/nova.stockflow-Depots.csv처럼 네임스페이스-엔티티명.csv 관례로 두면 자동 적재됩니다.
실전 예제 2단계: 핸들러 배치와 에러·로깅
서비스 정의와 같은 이름의 JS 파일을 srv/에 두면 CAP가 자동으로 연결합니다. 검증 실패는 req.reject, 경고성 로그는 cds.log를 사용합니다.
// srv/stock-service.js
const cds = require('@sap/cds');
const LOG = cds.log('stock-service');
module.exports = class StockService extends cds.ApplicationService {
init() {
const { MoveRequests, StockLines } = this.entities;
// 생성 전 검증: 수량과 창고 재고 확인
this.before('CREATE', MoveRequests, async (req) => {
const { fromDepot_ID, material, quantity } = req.data;
if (!quantity || quantity <= 0)
return req.reject(400, '이동 수량은 1 이상이어야 합니다.');
const line = await SELECT.one.from(StockLines)
.where({ depot_ID: fromDepot_ID, material });
if (!line || line.quantity < quantity) {
LOG.warn('재고 부족 요청 차단', { material, quantity });
return req.reject(409, `재고 부족: ${material}`);
}
});
// 커스텀 액션: 승인 처리
this.on('approveMove', async (req) => {
const { requestId } = req.data;
const updated = await UPDATE(MoveRequests, requestId)
.with({ status: 'APPROVED' });
if (!updated) return req.error(404, '요청을 찾을 수 없습니다.');
LOG.info('이동 승인 완료', { requestId });
return 'APPROVED';
});
return super.init();
}
};
핵심 포인트: req.reject는 트랜잭션을 롤백하며 HTTP 상태 코드를 그대로 클라이언트에 전달합니다. cds.log는 프로덕션에서 JSON 포맷으로 전환되어 BTP Application Logging과 자연스럽게 연동되므로 console.log 대신 쓰는 것이 권장됩니다.
실전 예제 3단계: profile 분리, 보안, 테스트
환경별 설정은 package.json의 cds 섹션 또는 .cdsrc.json에 둡니다. 실무에서는 "배포와 무관한 도구 설정은 .cdsrc.json, 런타임 필수 설정은 package.json"으로 나누는 팀이 많습니다.
// package.json 중 cds 섹션
{
"cds": {
"requires": {
"db": {
"[development]": { "kind": "sqlite", "credentials": { "url": ":memory:" } },
"[production]": { "kind": "hana", "impl": "@sap/cds-hana" }
},
"auth": {
"[development]": { "kind": "mocked", "users": { "mina": { "roles": ["StockPlanner"] } } },
"[production]": { "kind": "xsuaa" }
}
}
}
}
// srv/stock-service.cds 에 권한 추가
annotate StockService with @(requires: 'StockPlanner');
annotate StockService.MoveRequests with @restrict: [
{ grant: ['READ','CREATE'], to: 'StockPlanner' }
];
테스트는 cds.test로 서버를 띄우지 않고도 통합 검증이 가능합니다.
// test/stock-service.test.js
const cds = require('@sap/cds');
const { GET, POST, expect } = cds.test(__dirname + '/..');
describe('StockService', () => {
it('재고 부족 시 409를 반환한다', async () => {
await expect(POST('/stock/MoveRequests', {
material: 'PLT-900', quantity: 99999
}, { auth: { username: 'mina' } })).to.be.rejectedWith(/409/);
});
it('창고 목록을 조회한다', async () => {
const { status } = await GET('/stock/Depots', { auth: { username: 'mina' } });
expect(status).to.equal(200);
});
});
배포 전 cds build --production으로 HANA 아티팩트(hdbtable 등)가 gen/ 폴더에 생성되는지 확인하고, 하이브리드 검증은 cds bind 후 cds watch --profile hybrid로 실제 클라우드 서비스 인스턴스에 연결해 수행합니다. 성능 측면에서는 대량 조회 엔티티에 @readonly를 붙여 불필요한 draft/쓰기 경로를 차단하고, N+1을 유발하는 루프 내 SELECT 대신 SELECT ... where in 일괄 조회를 쓰는 것이 일반적입니다.
자주 만나는 함정 FAQ
- Q1. 핸들러 JS 파일이 실행되지 않아요. — 가장 흔한 원인은 파일명 불일치입니다.
srv/stock-service.cds의 핸들러는srv/stock-service.js여야 자동 연결됩니다. 다른 이름을 쓰려면 서비스 정의에@impl: './lib/custom.js'어노테이션을 명시해야 합니다. - Q2. CSV 초기 데이터가 로드되지 않아요. — 파일명이
네임스페이스-엔티티명.csv형식인지, 첫 줄 헤더의 컬럼명이 CDS 요소명과 일치하는지(관계 필드는depot_ID처럼_ID접미사) 확인하세요. 또한 production profile에서는db/data/가 초기 배포용으로만 쓰이므로 운영 데이터 관리 용도로 쓰면 안 됩니다. - Q3. 로컬에서는 되는데 배포하면 인증 오류가 납니다. — development의 mocked auth와 production의 xsuaa는 완전히 다른 경로입니다.
xs-security.json에 role-template이 정의되어 있는지, mta.yaml에서 xsuaa 인스턴스가 srv 모듈에 바인딩되어 있는지 확인하세요. - Q4. db/와 srv/에 모델을 어디까지 나눠야 하나요? — 영속 엔티티는 db/, projection과 어노테이션은 srv/가 원칙입니다. UI 어노테이션까지 srv에 몰리면 비대해지므로
srv/annotations/하위 파일로 분리한 뒤 using으로 참조하는 패턴이 널리 쓰입니다.
여기서 더 나아가기
구조가 잡혔다면 다음 주제로 확장해 보세요. (1) mta.yaml 기반 멀티타깃 애플리케이션 배포와 HDI 컨테이너 이해, (2) Fiori Elements 앱을 app/ 폴더에 추가하고 draft 편집 활성화, (3) 이벤트 메시징(cds.on('...') + SAP Event Mesh)으로 창고 간 비동기 연계, (4) 멀티테넌시(mtxs)와 확장성 설계. 특히 배포 파이프라인(CI/CD)과 하이브리드 테스트를 조기에 도입하면 구조화의 효과가 배가됩니다.
댓글 0
아직 댓글이 없습니다.