CAP for Node

CAP before vs on vs after 실행 순서 #shorts #SAP #CAP

▶ YouTube에서 보기

📖 개요 — 이 글에서 얻을 수 있는 것

SAP CAP(Cloud Application Programming Model) for Node.js에서 커스텀 로직은 before / on / after 세 단계의 이벤트 핸들러로 구현됩니다. 세 단계는 이름만 다른 게 아니라 실행 시점, req.data 변경 가능 여부, 응답 생성 책임이 전부 다릅니다. 이 차이를 모르면 "분명히 값을 바꿨는데 저장이 안 된다", "핸들러를 등록했는데 실행조차 안 된다" 같은 버그를 만나게 됩니다.

  • ✅ before / on / after 각 단계의 실행 순서와 역할 구분
  • ✅ req.data를 언제 바꿔야 실제로 반영되는지 이해
  • ✅ on 핸들러의 next() 체인 동작 원리 파악
  • ✅ SalesOrder 시나리오로 검증 → 저장 → 후속 처리 파이프라인 구현

📚 미리 알고 있으면 좋은 내용

Node.js의 async/await 기본 문법, CDS로 엔티티와 서비스를 정의하는 방법, 그리고 cds watch로 로컬 서버를 띄워본 경험이 있으면 충분합니다. Express 미들웨어를 써봤다면 on 핸들러의 next() 개념이 훨씬 빠르게 와닿을 것입니다.

🔧 환경 · 버전 · 준비물

이 글의 예제는 다음 환경을 기준으로 작성했습니다.

항목버전 / 비고
@sap/cds7.x 이상 (8.x에서도 동일하게 동작)
Node.js18 LTS 또는 20 LTS 권장
로컬 DBSQLite (in-memory), 배포 시 SAP HANA Cloud
플랫폼SAP BTP, Cloud Foundry 환경 기준

프로젝트가 없다면 npm i -g @sap/cds-dkcds init order-app으로 생성하고, cds watch로 실행하면서 따라오면 됩니다.

💡 핵심 개념 — 공항 수속에 비유한 3단계 파이프라인

CAP의 요청 처리 흐름은 공항 수속과 비슷합니다. before는 보안 검색대입니다. 탑승(실제 처리) 전에 짐을 검사하고, 문제가 있으면 그 자리에서 돌려보냅니다(req.reject). on은 실제 비행입니다. 승객을 목적지로 데려가는 본 작업, 즉 DB 저장이나 외부 API 호출이 여기서 일어납니다. after는 도착 후 안내입니다. 이미 비행(처리)은 끝났고, 결과물을 다듬거나 후속 알림을 보내는 단계입니다.

실행 순서: beforeon (체인) → after. before에서 reject되면 on/after는 실행되지 않습니다.

각 단계의 결정적 차이를 표로 정리하면 다음과 같습니다.

단계시그니처req.data 변경주 용도
before(req)변경 가능 · on에 반영됨유효성 검사, 기본값 채우기, 권한 체크
on(req, next)변경 가능하나 이 시점이 마지막실제 처리(DB 쓰기), 제네릭 핸들러 대체/보강
after(data, req)변경해도 무의미 (이미 처리 완료)결과 가공, 후속 처리 트리거

가장 많이 헷갈리는 지점 두 가지를 짚고 넘어가겠습니다.

첫째, on 핸들러는 인터셉터 스택입니다. 같은 이벤트에 on 핸들러를 등록하면 나중에 등록한 것이 먼저 실행되며, next()를 호출해야 다음 핸들러(최종적으로는 CAP이 제공하는 제네릭 DB 핸들러)로 제어가 넘어갑니다. next()를 부르지 않으면 제네릭 핸들러가 실행되지 않아 DB에 아무것도 저장되지 않습니다. "핸들러를 달았더니 저장이 안 된다"는 문의의 대부분이 이 케이스입니다.

둘째, after에서 고쳐야 할 대상은 req.data가 아니라 첫 번째 인자 data입니다. after 시점에는 요청 처리가 끝났기 때문에 req.data를 수정해도 결과에 반영되지 않습니다. 응답을 바꾸려면 data를 직접 변형(in-place)하거나, 새 값을 return해서 결과를 교체해야 합니다.

💻 실전 코드 3단계 — SalesOrder 파이프라인 만들기

다음 CDS 모델을 공통으로 사용합니다.

