CAP for Node

req.error vs reject — 에러 처리 3단계 #shorts #SAP #CAPforNode

개요 및 이 글에서 다루는 것

SAP CAP(Cloud Application Programming Model) Node.js 런타임에서 Custom Handler를 작성하다 보면, 비즈니스 규칙 위반·외부 시스템 장애·데이터 정합성 문제를 어떻게 클라이언트에 전달할지 고민하게 됩니다. 이 글은 CAP Node.js(@sap/cds 7.x 이상 기준)의 에러 처리 메커니즘을 베스트 프랙티스 관점에서 단계별로 정리합니다.

  • req.error()와 req.reject()의 차이와 선택 기준 이해
  • before / on / after 핸들러 단계별 에러 처리 전략 수립
  • HTTP 상태 코드 매핑과 커스텀 에러 클래스 설계
  • 트랜잭션 자동 롤백 동작과 에러의 관계 파악
  • 프로덕션 수준의 로깅·테스트·보안(에러 정보 노출 차단) 적용

미리 알아두면 좋은 배경

이 글은 중급 수준을 대상으로 합니다. CDS 모델링 기본(entity, service 정의), Node.js의 async/await 문법, CAP 서비스 핸들러 등록(srv.before, srv.on, srv.after) 경험이 있다면 수월하게 따라올 수 있습니다. OData V4의 에러 응답 구조(error.code, error.message, details)를 알고 있으면 이해가 더 빠릅니다.

환경 및 준비물

다음 환경을 기준으로 작성했습니다. 버전이 달라도 핵심 API는 대부분 동일하게 동작하지만, @sap/cds 7.x 이상을 권장합니다.

항목버전/설정
Node.js18 LTS 이상 (20 LTS 권장)
@sap/cds7.x 이상 (cds-dk 포함)
DB로컬 SQLite, 배포 시 SAP HANA Cloud
플랫폼SAP BTP Cloud Foundry 환경 기준

프로젝트가 없다면 cds init settlement-app으로 골격을 만들고, npm icds watch로 실행하면서 따라오면 됩니다.

핵심 개념

CAP의 에러 처리를 이해하는 가장 쉬운 비유는 공항 출국 심사입니다. before 핸들러는 체크인 카운터입니다. 여권 만료, 수하물 초과 같은 문제를 여러 건 모아서 한 번에 알려주는 것이 친절합니다. 이것이 req.error()의 역할로, 호출해도 즉시 실행이 중단되지 않고 에러를 수집(collect)한 뒤 핸들러 체인이 끝나는 시점에 요청 전체를 실패 처리합니다. 반면 req.reject()는 보안 검색대에서 위험물이 발견된 상황입니다. 그 즉시 예외를 던져(throw) 이후 로직 실행 자체를 차단합니다.

두 API의 동작 차이를 표로 정리하면 다음과 같습니다.

구분req.error()req.reject()
실행 흐름수집 후 계속 진행즉시 throw로 중단
다중 에러여러 건 누적 → details 배열로 응답단일 에러
주 사용처before 단계의 입력 검증on 단계의 치명적 거부

또 하나 중요한 원리는 트랜잭션과의 관계입니다. CAP은 요청 단위로 managed transaction을 열고, 핸들러 체인이 에러 없이 끝나면 commit, 에러가 하나라도 남아 있거나 예외가 전파되면 자동 rollback 합니다. 즉 개발자가 ROLLBACK을 직접 호출할 필요가 없으며, 오히려 try/catch로 에러를 삼켜버리면(swallow) 롤백이 일어나지 않아 부분 커밋이라는 최악의 상황이 생길 수 있습니다. "catch는 로깅과 변환을 위해서만, 처리 불가 에러는 반드시 다시 던진다"가 일반적으로 권장되는 원칙입니다.

마지막으로 after 핸들러는 이미 DB 작업이 끝난 뒤 결과를 가공하는 단계이므로, 여기서 에러를 내면 사용자는 실패 응답을 받지만 데이터 변경은 롤백 대상에 포함되는지 여부를 신중히 판단해야 합니다. 결과 후처리 실패가 요청 전체를 무효화할 사안이 아니라면 after에서는 로깅으로 처리하는 편이 안전합니다.

실전 코드 3단계

