CAP for Node

req.error 써도 왜 안 멈추지? #shorts #SAP #CAPforNode

▶ YouTube에서 보기

들어가며: 이 글에서 다루는 것

CAP(Cloud Application Programming Model) Node.js 런타임에서 에러 핸들링은 겉보기엔 단순합니다. req.error()req.reject() 두 API만 알면 될 것 같지만, 실제 프로젝트에서는 "에러를 던졌는데 코드가 계속 실행된다", "프로덕션에서 에러 메시지가 전부 Internal Server Error로 바뀐다", "트랜잭션이 롤백될 줄 알았는데 커밋됐다" 같은 문제가 반복적으로 발생합니다. 이 글은 SAP BTP 위에서 CAP Node.js 서비스를 운영하며 자주 마주치는 에러 핸들링 실수를 원리부터 짚고, 재고 이동 서비스라는 실무형 시나리오로 해결법을 단계별로 보여줍니다.

  • req.error() vs req.reject() vs throw의 동작 차이를 설명할 수 있다
  • 에러와 트랜잭션 롤백의 관계를 이해하고 의도대로 제어할 수 있다
  • 커스텀 에러 클래스와 srv.on('error') 훅으로 일관된 에러 응답을 설계할 수 있다
  • 프로덕션 환경의 에러 메시지 마스킹 동작을 이해하고 i18n 메시지로 대응할 수 있다

이 글을 보기 전에

이 글은 중급자용입니다. CDS 모델링 기초(entity, service 정의), CAP Node.js 서비스 핸들러 등록(srv.before, srv.on, srv.after), JavaScript의 async/await와 Promise 동작, 그리고 Error 객체와 프로토타입 상속에 대한 기본 이해를 전제로 합니다. OData 상태 코드(400, 404, 409, 500)의 의미를 알고 있으면 더 수월합니다.

테스트 환경

아래 환경을 기준으로 작성했습니다. CAP 런타임은 메이저 버전에 따라 에러 처리 세부 동작이 다를 수 있으므로 버전 확인을 권장합니다.

항목버전 / 비고
@sap/cds (Node.js 런타임)8.x (7.x에서도 대부분 동일하게 동작)
Node.js20 LTS 이상 권장
DB로컬 SQLite(in-memory), 배포 시 SAP HANA Cloud
플랫폼SAP BTP, Cloud Foundry 환경 기준
{
  "dependencies": { "@sap/cds": "^8" },
  "devDependencies": { "@cap-js/sqlite": "^1", "jest": "^29" }
}

버전 확인은 cds version, 로컬 실행은 cds watch로 합니다. 프로덕션 동작(에러 마스킹)을 로컬에서 재현하려면 NODE_ENV=production cds serve로 실행해 보는 것이 좋습니다.

핵심 개념: CAP 에러 처리 파이프라인의 동작 원리

CAP의 요청 처리는 공항 보안 검색대에 비유할 수 있습니다. 승객(요청)은 여러 검색대(before → on → after 핸들러)를 차례로 통과합니다. 이때 에러를 알리는 방법이 세 가지인데, 각각의 성격이 완전히 다릅니다.

  • req.reject(code, message) — 즉시 퇴장. 내부적으로 예외를 던져(throw) 그 자리에서 요청 처리를 중단합니다. 검색대에서 위험물이 발견되면 승객을 바로 돌려보내는 것과 같습니다.
  • req.error(code, message, target) — 위반 사항 수집. 예외를 던지지 않고 에러를 요청 컨텍스트에 쌓아둡니다. 핸들러 코드는 계속 실행되고, 파이프라인이 끝난 뒤 수집된 에러가 하나면 그대로, 여럿이면 details 배열로 묶여 400 계열 응답으로 반환됩니다. 입력 검증처럼 "문제를 한 번에 모두 알려주고 싶을 때" 적합합니다.
  • throw — 예상 밖 사고. 핸들러에서 던진 예외는 런타임이 잡아 에러 응답으로 변환합니다. 상태 코드 정보가 없는 일반 Error는 500으로 처리됩니다.

여기에 두 가지 원리가 더 얹힙니다. 첫째, 트랜잭션 경계입니다. CAP은 요청 단위로 관리되는 트랜잭션(managed transaction)을 사용하며, 핸들러가 에러로 종료되면 롤백, 정상 종료되면 커밋합니다. 즉 try/catch로 에러를 삼키고 정상 리턴하면 롤백되지 않습니다. 둘째, 프로덕션 마스킹입니다. 5xx 에러의 메시지는 프로덕션 프로파일에서 일반적으로 "Internal Server Error"류의 문구로 치환되어 내부 구현 정보 유출을 막습니다. 반면 4xx는 클라이언트에게 의미 있는 메시지이므로 그대로 전달됩니다. "왜 내 에러 메시지가 사라졌지?"의 대부분은 5xx로 분류된 에러 때문입니다.

