1. 개요 및 이 글에서 다룰 것
CAP(Cloud Application Programming Model) for Node.js의 강력한 기능 중 하나는 이벤트 기반 아키텍처를 자연스럽게 구현할 수 있다는 점입니다. 이 글에서는 emit과 on 패턴을 통해 서비스 간 느슨한 결합(loose coupling)을 달성하는 방법을 단계별로 다룹니다. 단순한 인메모리 이벤트부터 BTP Enterprise Messaging을 통한 분산 이벤트까지 실전 예제로 학습합니다.
이 글에서 다룰 것 체크리스트
- CDS의
event키워드로 도메인 이벤트 모델링this.emit()으로 이벤트 발행cds.connect.to()와srv.on()으로 이벤트 구독- 서비스 간 결합도 낮추기 패턴 이해
- BTP Event Mesh / Enterprise Messaging 연동 기초
- 흔한 실수와 트러블슈팅 대처법 습득
2. 이 글을 보기 전에
이 글을 따라가려면 다음 지식이 필요합니다.
- Node.js 비동기/Promise 기본 (
async/await) - CAP의 서비스 정의(
service,entity) 구조 cds watch를 통한 로컬 개발 흐름- SAP BTP Cockpit 기본 사용법 (선택)
- npm 패키지 설치 및
package.json편집 경험
3. 환경 / 버전 / 준비물
다음 환경을 권장합니다. 버전이 다를 경우 일부 API가 변경될 수 있습니다.
- Node.js: 20.x LTS 이상 (CAP 8 권장)
- @sap/cds: 8.x (또는 최신 7.x)
- @sap/cds-dk: 글로벌 설치 (
npm i -g @sap/cds-dk) - SQLite: 로컬 개발용 (프로덕션은 HANA Cloud)
- BTP 서브계정: Event Mesh / Enterprise Messaging 서비스 사용 시
- messaging 어댑터:
@sap/cds내장 (cds.requires.messaging설정으로 활성화)
# package.json 내 cds 설정 예시
"cds": {
"requires": {
"messaging": {
"[development]": { "kind": "file-based-messaging" },
"[production]": { "kind": "enterprise-messaging" }
}
}
}
4. 핵심 개념
CAP의 이벤트 모델은 발행/구독(Pub-Sub) 패턴을 기반으로 합니다. 한 서비스(Producer)가 도메인에서 의미 있는 일이 발생했음을 emit으로 알리면, 그 사건에 관심 있는 서비스(Consumer)가 on으로 받아 처리합니다. 두 서비스는 서로의 구현체나 위치를 알 필요가 없습니다. 마치 라디오 방송국과 청취자의 관계와 같습니다. 방송국은 누가 듣는지 모른 채 신호를 송출하고, 청취자는 채널만 맞추면 됩니다.
CAP에서 이벤트는 다음 3가지 레이어에서 다룹니다.
- 모델링 레이어:
.cds파일에서event키워드로 페이로드 스키마를 명시 - 발행 레이어: 서비스 핸들러에서
this.emit('eventName', payload)호출 - 구독 레이어: 다른 서비스에서
cds.connect.to('ProducerService')후srv.on('eventName', handler)등록
여기서 중요한 점은 messaging 채널입니다. 기본적으로 emit은 동일 프로세스 내에서 즉시 전달되지만, cds.requires.messaging을 구성하면 BTP Event Mesh나 Redis, Kafka 등을 통해 다른 마이크로서비스로 메시지가 라우팅됩니다. 코드는 그대로 둔 채 설정만으로 모놀리스에서 분산 시스템으로 전환할 수 있다는 것이 CAP 이벤트 모델의 강점입니다.
느슨한 결합의 장점은 다음과 같습니다.
- 독립 배포: 구독자가 변경되어도 발행자는 영향받지 않음
- 확장성: 새 구독자(감사 로그, 알림, 분석)를 추가해도 발행자 코드 변경 불필요
- 장애 격리: 메시지 큐가 버퍼 역할을 해 일시적 장애에서 복원력 확보
- 비동기 처리: 메인 요청 처리와 부수 작업(메일 발송 등)을 분리
5. 실전 코드 3단계
5-1. 1단계: 기본 예제 (인메모리 이벤트)
주문이 생성되면 OrderCreated 이벤트를 발행하고, 같은 프로세스 내 알림 서비스가 구독하는 예제입니다.
// db/schema.cds
namespace my.shop;
entity Orders {
key ID : UUID;
customer : String;
total : Decimal(10,2);
createdAt : Timestamp;
}
// srv/order-service.cds
using { my.shop as db } from '../db/schema';
service OrderService {
entity Orders as projection on db.Orders;
// 도메인 이벤트 정의
event OrderCreated : {
orderID : UUID;
customer : String;
total : Decimal(10,2);
};
}
// srv/order-service.js
const cds = require('@sap/cds');
module.exports = class OrderService extends cds.ApplicationService {
init() {
const { Orders } = this.entities;
this.after('CREATE', Orders, async (data) => {
// emit으로 이벤트 발행
await this.emit('OrderCreated', {
orderID: data.ID,
customer: data.customer,
total: data.total
});
});
return super.init();
}
};
// srv/notification-service.js
const cds = require('@sap/cds');
module.exports = cds.service.impl(async function () {
// OrderService에 연결
const orderSrv = await cds.connect.to('OrderService');
// on으로 이벤트 구독
orderSrv.on('OrderCreated', (msg) => {
const { orderID, customer, total } = msg.data;
console.log(`[Notify] 주문 ${orderID} 생성됨 - ${customer}, ${total}`);
});
});
5-2. 2단계: 실무 시나리오 (에러 처리/로깅)
실무에서는 구독자 측 에러가 발행자 트랜잭션에 영향을 주지 않도록 격리해야 합니다. 또한 구조화된 로깅과 페이로드 검증이 필요합니다.
// srv/notification-service.js (개선판)
const cds = require('@sap/cds');
const LOG = cds.log('notification');
module.exports = cds.service.impl(async function () {
const orderSrv = await cds.connect.to('OrderService');
orderSrv.on('OrderCreated', async (msg) => {
const { orderID, customer, total } = msg.data ?? {};
// 1) 페이로드 유효성 검증
if (!orderID || !customer) {
LOG.warn('잘못된 페이로드 수신', msg.data);
return;
}
// 2) 에러 격리: try/catch로 구독자 실패가 발행자에 전파되지 않게
try {
LOG.info(`알림 전송 시도 - 주문 ${orderID}`);
await sendEmail(customer, `주문 ${orderID} 접수 (${total})`);
LOG.info(`알림 전송 완료 - 주문 ${orderID}`);
} catch (err) {
// 재시도 큐로 보내거나 DLQ(Dead Letter Queue) 처리
LOG.error(`알림 실패 - 주문 ${orderID}`, err);
}
});
});
async function sendEmail(to, body) {
// 실제로는 SMTP/메일 서비스 호출
return new Promise((resolve) => setTimeout(resolve, 50));
}
발행자 측에서는 트랜잭션 경계 관리가 중요합니다. after 핸들러에서 emit하면 DB 커밋 직전에 발행되므로, 트랜잭션이 롤백되면 이벤트도 함께 취소되어 일관성이 보장됩니다.
// srv/order-service.js (개선판)
const cds = require('@sap/cds');
const LOG = cds.log('order');
module.exports = class OrderService extends cds.ApplicationService {
init() {
const { Orders } = this.entities;
this.after('CREATE', Orders, async (data, req) => {
try {
await this.emit('OrderCreated', {
orderID: data.ID,
customer: data.customer,
total: Number(data.total) // 타입 일관성 보장
}, { 'x-correlation-id': req.id });
} catch (e) {
LOG.error('이벤트 발행 실패', e);
throw e; // 필요 시 트랜잭션 롤백
}
});
return super.init();
}
};
5-3. 3단계: 프로덕션 (BTP Event Mesh 연동)
분산 환경에서는 messaging 어댑터를 통해 메시지 브로커를 사용합니다. package.json에 설정만 추가하면 동일한 emit/on 코드가 그대로 동작합니다.
{
"cds": {
"requires": {
"messaging": {
"[development]": { "kind": "file-based-messaging" },
"[hybrid]": { "kind": "enterprise-messaging" },
"[production]": { "kind": "enterprise-messaging", "format": "cloudevents" }
},
"OrderService": {
"kind": "odata",
"credentials": { "url": "https://order-srv.cfapps.eu10.hana.ondemand.com" }
}
}
}
}
구독 서비스에서 BTP Event Mesh로부터 메시지를 받는 코드는 동일합니다. 다만 토픽 이름이 길어질 수 있어 별칭을 사용하는 것이 일반적입니다.
// srv/audit-service.js
const cds = require('@sap/cds');
const LOG = cds.log('audit');
module.exports = cds.service.impl(async function () {
const messaging = await cds.connect.to('messaging');
// 토픽 패턴 구독 (CloudEvents 포맷)
messaging.on('my/shop/OrderService/OrderCreated/v1', async (msg) => {
const { orderID, customer, total } = msg.data;
// 멱등성 보장: 동일 메시지 재처리 방지
const exists = await SELECT.one.from('AuditLog').where({ eventID: msg.id });
if (exists) {
LOG.info(`중복 이벤트 무시 - ${msg.id}`);
return;
}
await INSERT.into('AuditLog').entries({
eventID: msg.id,
orderID,
customer,
amount: total,
receivedAt: new Date().toISOString()
});
});
});
프로덕션 체크리스트:
- 멱등성: 동일 메시지가 두 번 도착해도 안전하게 처리 (위 예제의 eventID 확인)
- DLQ: BTP Event Mesh에서 재시도 한계 초과 시 별도 큐로 이동 설정
- 모니터링:
cds.log를 ELK/Kibana 또는 BTP Application Logging으로 수집 - 보안: 메시지 페이로드에 PII 최소화, 필요 시 암호화된 필드만 전송
6. 흔한 실수 / 트러블슈팅
실제 프로젝트에서 자주 마주치는 함정과 해결책을 정리했습니다.
FAQ 1: 직접 서비스 호출과 이벤트 발행을 혼용해도 되나요?
가능하지만 권장하지 않습니다. 예를 들어 OrderService에서 NotificationService를 cds.connect.to로 직접 호출하면 결합도가 높아지고, 알림 서비스가 다운되면 주문 생성도 실패할 수 있습니다. 동기 응답이 필요하면 직접 호출, 부수 작업이면 이벤트로 구분하는 것이 일반적인 가이드입니다.
FAQ 2: payload 타입이 맞지 않아 구독자에서 NaN/undefined가 발생합니다
CDS event 정의의 타입과 실제 emit하는 객체가 다르면 발생합니다. 특히 Decimal은 문자열로, Date는 ISO 문자열로 직렬화되므로 구독자에서 Number() 또는 new Date()로 명시적 변환이 필요합니다. JSON 직렬화 단계에서 타입이 손실된다는 점을 항상 기억하세요.
FAQ 3: 로컬에서는 동작하는데 BTP에 배포하니 이벤트가 전달되지 않습니다
원인 후보를 점검합니다. (1) cds.requires.messaging 프로파일이 production에서 enterprise-messaging으로 설정되었는지, (2) Event Mesh 인스턴스가 바인딩되었는지(cf bind-service), (3) 토픽 네임스페이스가 일치하는지, (4) IAM 권한(messaging.publish, messaging.subscribe)이 부여되었는지 확인합니다. cds.log('messaging').level = 'debug'로 송수신 로그를 확인하면 진단에 도움이 됩니다.
기타 자주 보이는 실수
this.emit을before핸들러에 두어 트랜잭션 롤백 시에도 이벤트가 발행되는 경우- 구독자 핸들러 안에서
await없이 비동기 작업을 호출해 에러가 무시되는 경우 - 이벤트 이름에 오타가 있어 조용히 동작하지 않는 경우 — 상수로 추출 권장
7. 다음 단계 / 관련 주제
이벤트 패턴을 익혔다면 다음 주제로 확장해보세요.
- CloudEvents 포맷: 표준 메타데이터(
type,source,id)를 활용한 호환성 확보 - Outbox 패턴: DB 트랜잭션과 메시지 발행의 원자성 보장 (
@sap/cds내장 outbox 지원) - Saga 패턴: 다단계 분산 트랜잭션을 이벤트 체인으로 구성
- RAP과의 비교: ABAP RAP의 비즈니스 이벤트와 CAP 이벤트의 상호 운용
- S/4HANA 이벤트 수신: SAP S/4HANA Cloud의 비즈니스 이벤트를 CAP에서 구독
댓글 0
아직 댓글이 없습니다.