판매자 정산 도메인을 예로 들겠습니다. 셀러의 정산 요청(PayoutRequests)을 접수하고 승인(approvePayout)하는 서비스입니다.

// srv/settlement-service.cds
using { my.settlement as db } from '../db/schema';

service SettlementService {
  entity PayoutRequests as projection on db.PayoutRequests
    actions { action approvePayout() returns String; };
}

1단계 — 기본: before 검증과 에러 수집

입력 검증은 before 핸들러에서 req.error()로 모아서 반환합니다. 사용자는 한 번의 요청으로 모든 문제를 파악할 수 있습니다.

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

module.exports = class SettlementService extends cds.ApplicationService {
  async init() {
    const { PayoutRequests } = this.entities;

    this.before('CREATE', PayoutRequests, (req) => {
      const { amount, currency, sellerId } = req.data;
      if (!sellerId) req.error(400, '셀러 ID는 필수입니다.', 'in/sellerId');
      if (!amount || amount <= 0)
        req.error(400, '정산 금액은 0보다 커야 합니다.', 'in/amount');
      if (currency && !['KRW', 'USD', 'EUR'].includes(currency))
        req.error(400, `지원하지 않는 통화입니다: ${currency}`, 'in/currency');
      // 여기서 에러가 1건 이상 수집되면 on 핸들러는 실행되지 않고
      // 400 응답에 details 배열로 모든 에러가 담겨 나갑니다.
    });

    return super.init();
  }
};

세 번째 인자(target)는 in/필드명 형식으로 지정하면 Fiori Elements UI가 해당 입력 필드에 에러를 바인딩해 줍니다.

2단계 — 실무: on 핸들러의 reject, try/catch, 로깅

액션 구현부에서는 상태 검증 실패 시 req.reject()로 즉시 중단하고, 외부 시스템 호출은 try/catch로 감싸 로깅 후 의미 있는 에러로 변환합니다.

const LOG = cds.log('settlement');

this.on('approvePayout', PayoutRequests, async (req) => {
  const [id] = req.params.at(-1) ? [req.params.at(-1).ID] : [];
  const row = await SELECT.one.from(PayoutRequests).where({ ID: id });

  if (!row) return req.reject(404, `정산 요청을 찾을 수 없습니다: ${id}`);
  if (row.status !== 'PENDING')
    return req.reject(409, {
      code: 'PAYOUT_INVALID_STATE',
      message: `현재 상태(${row.status})에서는 승인할 수 없습니다.`
    });

  try {
    const bank = await cds.connect.to('BankTransferAPI'); // 외부 송금 시스템
    await bank.send('POST', '/transfers', { ref: row.ID, amount: row.amount });
  } catch (e) {
    LOG.error('은행 송금 API 실패', { requestId: row.ID, cause: e.message });
    // 원인(e)을 삼키지 않고 502로 변환해 다시 던짐 → 자동 롤백 보장
    return req.reject(502, {
      code: 'BANK_GATEWAY_ERROR',
      message: '송금 시스템 연동에 실패했습니다. 잠시 후 다시 시도하세요.'
    });
  }

  await UPDATE(PayoutRequests, id).with({ status: 'APPROVED' });
  LOG.info('정산 승인 완료', { requestId: id });
  return 'APPROVED';
});

포인트는 세 가지입니다. (1) cds.log()로 컴포넌트 단위 로거를 만들어 구조화 로깅을 남기고, (2) catch 블록에서 내부 예외를 그대로 노출하지 않고 사용자 친화적 메시지로 변환하며, (3) reject를 호출함으로써 트랜잭션이 확실히 롤백되도록 합니다.

3단계 — 프로덕션: 커스텀 에러 클래스, 전역 훅, 테스트, 보안

규모가 커지면 에러를 클래스로 표준화하고, 서비스 전역 error 이벤트 훅에서 응답을 정제합니다.

// srv/lib/errors.js — 도메인 에러 표준화
class DomainError extends Error {
  constructor(status, code, message, target) {
    super(message);
    this.status = status;   // CAP이 HTTP 상태 코드로 매핑
    this.code = code;       // OData error.code로 노출
    this.target = target;
  }
}
class InsufficientBalanceError extends DomainError {
  constructor(available) {
    super(422, 'SETTLE_INSUFFICIENT_BALANCE',
      `정산 가능 잔액(${available})을 초과했습니다.`, 'in/amount');
  }
}
module.exports = { DomainError, InsufficientBalanceError };
// init() 내부 — throw만 해도 CAP이 status/code를 응답에 매핑
const { InsufficientBalanceError } = require('./lib/errors');

