CAP for Node.js Custom Handler — before/after/on 이벤트 훅으로 비즈니스 로직 구현

Moderator · 조회 2
CAP Node Custom Handler 개요

CAP for Node.js Custom Handler — before/after/on 이벤트 훅으로 비즈니스 로직 구현

1. 개요 및 학습 목표

SAP Cloud Application Programming Model(CAP)에서 서비스의 동작을 제어하는 핵심 메커니즘은 Custom Handler입니다. CAP 런타임은 들어오는 요청을 .before(), .on(), .after() 세 단계의 이벤트 훅으로 분류하여 처리합니다. 개발자는 이 훅에 함수를 등록함으로써, CDS로 정의한 서비스 위에 입력 검증, 비즈니스 로직, 결과 보강 등의 로직을 자유롭게 추가할 수 있습니다.

이 튜토리얼을 마치면 다음을 수행할 수 있습니다.

2. 프로젝트 구조 및 서비스 파일 등록

CAP for Node.js에서 Custom Handler를 등록하려면, CDS 서비스 정의 파일과 동일한 이름의 JavaScript 파일을 srv/ 폴더에 배치해야 합니다. 예를 들어 srv/cat-service.cds에 정의된 서비스라면, srv/cat-service.js 파일이 자동으로 로드됩니다.

일반적인 프로젝트 구조는 다음과 같습니다.

my-bookshop/
  db/
    schema.cds          # 데이터 모델 정의
  srv/
    cat-service.cds     # 서비스 정의
    cat-service.js      # Custom Handler (자동 매칭)
  package.json

CDS 서비스 정의 예시입니다.

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

service CatalogService {
  entity Books as projection on my.Books;
  entity Authors as projection on my.Authors;

  // 커스텀 액션 정의
  action submitOrder (book : Books:ID, quantity : Integer);
}

Handler 파일은 두 가지 패턴으로 작성할 수 있습니다. 하나는 함수 패턴이고, 다른 하나는 클래스 패턴입니다. 클래스 패턴은 cds.ApplicationService를 상속받아 init() 메서드 안에서 핸들러를 등록합니다.

// 함수 패턴
module.exports = (srv) => {
  srv.before('CREATE', 'Books', req => { /* ... */ })
  srv.on('submitOrder', async req => { /* ... */ })
  srv.after('READ', 'Books', books => { /* ... */ })
}
// 클래스 패턴
class CatalogService extends cds.ApplicationService {
  init() {
    this.before('CREATE', 'Books', req => { /* ... */ })
    this.on('submitOrder', async req => { /* ... */ })
    this.after('READ', 'Books', books => { /* ... */ })
    return super.init()
  }
}
module.exports = CatalogService

클래스 패턴을 사용할 때는 init() 마지막에 반드시 return super.init()을 호출해야 합니다. 이를 빠뜨리면 프레임워크 기본 핸들러가 등록되지 않아 서비스가 정상 동작하지 않습니다.

3. .before() 훅 — 입력 검증 패턴

.before() 훅은 메인 로직(.on()) 이전에 실행되며, 주로 입력 데이터 검증 용도로 사용됩니다. CAP 런타임은 등록된 모든 .before() 핸들러를 병렬로 실행하며, 핸들러 내에서 req.error()를 호출하면 에러가 누적됩니다. 에러가 하나라도 수집되면 후속 .on() 처리는 자동으로 중단되고 클라이언트에 에러 응답이 반환됩니다.

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

module.exports = (srv) => {
  const { Books } = srv.entities

  // 1단계: 기본 입력 검증
  srv.before('CREATE', Books, req => {
    const { title, stock, price } = req.data
    if (!title) req.error(400, '제목은 필수 입력 항목입니다.', 'title')
    if (stock < 0)  req.error(400, '재고는 0 이상이어야 합니다.', 'stock')
    if (price <= 0)  req.error(400, '가격은 0보다 커야 합니다.', 'price')
  })

  // UPDATE 시에도 동일한 검증 적용
  srv.before('UPDATE', Books, req => {
    if (req.data.stock !== undefined && req.data.stock < 0) {
      req.error(400, '재고는 0 이상이어야 합니다.', 'stock')
    }
  })
}

req.error()는 즉시 요청을 중단하지 않고 에러를 req.errors 배열에 누적합니다. 이 방식 덕분에 클라이언트는 한 번의 요청으로 모든 검증 실패 항목을 확인할 수 있습니다. 반면 req.reject()는 즉시 처리를 중단하고 에러 응답을 반환합니다. 여러 필드를 동시에 검증해야 할 때는 req.error()가, 치명적 권한 오류 등 즉시 중단이 필요한 경우에는 req.reject()가 적합합니다.

before on after 흐름

4. .on() 훅 — 비즈니스 로직 구현

.on() 훅은 요청의 실제 비즈니스 로직을 처리하는 단계입니다. 동기 요청(CRUD, 커스텀 액션/함수)에 대해서는 등록된 핸들러가 순차적으로 실행되며, next()를 통해 다음 핸들러로 제어를 넘길 수 있습니다. 비동기 이벤트의 경우에는 등록된 핸들러가 병렬로 실행됩니다.