// srv/order-service.cds
using { cuid, managed } from '@sap/cds/common';

service OrderService {
  entity SalesOrders : cuid, managed {
    customerName : String(100);
    amount       : Decimal(15,2);
    currency     : String(3);
    status       : String(20) default 'NEW';
    discountRate : Decimal(5,2);
  }
}

1단계: 기본 — 세 단계를 눈으로 확인하기

먼저 각 단계가 언제 실행되고 req.data가 어느 시점까지 유효한지 로그로 확인합니다.

// srv/order-service.js
const cds = require('@sap/cds');

module.exports = class OrderService extends cds.ApplicationService {
  init() {
    const { SalesOrders } = this.entities;

    // (1) before: req.data 수정 → on 단계에 그대로 전달됨
    this.before('CREATE', SalesOrders, (req) => {
      req.data.currency ??= 'KRW';          // 기본값 주입: 여기서 바꾸면 저장에 반영
      console.log('[before]', req.data);
    });

    // (2) on: next()로 제네릭 DB 핸들러에 위임
    this.on('CREATE', SalesOrders, async (req, next) => {
      console.log('[on] 저장 직전 데이터:', req.data);
      const result = await next();          // 이 호출이 없으면 INSERT 자체가 안 됨!
      return result;
    });

    // (3) after: 첫 인자 data가 응답, req.data 수정은 무의미
    this.after('READ', SalesOrders, (rows) => {
      for (const row of rows) {
        if (row.amount > 1_000_000) row.status = 'VIP-REVIEW'; // 응답만 가공 (DB 미반영)
      }
    });

    return super.init();
  }
};

POST 요청을 보내면 콘솔에 before → on 순서로 찍히고, before에서 넣은 currency: 'KRW'가 실제 INSERT에 포함되는 것을 확인할 수 있습니다.

2단계: 실무 시나리오 — 유효성 검사와 에러/로깅 체계화

before에서 여러 검증 오류를 한 번에 수집하고, 구조화된 로거를 붙입니다. req.error()는 오류를 누적하고, req.reject()는 즉시 중단한다는 차이를 활용합니다.

const LOG = cds.log('order-service');   // cds 표준 로거 (레벨: cds.env로 제어)

this.before('CREATE', SalesOrders, (req) => {
  const { customerName, amount, currency } = req.data;

  // 오류 수집형: 여러 문제를 한 응답에 모아 반환 (400 Bad Request)
  if (!customerName?.trim())
    req.error(400, '고객명은 필수입니다', 'in/customerName');
  if (amount == null || amount <= 0)
    req.error(400, '주문 금액은 0보다 커야 합니다', 'in/amount');

  // 즉시 중단형: 복구 불가능한 조건은 reject로 바로 종료
  if (currency && !['KRW', 'USD', 'EUR'].includes(currency))
    req.reject(400, `지원하지 않는 통화입니다: ${currency}`);

  LOG.info('주문 검증 통과', { customer: customerName, amount });
});

this.on('CREATE', SalesOrders, async (req, next) => {
  try {
    const result = await next();
    LOG.info('주문 저장 완료', { id: result.ID });
    return result;
  } catch (e) {
    LOG.error('주문 저장 실패', e);
    return req.reject(500, '주문 처리 중 오류가 발생했습니다'); // 내부 정보 노출 방지
  }
});

req.error에 target(in/customerName)을 지정하면 SAP Fiori Elements UI가 해당 입력 필드에 오류 메시지를 매핑해 주기 때문에, UI 연동까지 고려하면 target 지정이 일반적으로 권장됩니다.

3단계: 프로덕션 — 트랜잭션 경계와 안전한 후속 처리

"after CREATE에서 이메일 발송"은 흔한 요구사항이지만 함정이 있습니다. after 시점에는 아직 트랜잭션이 커밋되지 않았습니다. after에서 이메일을 보낸 직후 커밋이 실패하면, 존재하지 않는 주문에 대한 메일이 나가는 정합성 문제가 생깁니다. 커밋 성공 이후에만 실행되는 req.on('succeeded') 훅을 쓰는 것이 안전합니다.

