개요 및 이 글에서 다룰 내용
CAP(Cloud Application Programming Model) for Node.js에서 srv.run()은 서비스 인스턴스를 통해 CQN(Core Query Notation) 쿼리를 직접 실행하는 핵심 API입니다. 이 글에서는 트랜잭션 컨텍스트와 독립적으로 단일 쿼리를 실행하는 패턴을 깊이 있게 다룹니다. 일반적으로 CAP 서비스 핸들러 내부에서는 자동으로 트랜잭션이 관리되지만, 배치 작업·헬스체크·관리자 스크립트 등에서는 명시적인 독립 쿼리 실행이 필요합니다.
cds.run()과srv.run()의 동작 차이 이해- SELECT / INSERT / UPDATE / DELETE CQN 객체 작성 방법
- 독립 쿼리 실행 시 트랜잭션 경계와 커넥션 관리
- 실무 시나리오에서의 오류 처리 및 로깅 패턴
- 프로덕션 환경에서의 성능 최적화 포인트
이 글을 읽기 전에 알아두면 좋은 내용
JavaScript ES2017 이상의 async/await 문법, Node.js 모듈 시스템(CommonJS), CAP의 기본 구조(srv/, db/, schema.cds) 및 CDS 엔티티 정의 방식에 대한 이해가 필요합니다. 또한 cds.connect.to()로 외부 서비스에 연결하는 패턴과 SQLite/HANA 어댑터의 기본 동작을 알고 있으면 학습 흐름이 매끄럽습니다.
환경 및 준비물
이 글의 예제는 다음 환경을 기준으로 동작을 검증했습니다. SAP BTP 환경에서는 HANA Cloud, 로컬에서는 SQLite를 사용한다고 가정합니다.
- Node.js 20 LTS 이상 (CAP 8.x 권장)
@sap/cds8.2 이상@cap-js/sqlite1.7 이상 (로컬 개발용)@cap-js/hana1.2 이상 (BTP 배포 시)- SAP BTP Cloud Foundry 또는 Kyma 런타임 (배포 단계에서만 필요)
dependencies:
"@sap/cds": "^8.2.0"
"@cap-js/sqlite": "^1.7.0"
"@cap-js/hana": "^1.2.0"
devDependencies:
"@cap-js/cds-test": "^0.3.0"
설치 후 cds init으로 프로젝트를 생성하고, db/schema.cds와 srv/service.cds를 작성한 다음 cds watch로 로컬 실행을 시작하면 됩니다.
핵심 개념: srv.run의 동작 원리
CAP에서 데이터 접근은 항상 "서비스"라는 추상 계층을 통해 이루어집니다. cds.connect.to('db')로 얻은 데이터베이스 서비스든, cds.connect.to('CatalogService')로 얻은 원격 서비스든 모두 동일한 Service 인터페이스를 따릅니다. 그리고 그 인터페이스의 핵심 메서드가 바로 run()입니다.
srv.run(query)는 CQN 객체를 받아 해당 서비스의 어댑터(예: HANA, SQLite, OData 클라이언트)가 이해할 수 있는 형태로 번역해 실행합니다. 비유하자면 run()은 "택배 송장"을 운송회사에 넘기는 행위이며, 운송회사(어댑터)는 각자의 방식으로 짐을 옮깁니다. 동일한 CQN 객체가 SQLite에서는 SQL로, OData 백엔드에서는 HTTP 요청으로 변환됩니다.
cds.run()과 srv.run()의 결정적 차이는 "어느 서비스를 거치는가"입니다.
cds.run(query): 기본 데이터베이스 서비스(cds.db)에서 실행되며, 현재 트랜잭션 컨텍스트가 있으면 그것을 사용합니다.srv.run(query): 지정한 서비스 인스턴스에서 실행됩니다.srv가 DB 서비스라면 SQL이 나가고, 원격 서비스라면 외부 호출이 일어납니다.- 독립 쿼리:
cds.tx(async tx => tx.run(...))또는srv.run()을 핸들러 외부에서 호출하면 별도 트랜잭션이 열리고 자동 커밋됩니다.
핵심: 핸들러 내부의
this.run()은 요청 트랜잭션을 따라가고, 핸들러 바깥에서의srv.run()은 일반적으로 독립된 짧은 트랜잭션으로 실행됩니다.
기본 패턴으로 익히는 독립 쿼리
가장 단순한 형태부터 시작합니다. 부팅 직후 데이터베이스에 연결해 단일 카운트 쿼리를 실행하는 예제입니다. 시나리오는 "창고 재고 시스템에서 부팅 시점에 등록된 품목 수를 로깅"하는 상황입니다.
// srv/bootstrap.js
const cds = require('@sap/cds')
cds.on('served', async () => {
const db = await cds.connect.to('db')
const { WarehouseItem } = db.entities('inventory')
// SELECT 카운트 쿼리: 독립 실행
const total = await db.run(
SELECT.one`count(*) as cnt`.from(WarehouseItem)
)
console.log(`[bootstrap] 등록된 품목 수: ${total.cnt}`)
})
위 코드는 서비스가 완전히 기동된 직후 served 이벤트에서 한 번만 실행됩니다. 어떤 요청 컨텍스트에도 묶이지 않으며, db.run()이 내부적으로 짧은 트랜잭션을 열어 SELECT를 수행하고 종료합니다.
// 단일 INSERT
await db.run(
INSERT.into(WarehouseItem).entries({
ID: 'SKU-A0007',
name: '리튬 배터리 18650',
stock: 240
})
)
// 조건부 UPDATE
const affected = await db.run(
UPDATE(WarehouseItem).set({ stock: 0 }).where({ ID: 'SKU-A0007' })
)
console.log(`갱신된 행: ${affected}`)
실무 시나리오: 외부 트리거 기반 일괄 정리 작업
실무에서는 단순히 한 줄 실행으로 끝나지 않습니다. 외부 메시지(예: BTP Event Mesh, 또는 정기 스케줄러)에서 트리거되어 만료된 견적서를 일괄 종료시키는 시나리오를 가정합니다. 이때는 오류 처리·로깅·실행 시간 측정이 필수입니다.
// srv/quotation-cleanup.js
const cds = require('@sap/cds')
const LOG = cds.log('quotation-cleanup')
module.exports = async function cleanupExpiredQuotations() {
const db = await cds.connect.to('db')
const { SalesQuotation } = db.entities('sales')
const startedAt = Date.now()
try {
const expired = await db.run(
SELECT.from(SalesQuotation)
.columns('ID', 'customerName', 'validUntil')
.where`validUntil < ${new Date()} and status = 'OPEN'`
.limit(500)
)
if (expired.length === 0) {
LOG.info('만료된 견적이 없습니다. 종료합니다.')
return { closed: 0 }
}
LOG.info(`만료 견적 ${expired.length}건 발견, 일괄 종료를 시작합니다.`)
const ids = expired.map(q => q.ID)
const affected = await db.run(
UPDATE(SalesQuotation)
.set({ status: 'EXPIRED', closedAt: new Date() })
.where({ ID: { in: ids } })
)
LOG.info(`${affected}건의 견적을 EXPIRED로 변경했습니다.`, {
elapsedMs: Date.now() - startedAt
})
return { closed: affected, ids }
} catch (err) {
LOG.error('일괄 정리 작업 실패', {
message: err.message,
code: err.code
})
throw err
}
}
프로덕션: 트랜잭션, 테스트, 보안
프로덕션 수준에서는 원자성·재시도·SQL 인젝션 방지·테스트 자동화가 모두 갖춰져야 합니다. 다음은 결제 정산 배치를 가정한 코드입니다.
// srv/settlement-job.js
const cds = require('@sap/cds')
const LOG = cds.log('settlement')
async function runSettlement(merchantId, { maxRetry = 3 } = {}) {
if (!merchantId || typeof merchantId !== 'string') {
throw new Error('merchantId가 유효하지 않습니다.')
}
const db = await cds.connect.to('db')
const { PaymentEntry, SettlementBatch } = db.entities('billing')
let attempt = 0
while (attempt < maxRetry) {
attempt++
try {
return await cds.tx(async tx => {
const entries = await tx.run(
SELECT.from(PaymentEntry)
.where`merchantId = ${merchantId} and settledAt is null`
)
if (entries.length === 0) return { batchId: null, count: 0 }
const batchId = cds.utils.uuid()
const total = entries.reduce((sum, e) => sum + Number(e.amount), 0)
await tx.run(
INSERT.into(SettlementBatch).entries({
ID: batchId,
merchantId,
totalAmount: total,
entryCount: entries.length,
createdAt: new Date()
})
)
await tx.run(
UPDATE(PaymentEntry)
.set({ settledAt: new Date(), batch_ID: batchId })
.where({ ID: { in: entries.map(e => e.ID) } })
)
LOG.info('정산 배치 생성 완료', { batchId, count: entries.length })
return { batchId, count: entries.length, total }
})
} catch (err) {
LOG.warn(`정산 시도 ${attempt}/${maxRetry} 실패`, { code: err.code })
if (attempt >= maxRetry) throw err
await new Promise(r => setTimeout(r, 500 * attempt))
}
}
}
module.exports = { runSettlement }
보안 측면에서 가장 중요한 것은 파라미터 바인딩입니다. 태그드 템플릿(where`merchantId = ${merchantId}`) 또는 객체 조건(where: { ID: { in: ids } })을 사용하면 어댑터가 자동으로 prepared statement로 변환하므로 인젝션 위험이 차단됩니다.
흔한 실수와 트러블슈팅
Q1. 핸들러 안에서 srv.run()을 직접 호출했더니 트랜잭션이 중첩됩니다.
핸들러 컨텍스트에서는 this.run() 또는 req.run()을 사용해야 현재 요청 트랜잭션을 공유합니다. 외부에서 가져온 srv 변수로 srv.run()을 호출하면 컨텍스트 전파가 누락되어 별도 트랜잭션이 열릴 수 있습니다.
Q2. SELECT.one을 썼는데 결과가 undefined입니다.
SELECT.one은 매칭되는 행이 없을 때 null 또는 undefined를 반환합니다. 직후에는 반드시 null 체크를 추가하세요. 디버깅 시에는 DEBUG=sql cds watch로 실제 SQL을 확인하는 것이 효과적입니다.
Q3. 로컬 SQLite에서는 잘 되는데 HANA Cloud에서 Connection lost 오류가 납니다.
HANA는 유휴 커넥션이 끊길 수 있습니다. @cap-js/hana의 풀 설정에서 acquireTimeoutMillis·idleTimeoutMillis를 조정하거나, BTP 환경에서 HANA Cloud 자동 일시정지 설정을 확인합니다.
확장 탐구: 이 패턴을 넘어서
독립 쿼리 실행 패턴에 익숙해졌다면 트랜잭션 격리 수준과 cds.tx()의 중첩 동작, 원격 OData 서비스에 대한 srv.send()와 srv.run()의 차이, 스트리밍 쿼리(SELECT + foreach)를 이용한 대용량 처리를 살펴보세요. BTP Job Scheduler와의 연동으로 정기 배치를 구성하는 방식도 실전 적용폭을 넓혀줍니다.
댓글 0
아직 댓글이 없습니다.