const cds = require('@sap/cds')

module.exports = (srv) => {
  const { Books, Authors } = srv.entities

  // 커스텀 액션 구현
  srv.on('submitOrder', async req => {
    const { book, quantity } = req.data

    // 재고 확인
    const b = await SELECT.one.from(Books).where({ ID: book })
    if (!b) return req.reject(404, `도서 ${book}을(를) 찾을 수 없습니다.`)
    if (b.stock < quantity) {
      return req.reject(409, `재고 부족: 현재 ${b.stock}권, 요청 ${quantity}권`)
    }

    // 재고 차감
    await UPDATE(Books).where({ ID: book }).set({
      stock: { '-=': quantity }
    })

    // 주문 확인 결과 반환
    return { book, quantity, remainingStock: b.stock - quantity }
  })

  // CRUD READ 커스터마이징 — 특정 조건 추가
  srv.on('READ', Authors, async (req, next) => {
    // 기본 READ를 실행한 후 결과 가공
    const authors = await next()
    // 비활성 작가 필터링 등의 로직
    return authors
  })
}

커스텀 액션에서 return 값은 클라이언트 응답 본문이 됩니다. CRUD 이벤트의 .on() 핸들러를 등록하면 CAP의 기본 제네릭 핸들러가 대체되므로, 기본 동작을 유지하면서 추가 로직을 넣고 싶다면 반드시 next()를 호출해야 합니다.

5. .after() 훅 — 결과 데이터 보강

.after() 훅은 .on() 처리 완료 후 반환된 결과 데이터를 가공하는 데 사용됩니다. 등록된 .after() 핸들러는 병렬로 실행되며, 결과 배열을 직접 수정하는 방식으로 데이터를 보강합니다.

const cds = require('@sap/cds')

module.exports = (srv) => {
  const { Books } = srv.entities

  // 결과 데이터에 할인율 추가
  srv.after('READ', Books, books => {
    // books가 배열인 경우와 단일 객체인 경우 모두 처리
    const items = Array.isArray(books) ? books : [books]
    for (const b of items) {
      // 재고 111권 초과 시 할인 표시
      if (b.stock > 111) b.discount = '11%'
      // 재고 부족 경고 표시
      if (b.stock !== undefined && b.stock < 10) {
        b.stockWarning = '재고 부족 - 곧 품절 예정'
      }
    }
  })

  // 개별 항목 처리: 'each' 키워드 활용
  srv.after('each', Books, (book) => {
    if (book.price) {
      book.formattedPrice = `${book.price.toLocaleString()}원`
    }
  })

  // 작가 정보 보강 — 비동기 후처리
  srv.after('READ', Books, async (books, req) => {
    const items = Array.isArray(books) ? books : [books]
    const authorIds = [...new Set(items.map(b => b.author_ID).filter(Boolean))]

    if (authorIds.length > 0) {
      const authors = await SELECT.from('Authors').where({ ID: { in: authorIds } })
      const authorMap = new Map(authors.map(a => [a.ID, a.name]))

      for (const b of items) {
        if (b.author_ID) b.authorName = authorMap.get(b.author_ID)
      }
    }
  })
}

.after('each', entity, handler)는 결과의 각 항목마다 핸들러를 호출하는 편의 기능입니다. 배열 여부를 직접 분기하지 않아도 되므로 단순 변환에 적합합니다. 다만 비동기 작업이 필요한 경우에는 일반 .after()에서 배열 전체를 한 번에 처리하는 것이 성능상 유리합니다.

6. next() 체인과 인터셉터 패턴

.on() 핸들러는 두 번째 파라미터로 next 함수를 받을 수 있습니다. next()를 호출하면 다음에 등록된 .on() 핸들러(또는 CAP 기본 제네릭 핸들러)로 제어가 넘어갑니다. 이 메커니즘을 활용하면 인터셉터 패턴을 구현할 수 있습니다.

인터셉터 패턴의 핵심은, 와일드카드('*')를 이벤트나 엔티티 파라미터로 사용하여 모든 요청을 가로채는 공통 핸들러를 먼저 등록한 뒤, 개별 핸들러에서 실제 로직을 처리하는 것입니다.

const cds = require('@sap/cds')

class CatalogService extends cds.ApplicationService {
  init() {
    const { Books } = this.entities

    // 인터셉터: 모든 요청에 대한 권한 검사
    this.on('*', async (req, next) => {
      console.log(`[${new Date().toISOString()}] ${req.event} ${req.target?.name || ''} by ${req.user.id}`)

      // 관리자 전용 작업 체크
      if (req.event === 'DELETE' && !req.user.is('admin')) {
        return req.reject(403, '삭제 권한이 없습니다.')
      }

      // 다음 핸들러로 제어 전달
      return next()
    })

    // 캐싱 인터셉터: READ 요청에 캐시 적용
    this.on('READ', Books, async (req, next) => {
      const cacheKey = JSON.stringify(req.query)
      const cached = this._cache?.get(cacheKey)

      if (cached && Date.now() - cached.timestamp < 60000) {
        return cached.data
      }

      // 기본 핸들러 실행
      const result = await next()

      // 결과 캐싱
      if (!this._cache) this._cache = new Map()
      this._cache.set(cacheKey, { data: result, timestamp: Date.now() })

      return result
    })

    return super.init()
  }
}