this.before('CREATE', PayoutRequests, async (req) => {
  const bal = await SELECT.one.from('my.settlement.SellerBalances')
                          .where({ seller_ID: req.data.sellerId });
  if (bal && req.data.amount > bal.available)
    throw new InsufficientBalanceError(bal.available);
});

// 전역 에러 훅: 프로덕션에서 내부 정보(스택, SQL) 노출 차단
this.on('error', (err, req) => {
  if (process.env.NODE_ENV === 'production' && !err.code?.startsWith('SETTLE_')) {
    err.message = '요청 처리 중 오류가 발생했습니다.'; // 내부 메시지 마스킹
    delete err.stack;
  }
});

테스트는 cds.test로 상태 코드와 에러 코드를 검증합니다.

// test/settlement.test.js (jest)
const cds = require('@sap/cds');
const { GET, POST, expect } = cds.test(__dirname + '/..');

test('잔액 초과 시 422와 도메인 에러 코드를 반환한다', async () => {
  const res = await POST('/odata/v4/settlement/PayoutRequests',
    { sellerId: 'S-001', amount: 999999999, currency: 'KRW' },
    { validateStatus: () => true });
  expect(res.status).to.equal(422);
  expect(res.data.error.code).to.equal('SETTLE_INSUFFICIENT_BALANCE');
});

성능 관점에서는 before 검증에 필요한 조회를 최소화하고(단건 SELECT.one, 필요한 컬럼만), 전역 error 훅에서는 무거운 작업을 피하는 것이 일반적으로 권장됩니다.

흔한 실수와 트러블슈팅

Q1. req.error()를 호출했는데 뒤 코드가 계속 실행됩니다. 버그인가요?

버그가 아니라 의도된 동작입니다. req.error()는 에러를 수집만 하고 흐름을 끊지 않습니다. 즉시 중단이 필요하면 return req.error(...)로 핸들러를 빠져나가거나 req.reject()를 사용하세요. 단, return해도 같은 핸들러 내 이전 수집분과 함께 응답됩니다.

Q2. try/catch로 감쌌더니 실패했는데도 데이터가 커밋됩니다.

catch에서 에러를 다시 던지지 않으면 CAP은 "정상 종료"로 판단해 commit합니다. 로깅·변환 후에는 반드시 throw 또는 req.reject()로 에러를 전파해야 자동 롤백이 동작합니다.

Q3. 프로덕션에서 500 에러 메시지가 "Internal Server Error"로만 나옵니다.

5xx 계열은 보안상 상세 메시지가 기본적으로 마스킹됩니다. 사용자에게 보여줄 메시지라면 4xx(400/404/409/422)와 명시적 code를 사용하고, 내부 원인은 로그로만 남기는 것이 권장 패턴입니다.

Q4. after 핸들러에서 던진 에러로 응답은 실패했는데 데이터는 이미 바뀐 것 같습니다.

after는 커밋 이전 단계라 같은 트랜잭션에서 롤백되는 것이 기본이지만, 핸들러 안에서 cds.spawn이나 별도 트랜잭션(cds.tx)으로 수행한 작업은 롤백 범위 밖입니다. 부수 작업은 요청 트랜잭션과 분리 여부를 명확히 설계하세요.

이어서 살펴볼 만한 주제

에러 처리 기본기를 갖췄다면 다음 주제로 확장해 보세요. (1) @mandatory, @assert.range 같은 CDS 어노테이션 기반 선언적 검증으로 before 핸들러 코드를 줄이는 방법, (2) i18n 텍스트 번들(_i18n/messages.properties)로 에러 메시지를 다국어화하는 패턴, (3) messaging(이벤트)과 outbox를 이용해 외부 연동 실패를 재시도 가능한 구조로 바꾸는 설계, (4) Cloud Logging 서비스와 연계한 구조화 로그 상관관계(correlation ID) 추적이 자연스러운 다음 순서입니다.

댓글 0

아직 댓글이 없습니다.