이 글이 답하는 질문
SAP Cloud Application Programming Model(CAP) for Node.js 기반 서비스에서 외부 OData 서비스를 호출해야 하는 상황은 거의 매일 발생합니다. S/4HANA Cloud의 Business Partner API를 끌어다 쓰거나, BTP에 등록된 destination을 거쳐 다른 마이크로서비스의 OData를 소비해야 할 때 axios나 node-fetch로 직접 호출하면 인증, $batch, CSRF 토큰, 페이지네이션을 일일이 구현해야 합니다. RemoteService는 이 모든 것을 CDS 레이어가 흡수하도록 만든 추상화입니다.
- RemoteService가 왜 단순 HTTP 클라이언트보다 안전한가
- EDMX 메타데이터를 어떻게 CDS 모델로 임포트하는가
- 외부 OData의 필드를 내 서비스 형태로 어떻게 매핑하는가
- destination 기반 연결과 로컬 mock 전환은 어떻게 처리하는가
RemoteService가 등장한 배경
CAP의 핵심 철학은 "서비스는 모두 동일한 인터페이스를 가진다"입니다. 내가 정의한 로컬 서비스(cds.connect.to('OrderService'))와 외부 OData 서비스(cds.connect.to('S4_BusinessPartner'))를 동일한 API로 호출할 수 있어야 한다는 것이죠. 이를 가능하게 하는 것이 @sap/cds에 내장된 RemoteService 클래스입니다.
내부적으로 RemoteService는 다음 작업을 자동화합니다.
- CDS QL 쿼리(
SELECT.from(...))를 OData v2/v4 URL로 변환 ($select, $filter, $expand 모두 포함) - SAP BTP destination service를 통한 토큰 발급 및 OAuth2 흐름 처리
- CSRF 토큰 자동 fetch (POST/PUT/DELETE 시)
- 응답 JSON을 CDS 엔티티 구조로 역직렬화
비유하자면 ORM이 SQL을 숨기듯, RemoteService는 OData 프로토콜을 숨깁니다. 개발자는 그저 "이 엔티티에서 이 조건으로 조회"라고 말하면 되는 셈입니다.
준비물 — package.json과 .cdsrc.json 설정
이 글에서는 @sap/cds 7.x와 Node.js 20 LTS, @sap/cds-dk 7.x 환경을 가정합니다. 가상의 외부 서비스로 ProductCatalog라는 OData v4 서비스를 호출하는 시나리오를 잡았습니다.
먼저 의존성을 추가합니다.
{
"name": "catalog-aggregator",
"dependencies": {
"@sap/cds": "^7.9.0",
"@sap/xssec": "^3.6.0",
"@cap-js/sqlite": "^1.7.0",
"express": "^4.19.0"
},
"cds": {
"requires": {
"ProductCatalogAPI": {
"kind": "odata-v4",
"model": "srv/external/ProductCatalogAPI",
"[development]": {
"credentials": {
"url": "http://localhost:4005/odata/v4/product-catalog"
}
},
"[production]": {
"credentials": {
"destination": "PRODUCT_CATALOG_DEST",
"path": "/odata/v4/product-catalog"
}
}
}
}
}
}
여기서 kind: odata-v4가 핵심입니다. CAP은 이 키워드를 보고 RemoteService 인스턴스를 자동 생성합니다. [development]와 [production] 프로필을 나눠두면 로컬에서는 mock 서버를, 운영에서는 BTP destination을 자동으로 선택합니다.
CDS 모델에서 외부 서비스 가져오기
외부 OData 서비스의 EDMX 메타데이터를 CDS로 변환해야 합니다. cds import 명령이 이 작업을 해줍니다.
$ cds import ./external/ProductCatalogAPI.edmx --as cds
[info] creating srv/external/ProductCatalogAPI.cds
[info] updating package.json (cds.requires.ProductCatalogAPI)
생성된 CDS 파일은 대략 이런 형태가 됩니다.
// srv/external/ProductCatalogAPI.cds
@cds.external : true
service ProductCatalogAPI {
entity Products {
key ID : String(36);
sku : String(40);
name : String(200);
priceUSD : Decimal(15, 2);
stockLevel : Integer;
category : Association to Categories;
};
entity Categories {
key code : String(20);
label : String(80);
};
}
이렇게 임포트된 모델은 단지 "원격 서비스의 형태"를 알려주는 선언일 뿐 실제 DB 테이블을 만들지는 않습니다. @cds.external 어노테이션이 그 신호입니다.
1단계 — 기본 호출 예제
이제 내 서비스에서 외부 OData를 호출해봅니다. 가장 단순한 형태는 다음과 같습니다.
// srv/catalog-service.js
const cds = require('@sap/cds');
module.exports = class CatalogService extends cds.ApplicationService {
async init() {
const ProductCatalog = await cds.connect.to('ProductCatalogAPI');
this.on('READ', 'ExternalProducts', async (req) => {
const { Products } = ProductCatalog.entities;
return ProductCatalog.run(
SELECT.from(Products).limit(20)
);
});
await super.init();
}
};
이 한 줄 — ProductCatalog.run(SELECT.from(Products)) — 이 내부에서 다음을 수행합니다. 우선 destination 또는 로컬 URL을 결정하고, 인증 토큰을 발급받고, GET /odata/v4/product-catalog/Products?$top=20 요청을 보내고, 응답을 CDS 엔티티 객체 배열로 역직렬화합니다.
2단계 — 에러 처리와 로깅을 더한 실무 예제
운영 환경에서는 네트워크 단절, 인증 실패, 4xx/5xx 응답에 대비해야 합니다. cds.log와 req.error를 활용해 견고하게 만들어봅니다.
// srv/catalog-service.js (개선)
const cds = require('@sap/cds');
const LOG = cds.log('catalog-aggregator');
module.exports = class CatalogService extends cds.ApplicationService {
async init() {
const ProductCatalog = await cds.connect.to('ProductCatalogAPI');
const { Products, Categories } = ProductCatalog.entities;
this.on('READ', 'ExternalProducts', async (req) => {
try {
const filter = req.query.SELECT.where;
const top = req.query.SELECT.limit?.rows?.val ?? 50;
LOG.info('Fetching external products', { filter, top });
const rows = await ProductCatalog.run(
SELECT.from(Products)
.columns('ID', 'sku', 'name', 'priceUSD', 'stockLevel')
.where(filter || {})
.limit(top)
);
LOG.debug(`Got ${rows.length} products`);
return rows;
} catch (e) {
LOG.error('ProductCatalog call failed', e);
if (e.code === 'ECONNREFUSED' || e.statusCode >= 500) {
return req.error(503, 'Upstream catalog temporarily unavailable');
}
return req.error(502, `Catalog error: ${e.message}`);
}
});
// 외부 서비스로 들어가는 요청을 가로채 헤더를 추가
ProductCatalog.before('*', (req) => {
req.headers ??= {};
req.headers['x-correlation-id'] =
cds.context?.id ?? `local-${Date.now()}`;
});
await super.init();
}
};
여기서 두 가지 포인트가 있습니다. 첫째, req.query.SELECT.where를 그대로 외부 서비스로 전달함으로써 클라이언트의 $filter가 OData URL로 자연스럽게 변환됩니다. 둘째, ProductCatalog.before('*')로 모든 아웃바운드 요청에 상관관계 ID를 주입할 수 있습니다. 분산 추적에 매우 유용한 패턴입니다.
3단계 — 프로덕션 수준 캐싱과 회복성
외부 호출은 비싸기 때문에 캐싱과 회복성 패턴을 반드시 고려해야 합니다. CAP에는 내장 캐시가 없으므로 @sap/cds의 cds.middlewares와 in-memory LRU를 조합합니다.
// srv/catalog-service.js (프로덕션)
const cds = require('@sap/cds');
const LRU = require('lru-cache');
const LOG = cds.log('catalog-aggregator');
const productCache = new LRU({
max: 500,
ttl: 1000 * 60 * 5 // 5분
});
module.exports = class CatalogService extends cds.ApplicationService {
async init() {
const ProductCatalog = await cds.connect.to('ProductCatalogAPI');
const { Products } = ProductCatalog.entities;
this.on('READ', 'ExternalProducts', async (req) => {
const cacheKey = JSON.stringify(req.query.SELECT);
const hit = productCache.get(cacheKey);
if (hit) {
LOG.debug('Cache hit', { cacheKey });
return hit;
}
const rows = await this._fetchWithRetry(ProductCatalog, Products, req, 3);
productCache.set(cacheKey, rows);
return rows;
});
await super.init();
}
async _fetchWithRetry(svc, Entity, req, maxAttempts) {
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await svc.run(
SELECT.from(Entity).where(req.query.SELECT.where || {})
);
} catch (e) {
lastErr = e;
if (e.statusCode === 401 || e.statusCode === 403) throw e;
const delay = Math.min(2 ** attempt * 100, 2000);
LOG.warn(`Retry ${attempt}/${maxAttempts} after ${delay}ms`, {
status: e.statusCode
});
await new Promise(r => setTimeout(r, delay));
}
}
throw lastErr;
}
};
이 코드의 핵심은 3가지입니다. 첫째, 쿼리 객체 전체를 캐시 키로 사용해 다른 필터 조건은 별도 캐시 슬롯을 차지합니다. 둘째, 401/403은 재시도하지 않고 즉시 던집니다(자격 증명 문제는 시간이 해결하지 않습니다). 셋째, 지수 백오프로 외부 서비스를 압박하지 않습니다.
커스텀 핸들러로 데이터 변환하기
외부 OData가 내 서비스의 모델과 형태가 다른 경우가 흔합니다. 예를 들어 외부는 priceUSD로 주는데 내 클라이언트는 priceKRW를 기대한다면, after 핸들러에서 변환합니다.
this.after('READ', 'ExternalProducts', (rows) => {
const rate = 1380; // 실무에서는 ExchangeRate 서비스 호출
for (const row of rows) {
row.priceKRW = row.priceUSD ? Math.round(row.priceUSD * rate) : null;
delete row.priceUSD;
}
});
또는 외부 서비스의 키가 GUID인데 내 서비스에서는 sku를 키로 노출하고 싶다면, 프로젝션과 핸들러를 조합합니다. 이 매핑 레이어가 안티 코럽션 레이어 역할을 해서 외부 스키마 변경이 내 클라이언트로 전파되지 않도록 막아줍니다.
삽질 노트 — 흔한 함정 3가지
Q1. 로컬에서는 잘 되는데 BTP에 배포하면 401이 떨어집니다.
대부분 destination service가 바인딩되지 않았거나 destination 이름의 대소문자가 틀렸기 때문입니다. cf env <app-name>으로 VCAP_SERVICES를 확인하고, cds.env.requires.ProductCatalogAPI.credentials.destination 값과 BTP 코크핏의 destination 이름이 정확히 일치하는지 점검하세요. xsuaa 바인딩이 함께 필요하다는 점도 잊지 마세요.
Q2. $expand가 외부 서비스로 전달되지 않습니다.
CDS QL에서 .columns('name', { ref: ['category'], expand: ['*'] }) 형태로 명시적으로 작성해야 OData URL에 $expand가 붙습니다. 단순히 클라이언트의 $expand가 자동 전파될 거라 기대하면 안 됩니다. 또한 외부 서비스가 해당 navigation property를 expandable로 선언했는지 EDMX에서 확인해야 합니다.
Q3. POST 요청에서 CSRF 토큰 에러(403)가 발생합니다.
RemoteService는 일반적으로 자동으로 CSRF 토큰을 fetch하지만, 외부 서비스가 x-csrf-token: fetch 헤더를 표준대로 처리하지 않으면 실패할 수 있습니다. credentials에 requestTimeout을 늘려보고, 그래도 안 되면 before('CREATE') 핸들러에서 직접 GET 호출로 토큰을 받아 헤더에 주입하는 우회법을 사용하세요. OData v2 서비스에서 특히 자주 부딪힙니다.
한 발 더 — 관련 주제로 넘어가기
RemoteService 패턴이 익숙해졌다면 자연스럽게 다음 주제로 이어집니다. Messaging으로 외부 시스템과 이벤트 기반 통신을 구현하거나, Mocking external services로 로컬 테스트 환경을 풍부하게 만들거나, SAP Cloud SDK for JavaScript와의 차이점을 비교해보는 것도 좋습니다. SDK는 더 세밀한 제어가 가능하지만 보일러플레이트가 많고, RemoteService는 CDS 생태계와 자연스럽게 어울립니다. 또한 cds.connect.to의 멀티 테넌시 동작과 token forwarding 옵션도 운영 환경에서 반드시 이해해야 할 주제입니다.
핵심 한 줄
외부 OData를 호출하는 가장 빠른 길은 axios가 아니라 CDS 모델로 임포트한 뒤
cds.connect.to로 잇는 것이다 — RemoteService는 인증, $batch, CSRF, 페이지네이션을 모두 흡수한다.
더 깊이 파보기
- CAP capire — Consuming Services Cookbook
- CAP capire — cds.connect API Reference
- help.sap.com — BTP Connectivity Service
- help.sap.com — Destination Service in BTP
- help.sap.com — SAP Cloud Application Programming Model
- SAP Cloud SDK for JavaScript Documentation
- CAP capire — Service Composition Patterns
댓글 0
아직 댓글이 없습니다.