마지막 조각은 srv.on('error', (err, req) => { ... }) 훅입니다. 응답 직전에 에러 객체를 마지막으로 손볼 수 있는 지점으로, 일반적으로 동기 함수로 작성해야 하며(비동기 작업 결과는 반영되지 않음) 메시지 치환·코드 매핑 같은 가벼운 가공에 쓰는 것이 권장됩니다.

실전 예제: 3단계로 완성하는 에러 핸들링

시나리오는 창고 간 재고 이동을 관리하는 InventoryService입니다. 자재 재고(MaterialStocks)를 출발 보관구역(sourceBin)에서 도착 보관구역(targetBin)으로 옮기는 이동 지시(StockTransfers)를 생성·확정합니다.

// db/schema.cds
namespace btpstacks.inventory;

entity MaterialStocks {
  key ID          : UUID;
  materialCode    : String(18);
  binCode         : String(10);
  availableQty    : Integer;
}

entity StockTransfers {
  key ID          : UUID;
  materialCode    : String(18);
  sourceBin       : String(10);
  targetBin       : String(10);
  transferQty     : Integer;
  status          : String(2) default 'NW'; // NW, CF, ER
}

// srv/inventory-service.cds
using { btpstacks.inventory as db } from '../db/schema';

service InventoryService {
  entity StockTransfers as projection on db.StockTransfers
    actions { action confirm() };
}

1단계 — 기본: reject와 error를 올바른 자리에 쓰기. 생성 전 검증에서 "모아서 알려줄 것"은 req.error, "더 진행할 수 없는 것"은 req.reject로 구분합니다.

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

module.exports = class InventoryService extends cds.ApplicationService {
  async init () {
    const { StockTransfers } = this.entities

    this.before('CREATE', StockTransfers, (req) => {
      const { transferQty, sourceBin, targetBin } = req.data
      // 입력 검증: 문제를 전부 수집해 한 번에 응답
      if (!transferQty || transferQty <= 0)
        req.error(400, '이동 수량은 1 이상이어야 합니다', 'transferQty')
      if (sourceBin === targetBin)
        req.error(400, '출발/도착 보관구역이 같을 수 없습니다', 'targetBin')
    })

    this.on('confirm', StockTransfers, async (req) => {
      const [id] = req.params.at(-1) ? [req.params.at(-1).ID] : []
      const transfer = await SELECT.one.from(StockTransfers).where({ ID: id })
      if (!transfer) return req.reject(404, '이동 지시를 찾을 수 없습니다')
      if (transfer.status === 'CF')
        return req.reject(409, '이미 확정된 이동 지시입니다')
      await UPDATE(StockTransfers, id).with({ status: 'CF' })
    })

    return super.init()
  }
}

핵심은 req.error 뒤의 코드가 계속 실행된다는 점입니다. 검증 블록에서는 그 특성이 장점(에러 누적)이지만, 실행을 멈춰야 하는 지점에서는 반드시 return req.reject(...) 형태로 써야 합니다.

2단계 — 실무: 커스텀 에러 클래스와 구조화 로깅. 도메인 에러를 클래스로 정의하면 상태 코드·에러 코드가 항상 함께 다니고, 핸들러는 throw만 하면 됩니다. 로깅은 console.log 대신 cds.log를 사용해 모듈별 로그 레벨 제어와 Kibana/Cloud Logging 연계를 준비합니다.

// srv/lib/errors.js
class DomainError extends Error {
  constructor (status, code, message, target) {
    super(message)
    this.status = status   // HTTP 상태 코드로 매핑됨
    this.code = code       // 클라이언트가 분기할 안정적인 식별자
    this.target = target
  }
}
class InsufficientStockError extends DomainError {
  constructor (materialCode, requested, available) {
    super(409, 'STOCK_INSUFFICIENT',
      `자재 ${materialCode} 가용 재고 부족 (요청 ${requested} / 가용 ${available})`,
      'transferQty')
    this.requested = requested
    this.available = available
  }
}
module.exports = { DomainError, InsufficientStockError }
// srv/inventory-service.js (confirm 핸들러 확장)
const LOG = cds.log('inventory')
const { InsufficientStockError } = require('./lib/errors')

this.on('confirm', StockTransfers, async (req) => {
  const id = req.params.at(-1).ID
  const transfer = await SELECT.one.from(StockTransfers).where({ ID: id })
  if (!transfer) return req.reject(404, '이동 지시를 찾을 수 없습니다')

  const stock = await SELECT.one.from('btpstacks.inventory.MaterialStocks')
    .where({ materialCode: transfer.materialCode, binCode: transfer.sourceBin })
  const available = stock?.availableQty ?? 0

  if (available < transfer.transferQty) {
    LOG.warn('재고 부족으로 확정 실패', { transferId: id, available })
    throw new InsufficientStockError(
      transfer.materialCode, transfer.transferQty, available)
  }

  // 아래 두 UPDATE는 같은 관리 트랜잭션에 속하며,
  // 이후 예외가 발생하면 함께 롤백된다
  await UPDATE('btpstacks.inventory.MaterialStocks')
    .set('availableQty -=', transfer.transferQty)
    .where({ materialCode: transfer.materialCode, binCode: transfer.sourceBin })
  await UPDATE(StockTransfers, id).with({ status: 'CF' })
  LOG.info('이동 지시 확정 완료', { transferId: id })
})