this.after('CREATE', SalesOrders, (order, req) => {
  // 응답 가공은 여기서: 등급 필드를 계산해 응답에만 추가
  order.tier = order.amount > 5_000_000 ? 'GOLD' : 'STANDARD';

  // 부수효과는 커밋 이후로 미룬다
  req.on('succeeded', async () => {
    try {
      const notifier = await cds.connect.to('NotificationService');
      await notifier.send('orderCreated', { orderId: order.ID });
      LOG.info('주문 알림 발행 완료', { id: order.ID });
    } catch (e) {
      LOG.warn('알림 발행 실패 — 주문 자체는 정상 저장됨', e); // 알림 실패가 주문을 깨면 안 됨
    }
  });
});

성능·테스트 관점의 체크포인트도 정리합니다.

  • after READ의 N+1 주의: after 안에서 행마다 SELECT를 날리면 목록 조회가 급격히 느려집니다. 추가 데이터가 필요하면 before에서 req.query에 컬럼을 확장하거나, 한 번의 조회로 묶어 매핑하는 방식이 권장됩니다.
  • 보안: 검증 로직은 UI가 아니라 before 핸들러에 두어야 OData 직접 호출까지 방어됩니다. 에러 메시지에 스택이나 SQL을 노출하지 않도록 on의 catch에서 메시지를 정제합니다.
  • 테스트: cds.test()로 인메모리 서버를 띄워 세 단계를 통합 검증할 수 있습니다.
// test/order.test.js
const cds = require('@sap/cds');
const { POST, expect } = cds.test(__dirname + '/..');

it('before 검증: 금액 0 이하이면 400', async () => {
  await expect(
    POST('/odata/v4/order/SalesOrders', { customerName: '민수상사', amount: -1 })
  ).to.be.rejectedWith(/400/);
});

it('before 기본값: currency 미지정 시 KRW 저장', async () => {
  const { data } = await POST('/odata/v4/order/SalesOrders',
    { customerName: '한빛물산', amount: 250000 });
  expect(data.currency).to.equal('KRW');
});

⚠️ 흔한 실수와 트러블슈팅 FAQ

Q1. on 핸들러를 등록했더니 DB에 저장이 안 됩니다.

A. on 핸들러 안에서 next()를 호출하지 않았기 때문입니다. on은 제네릭 핸들러를 "대체"하는 단계라서, 위임 없이 return하면 여러분의 코드가 응답의 전부가 됩니다. DB 저장을 유지하려면 반드시 const result = await next() 패턴을 사용하세요.

Q2. after에서 req.data를 바꿨는데 응답도 DB도 그대로입니다.

A. 정상 동작입니다. after 시점에 req.data는 이미 소비된 입력값의 흔적일 뿐입니다. 응답을 바꾸려면 첫 번째 인자인 data(결과 객체/배열)를 직접 수정하거나 새 값을 return해야 하고, DB 값을 바꾸고 싶다면 before 단계로 로직을 옮겨야 합니다.

Q3. before에서 async 함수로 외부 조회를 하는데 검증이 끝나기 전에 on이 실행되는 것 같습니다.

A. Promise를 return(또는 await)하지 않고 흘려보낸 경우입니다. CAP은 핸들러가 반환한 Promise를 기다려 주지만, async 없이 .then()만 걸어두고 return을 빠뜨리면 순서 보장이 깨집니다. before 핸들러는 항상 async (req) => { await ... } 형태로 작성하는 습관이 안전합니다.

Q4. after CREATE에서 보낸 이메일이 롤백된 주문에도 발송됩니다.

A. after는 커밋 전에 실행되기 때문입니다. 3단계 예제처럼 req.on('succeeded')로 커밋 이후로 미루거나, 신뢰성이 중요하면 SAP Event Mesh 같은 메시징으로 분리하는 것이 일반적입니다.

🚀 더 깊이 들어가려면

핸들러 3단계를 이해했다면 다음 주제로 확장하는 것을 권장합니다. 이벤트 발행/구독으로 서비스 간 결합도를 낮추는 CAP Messaging(Event Mesh 연동), 트랜잭션과 테넌트 컨텍스트를 다루는 cds.tx와 cds.context, 그리고 CREATE/UPDATE 없이 도메인 동작을 노출하는 Actions & Functions의 on 핸들러 구현이 자연스러운 다음 코스입니다. Java 스택을 쓴다면 CAP Java의 @Before/@On/@After 어노테이션이 동일한 철학으로 설계되어 있어 비교해 보는 것도 좋습니다.

📚 함께 보면 좋은 문서

댓글 0

아직 댓글이 없습니다.