module.exports = CatalogService

next()를 호출하지 않으면 후속 핸들러와 CAP 기본 제네릭 핸들러가 실행되지 않습니다. 이를 의도적으로 활용하면 기본 CRUD 동작을 완전히 대체할 수 있지만, 의도하지 않게 next() 호출을 빠뜨리면 데이터가 반환되지 않는 문제가 발생하므로 주의해야 합니다.

Custom Handler 코드 예제

7. cds.Request 객체 주요 속성 활용

모든 핸들러의 첫 번째 파라미터인 reqcds.Request 인스턴스로, 요청에 대한 풍부한 컨텍스트 정보를 제공합니다. 실무에서 자주 사용하는 속성을 정리하면 다음과 같습니다.

속성설명활용 예시
req.data요청 페이로드 (CREATE/UPDATE 바디)입력 검증, 데이터 변환
req.usercds.User 인스턴스권한 검사 (req.user.is('admin'))
req.tenant멀티테넌트 식별자테넌트별 분기 처리
req.event이벤트명 (CREATE/READ/UPDATE/DELETE 또는 커스텀)공통 핸들러에서 이벤트별 분기
req.headersHTTP 요청 헤더언어, 인증 토큰 확인
req.paramsURL 경로 파라미터 (이터러블)키 값 추출
req.queryCQN 쿼리 객체쿼리 조건 동적 변경
req.target대상 엔티티 CSN 정의엔티티 메타데이터 참조
req.path내비게이션 포함 전체 경로로깅, 감사 추적

실무 활용 예시입니다.

srv.before('*', '*', req => {
  // 감사 로그: 누가 어떤 엔티티에 어떤 작업을 했는지 기록
  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    user: req.user.id,
    tenant: req.tenant,
    event: req.event,
    entity: req.target?.name,
    path: req.path
  }))
})

srv.before('CREATE', 'Books', req => {
  // 멀티테넌트 환경에서 테넌트별 데이터 보강
  req.data.tenant = req.tenant

  // 생성자 정보 자동 기록
  req.data.createdBy = req.user.id
  req.data.createdAt = new Date().toISOString()
})

srv.before('READ', 'Books', req => {
  // 인증되지 않은 사용자는 공개 도서만 조회 가능
  if (req.user === cds.User.anonymous) {
    req.query.where({ isPublic: true })
  }
})

req.user가 인증되지 않은 경우 cds.User.anonymous 인스턴스가 할당됩니다. 운영 환경에서는 XSUAA 등 인증 미들웨어와 연동하여 실제 사용자 정보가 채워집니다.

8. 실무 적용 가이드 및 주의사항

Custom Handler를 실무에 적용할 때 자주 발생하는 실수와 권장 패턴을 정리합니다.

흔한 실수 Top 5

FAQ

Q: 하나의 이벤트에 여러 핸들러를 등록하면 실행 순서는?

A: .before().after()는 병렬로 실행됩니다. .on() 핸들러는 등록 순서대로 순차 실행되며, next()를 통해 다음 핸들러로 넘어갑니다. 전체 흐름은 Before(병렬) -> On(순차) -> After(병렬)입니다.

Q: 와일드카드 '*'로 등록한 핸들러와 특정 엔티티 핸들러의 우선순위는?

A: .on() 핸들러의 경우 등록 순서가 실행 순서입니다. 와일드카드 핸들러를 먼저 등록하고 next()를 호출하면 후속 특정 핸들러가 실행됩니다. 인터셉터 패턴으로 공통 로직(로깅, 인증)을 먼저 처리하고 개별 로직으로 넘기는 것이 일반적입니다.

Q: .on()에서 기본 CRUD 동작을 완전히 대체하고 싶다면?

A: next()를 호출하지 않으면 됩니다. 이 경우 CAP 제네릭 핸들러는 실행되지 않으므로, 데이터베이스 쿼리를 직접 작성해야 합니다. cds.run()이나 CQL API를 사용하여 직접 데이터를 조회/수정할 수 있습니다.

프로덕션 체크리스트

다음 단계

Custom Handler의 기본기를 익혔다면, 다음 주제로 학습 범위를 확장할 수 있습니다.

참고 자료

이 튜토리얼은 @sap/cds 8.x (CAP for Node.js) 기준으로 작성되었습니다. 버전에 따라 API 세부 동작이 다를 수 있으므로, 프로젝트에서 사용하는 @sap/cds 버전의 릴리스 노트를 함께 참고하시기 바랍니다.