3단계 — 프로덕션: 에러 훅, 메시지 국제화, 테스트. 프로덕션에서는 (1) 5xx 메시지 마스킹을 전제로 도메인 에러를 4xx로 정확히 분류하고, (2) srv.on('error')에서 마지막 정리를 하며, (3) 에러 경로를 테스트로 고정합니다. 사용자 노출 메시지는 _i18n/messages.properties의 키로 관리하면 다국어 번들이 적용됩니다.

// 응답 직전 최종 가공 — 동기 함수로 작성 (일반적으로 비동기 로직 금지)
this.on('error', (err, req) => {
  if (err.code === 'STOCK_INSUFFICIENT') {
    err.message = req._.req?.__?.('STOCK_INSUFFICIENT') ?? err.message
  }
  // 예상 못한 500 에러: 상관관계 ID만 남기고 내부 정보는 로그로
  if (!err.status || err.status >= 500) {
    LOG.error('unexpected error', { id: req.id, message: err.message })
  }
})
// test/inventory.test.js — 에러 경로를 명시적으로 검증
const cds = require('@sap/cds')
const { GET, POST, expect } = cds.test(__dirname + '/..')

test('재고 부족 시 409와 STOCK_INSUFFICIENT 코드를 반환한다', async () => {
  const { data: t } = await POST('/odata/v4/inventory/StockTransfers', {
    materialCode: 'MAT-7731', sourceBin: 'A-01', targetBin: 'B-02',
    transferQty: 99999
  })
  const res = await POST(
    `/odata/v4/inventory/StockTransfers(${t.ID})/InventoryService.confirm`, {}
  ).catch(e => e.response)
  expect(res.status).to.equal(409)
  expect(res.data.error.code).to.equal('STOCK_INSUFFICIENT')
})

보안 관점에서 한 가지 더: 에러 메시지에 SQL 문장, 내부 호스트명, 스택 트레이스를 포함하지 않도록 하고, 상세 정보는 cds.log로만 남기는 습관이 권장됩니다. 성능 관점에서는 검증 로직을 before 단계에 모아 불필요한 DB 왕복 전에 요청을 걸러내는 것이 효과적입니다.

자주 만나는 함정

Q1. req.error()를 호출했는데 아래 코드가 계속 실행되어 DB까지 변경됩니다.
의도된 동작입니다. req.error는 에러를 수집만 하고 흐름을 끊지 않습니다. 파이프라인 종료 시점에 에러가 있으면 트랜잭션은 롤백되지만, 실행 자체를 멈추고 싶다면 return req.reject(...) 또는 throw를 사용하세요.

Q2. try/catch로 감쌌더니 실패했는데도 일부 UPDATE가 커밋됐습니다.
catch 블록에서 에러를 로깅만 하고 다시 던지지 않으면 핸들러가 정상 종료로 간주되어 관리 트랜잭션이 커밋됩니다. 복구 가능한 경우가 아니라면 catch에서 반드시 throw로 재전파하세요. 비슷한 원인으로, 비동기 DB 호출에 await를 빠뜨리면 예외가 try 블록 밖에서 터져 unhandled rejection이 됩니다.

Q3. 로컬에서는 잘 보이던 에러 메시지가 배포 후 "Internal Server Error"로 바뀝니다.
프로덕션 프로파일에서 5xx 메시지가 마스킹되기 때문입니다. 사용자에게 보여야 할 도메인 에러라면 400/404/409 등 4xx 상태 코드를 명시하세요. throw new Error('...')처럼 상태 코드 없는 예외는 500으로 분류됩니다.

Q4. srv.on('error')에서 DB를 조회해 메시지를 바꾸려는데 반영이 안 됩니다.
이 훅은 일반적으로 동기 실행을 전제로 하므로 비동기 결과가 응답에 반영되지 않습니다. 조회가 필요한 가공은 핸들러 안에서 미리 처리하고, 훅에서는 매핑·치환 같은 동기 작업만 수행하세요.

더 파볼 주제

이 글의 패턴을 익혔다면 다음 주제로 확장해 보세요. (1) @mandatory, @assert.range 같은 CDS 어노테이션 기반 선언적 검증으로 코드형 검증 줄이기, (2) 원격 서비스 호출(S/4HANA API 연동) 실패 시 재시도·서킷브레이커 패턴과 502/504 매핑, (3) SAP Cloud Logging·Open Telemetry와 연계한 에러 상관관계 추적, (4) 메시징(이벤트) 핸들러에서의 에러와 데드레터 처리. 특히 원격 호출 에러는 이 글의 로컬 트랜잭션 원리와 결합될 때 난이도가 올라가므로 다음 학습 대상으로 권장합니다.

댓글 0

아직 댓글이 없습니다.