개요 및 이 글에서 다루는 것
SAP Cloud Application Programming Model(CAP)로 서비스를 만들다 보면 표준 CRUD로는 표현이 어려운 비즈니스 오퍼레이션이 반드시 등장합니다. 예를 들어 구매 요청 승인, 재고 가용성 조회, 주문 취소 같은 동작은 단순 INSERT/UPDATE로 담기 애매합니다. CAP은 이런 경우를 위해 OData의 action과 function을 CDS 문법으로 노출합니다. 이 글은 두 개념의 차이를 세 가지 축으로 정리하고 Node.js 런타임 기준으로 실제 구현·에러 처리·테스트까지 다룹니다.
- Action과 Function의 HTTP 매핑(POST vs GET) 이해
- 부수 효과(side-effect) 유무에 따른 선택 기준
- Bound / Unbound 형태별 CDS 선언 방법
- Node.js 서비스 핸들러에서 파라미터 검증·로깅·트랜잭션 처리
- vitest 기반 단위 테스트 및 권한 제어
사전에 알아두면 좋은 것
이 글은 CAP for Node.js 사용 경험이 조금이라도 있는 개발자를 대상으로 합니다. CDS 엔티티 선언, @sap/cds의 서비스 핸들러(this.on, this.before) 구조, OData v4 URL 규칙($batch, $expand)에 대한 기본 감각이 있으면 좋습니다. Node.js 18 이상, npm 스크립트 실행 경험도 필요합니다.
실행 환경과 준비물
예제는 아래 환경 기준으로 검증했습니다. 버전이 다르면 일부 로그 포맷이나 에러 메시지가 달라질 수 있습니다.
- Node.js 20.11 LTS
- @sap/cds 7.9.x (CAP Node.js runtime)
- @sap/cds-dk 7.9.x (CLI)
- SQLite in-memory (개발 프로파일) / SAP HANA Cloud (프로덕션 프로파일)
- vitest 1.6.x (테스트), supertest 6.x (HTTP 검증)
프로젝트 스캐폴딩은 다음과 같이 준비합니다.
npm init -y
npm add @sap/cds express
npm add -D @sap/cds-dk vitest supertest sqlite3
npx cds init --add sample
CAP은 개발 프로파일에서 cds watch로 즉시 서비스가 뜨며, OData 메타데이터는 /service-path/$metadata에서 확인할 수 있습니다. Action·Function은 이 metadata XML에도 각각 <Action>, <Function>로 노출됩니다.
핵심 개념 — 세 가지 차이 축
Action과 Function을 구분하는 규칙은 표면적으로는 하나(HTTP 메서드)지만, 실제로는 세 가지 축이 얽혀 있습니다. 순서대로 살펴봅니다.
축 1. HTTP 메서드와 캐시 가능성 — Function은 GET, Action은 POST로 매핑됩니다. GET은 프록시·CDN 계층에서 캐시될 수 있어야 하므로 같은 입력에 같은 출력(idempotent)이 강하게 권장됩니다. Action은 그 반대로 상태 변경을 전제로 하며, 브라우저나 게이트웨이가 임의로 재시도해도 안전하다고 보장할 수 없습니다.
축 2. 부수 효과(side-effect) — Function은 데이터베이스 쓰기, 외부 시스템 호출로 인한 상태 변경이 없어야 한다는 규범을 따릅니다. "조회하면서 감사 로그를 남긴다" 정도는 회색 영역이지만, 주문 상태를 바꾼다거나 재고를 차감한다면 반드시 Action으로 설계합니다. 비유하면 Function은 은행 잔액 조회, Action은 이체 실행에 가깝습니다.
축 3. Bound vs Unbound — 특정 엔티티 인스턴스에 붙는지 여부입니다. PurchaseOrders에 바인딩된 Action cancel()은 URL이 /PurchaseOrders(ID=...)/Service.cancel 형태가 되고, 핸들러 안에서 req.params[0]로 대상 키를 받습니다. Unbound는 서비스 루트에 붙어 /Service/checkStock(sku='X')처럼 호출됩니다. Bound 형태는 권한·상태 검증을 대상 레코드와 함께 처리할 수 있어 도메인 로직에 잘 맞습니다.
결정 트리: (1) 상태를 바꾸는가? → Action. (2) 안 바꾸는데 파라미터가 필요한가? → Function. (3) 특정 레코드 대상인가? → Bound.
실전 예제 1단계 — 기본형 선언과 호출
가장 단순한 형태부터 시작합니다. 구매 발주(PurchaseOrder) 서비스에 재고 조회 Function과 승인 Action을 각각 하나씩 두는 시나리오입니다. 서비스 정의는 CDS로 작성합니다.
// srv/procurement-service.cds
using { managed } from '@sap/cds/common';
namespace acme.procurement;
entity PurchaseOrders : managed {
key ID : UUID;
supplierCode : String(20);
totalAmount : Decimal(15,2);
status : String(10) default 'DRAFT';
}
service ProcurementService {
entity Orders as projection on PurchaseOrders actions {
action approve(comment : String) returns Orders;
};
function checkStock(sku : String, warehouse : String)
returns { available : Integer; leadTimeDays : Integer };
}
핸들러는 서비스 클래스에서 등록합니다.
// srv/procurement-service.js
const cds = require('@sap/cds');
module.exports = class ProcurementService extends cds.ApplicationService {
init() {
const { Orders } = this.entities;
this.on('checkStock', async (req) => {
const { sku, warehouse } = req.data;
// 실제로는 외부 WMS 호출; 여기서는 모의값
return { available: 42, leadTimeDays: 3 };
});
this.on('approve', Orders, async (req) => {
const [{ ID }] = req.params;
await UPDATE(Orders).set({ status: 'APPROVED' }).where({ ID });
return SELECT.one.from(Orders).where({ ID });
});
return super.init();
}
};
호출은 각각 다음과 같이 이뤄집니다.
// Function: GET, 쿼리 파라미터로 인자 전달
GET /odata/v4/procurement/checkStock(sku='A-001',warehouse='SEL-1')
// Action: POST, JSON body
POST /odata/v4/procurement/Orders(ID=...)/ProcurementService.approve
Content-Type: application/json
{ "comment": "예산 범위 내 승인" }
실전 예제 2단계 — 검증, 에러, 감사 로그
실무 서비스는 파라미터 검증과 구조화된 에러 응답이 필수입니다. CAP은 req.error()와 req.reject()로 OData 표준 에러 포맷을 만들어 줍니다. Action에서는 트랜잭션 롤백까지 자동 연결됩니다.
this.before('approve', Orders, async (req) => {
const [{ ID }] = req.params;
const order = await SELECT.one.from(Orders).where({ ID });
if (!order) return req.reject(404, `Order ${ID} not found`);
if (order.status !== 'DRAFT') {
return req.reject(409, `Only DRAFT orders can be approved. current=${order.status}`);
}
if (order.totalAmount > 100000 && !req.user.is('senior-approver')) {
return req.reject(403, 'Amount above 100k requires senior approver');
}
});
this.on('approve', Orders, async (req) => {
const [{ ID }] = req.params;
const log = cds.log('procurement');
const tx = cds.tx(req);
await tx.run(UPDATE(Orders).set({
status: 'APPROVED',
modifiedBy: req.user.id
}).where({ ID }));
await tx.run(INSERT.into('acme.procurement.AuditLog').entries({
ID: cds.utils.uuid(),
orderID: ID,
action: 'APPROVE',
actor: req.user.id,
comment: req.data.comment,
at: new Date().toISOString()
}));
log.info('order approved', { ID, actor: req.user.id });
return SELECT.one.from(Orders).where({ ID });
});
Function 쪽도 방어 코드를 추가합니다. GET이라 body가 없으므로 쿼리 파라미터 유효성 검사가 특히 중요합니다.
this.on('checkStock', async (req) => {
const { sku, warehouse } = req.data;
if (!sku || !/^[A-Z]-\d{3,6}$/.test(sku)) {
return req.reject(400, 'Invalid SKU format');
}
// 조회 전용: 절대 write 하지 않음
const row = await SELECT.one.from('acme.wms.Stock')
.where({ sku, warehouse });
return {
available: row?.qty ?? 0,
leadTimeDays: row?.leadTimeDays ?? 14
};
});
실전 예제 3단계 — 프로덕션 관점: 성능, 보안, 테스트
프로덕션에서는 (1) 권한 어노테이션, (2) 캐시 헤더, (3) 회귀 테스트를 반드시 챙깁니다. 우선 CDS에서 역할 기반 제어를 붙입니다.
annotate ProcurementService with @(requires: 'authenticated-user');
annotate ProcurementService.Orders with @(restrict: [
{ grant: 'READ', to: 'procurement-viewer' },
{ grant: 'approve', to: 'procurement-approver' }
]);
annotate ProcurementService.checkStock with @(requires: 'procurement-viewer');
Function은 GET이므로 CDN이나 API Management 앞단에서 Cache-Control을 활용할 여지가 있습니다. 다만 조회 결과가 사용자별로 다르다면 private를 유지합니다.
this.after('checkStock', (result, req) => {
req.res?.set('Cache-Control', 'private, max-age=30');
});
vitest로 두 오퍼레이션을 각각 검증합니다. CAP은 cds.test가 내장되어 supertest 없이도 HTTP 계층까지 테스트 가능합니다.
// test/procurement.test.js
const cds = require('@sap/cds');
const { expect, test, beforeAll } = require('vitest');
const { GET, POST } = cds.test(__dirname + '/..').in(__dirname + '/..');
test('Function은 GET으로 상태를 바꾸지 않는다', async () => {
const { data, status } = await GET(
`/odata/v4/procurement/checkStock(sku='A-001',warehouse='SEL-1')`
);
expect(status).toBe(200);
expect(data.available).toBeTypeOf('number');
});
test('Action은 DRAFT 상태만 승인한다', async () => {
const { data: created } = await POST('/odata/v4/procurement/Orders', {
supplierCode: 'SUP-1', totalAmount: 5000
});
const url = `/odata/v4/procurement/Orders(ID=${created.ID})/ProcurementService.approve`;
const { data } = await POST(url, { comment: 'ok' });
expect(data.status).toBe('APPROVED');
const second = await POST(url, { comment: 'again' }).catch(e => e);
expect(second.response.status).toBe(409);
});
자주 밟는 지뢰와 해결법
Q1. Function을 만들었는데 데이터가 바뀝니다. 뭐가 잘못됐나요? — 개념 오용입니다. Function 핸들러에서 UPDATE/INSERT를 하면 문법적으로는 통과하지만, OData 규약 위반이라 클라이언트가 GET을 재시도할 때 이중 처리가 발생할 수 있습니다. 상태 변경이 필요하면 Action으로 옮기고, 조회형 이름(getX, checkX)이 아닌 동사형(submitX)으로 리네이밍하세요.
Q2. Action 호출 시 405 Method Not Allowed가 뜹니다. — 대부분 GET으로 잘못 호출한 경우입니다. Bound action의 URL은 /Entity(key)/Namespace.actionName이며 반드시 POST여야 합니다. 또한 Content-Type: application/json 헤더가 빠지면 400으로 응답합니다.
Q3. Function의 파라미터에 문자열을 넣었는데 400이 납니다. — OData는 URL 리터럴 규칙이 엄격합니다. 문자열은 홑따옴표로 감싸야 하고(sku='A-001'), 홑따옴표 자체는 두 개로 이스케이프합니다. GUID나 날짜는 별도 리터럴 포맷이 필요합니다. 클라이언트 SDK를 사용하지 않고 fetch로 직접 호출한다면 encodeURIComponent로 안전하게 처리하세요.
Q4. Unbound인지 Bound인지 헷갈립니다. — 대상 레코드의 필드를 읽어야 결정할 수 있는 로직이면 Bound가 유리합니다. 반면 전역 계산기(예: 환율 변환)나 마스터 데이터 검색은 Unbound가 자연스럽습니다. 두 개의 규칙만 기억해도 대부분 정리됩니다.
이어서 살펴볼 주제
Action·Function을 손에 익혔다면 다음 단계로 넘어가 볼 만한 확장 주제가 있습니다. 첫째, Draft-enabled 엔티티와 함께 쓰는 Fiori Elements의 커스텀 액션 노출 방식입니다. 둘째, cds.connect.to('messaging')과 결합해 Action 완료 후 이벤트를 발행하는 패턴입니다. 셋째, HANA 스토어드 프로시저를 Function으로 감싸 대량 집계 조회를 노출하는 접근입니다. 각각 서비스 경계와 트랜잭션 스코프를 어떻게 잡을지가 핵심 포인트가 됩니다.
더 깊이 파고들 링크
- CAP Docs — Providing Services (actions/functions 섹션)
- CAP Docs — Core Services API for Node.js
- CAP Docs — CDL Reference: Actions & Functions
- help.sap.com — SAP Build Code CAP: Actions and Functions
- help.sap.com — Cloud Application Programming Model on SAP BTP
- help.sap.com — OData Actions and Functions 개념 정리
- OASIS — OData v4.01 Protocol Specification
댓글 0
아직 댓글이 없습니다.