이 글이 답하는 질문
CAP Node.js 서비스에서 수백 MB짜리 PDF·이미지·CSV를 응답할 때 Buffer로 통째로 읽어 메모리가 터진 경험이 있다면, 이 글이 해결책입니다. ReadableStream 기반 스트리밍 응답으로 메모리 사용량을 일정하게 유지하는 방법을 다룹니다.
- CAP에서 미디어 엔티티(
LargeBinary)는 어떻게 스트리밍으로 서빙되는가? - 커스텀 핸들러에서
ReadableStream을 직접 반환하려면 어떻게 해야 하는가? - 프로덕션에서 백프레셔·에러 전파·권한 제어까지 어떻게 챙기는가?
이 글을 보기 전에
CAP Node.js 런타임(@sap/cds)으로 서비스 핸들러를 작성해 본 경험, CDS 모델링 기본기, Node.js의 Stream API(Readable, pipeline) 개념을 알고 있다고 가정합니다. 난이도는 advanced로, 기본 CRUD 예제는 생략합니다.
테스트 환경
- SAP BTP, Cloud Foundry 환경 (트라이얼로도 재현 가능)
@sap/cds8.x (7.x에서도 동일 패턴 동작, 일반적으로 8.x 권장)- Node.js 20 LTS
- 로컬 DB: SQLite(
@cap-js/sqlite), 배포 시 SAP HANA Cloud - 테스트 도구:
curl또는 REST Client,jest+@sap/cds test
핵심 개념 — 왜 Buffer가 아니라 Stream인가
파일 응답을 수도관에 비유하면, Buffer 방식은 물탱크를 가득 채운 뒤 한 번에 쏟아붓는 방식입니다. 500MB 파일이면 Node.js 프로세스 힙에 500MB가 통째로 올라가고, 동시 요청 4건이면 2GB — Cloud Foundry 기본 메모리 쿼터에서 OOM으로 인스턴스가 재시작됩니다. 반면 Stream은 수도관을 직접 연결하는 방식입니다. 64KB 단위 청크가 소스에서 클라이언트로 흘러가고, 클라이언트가 느리면 백프레셔(backpressure)로 읽기 속도를 자동 조절합니다. 메모리 사용량은 파일 크기와 무관하게 거의 일정합니다.
CAP Node.js 런타임은 이 패턴을 미디어 엔티티로 지원합니다. 핵심 애너테이션은 두 가지입니다.
@Core.MediaType— 해당 엘리먼트가 미디어 데이터임을 선언. 값 또는 다른 필드 참조로 MIME 타입 지정@Core.ContentDisposition.Filename— 다운로드 시 파일명을 담은 필드 지정
흐름을 도식화하면 다음과 같습니다.
클라이언트 GET /Invoices(...)/pdfContent
→ CAP 프로토콜 어댑터 (OData/REST)
→ 커스텀/제네릭 READ 핸들러
→ { value: ReadableStream } 반환
→ 어댑터가 HTTP 응답에 pipe (청크 단위 전송, 백프레셔 자동)
제네릭 핸들러는 HANA의 LOB 컬럼을 스트림으로 읽어 그대로 흘려보내고, 커스텀 핸들러에서는 파일시스템·오브젝트 스토어 등 어떤 소스든 Readable만 반환하면 동일하게 동작합니다. 일반적으로 수 MB를 넘는 바이너리는 무조건 이 경로를 태우는 것이 권장됩니다.
실전 예제 3단계
1단계 — 미디어 엔티티 기본: 인보이스 PDF 서빙
인보이스 아카이브 시나리오입니다. CDS 모델에 미디어 엘리먼트를 선언합니다.
// db/schema.cds
namespace archive;
using { cuid, managed } from '@sap/cds/common';
entity InvoiceDocs : cuid, managed {
invoiceNo : String(20);
fileName : String(255);
mimeType : String(100);
@Core.MediaType : mimeType
@Core.ContentDisposition.Filename : fileName
pdfContent : LargeBinary;
}
// srv/archive-service.cds
using archive from '../db/schema';
service ArchiveService @(requires: 'authenticated-user') {
entity InvoiceDocs as projection on archive.InvoiceDocs;
}
이것만으로 GET /odata/v4/archive/InvoiceDocs(<id>)/pdfContent 요청 시 런타임이 DB LOB을 스트리밍으로 응답합니다. 업로드도 PUT + Content-Type: application/pdf로 스트리밍 수신됩니다. 별도 코드가 없어도 Buffer 전체 적재가 일어나지 않는다는 점이 핵심입니다.
2단계 — 커스텀 핸들러: 파일시스템 소스 + 에러/로깅
파일이 DB가 아니라 마운트된 볼륨에 있는 경우입니다. READ 핸들러에서 { value: Readable } 형태로 반환합니다.
// srv/archive-service.js
const cds = require('@sap/cds')
const fs = require('fs')
const path = require('path')
const LOG = cds.log('archive')
module.exports = class ArchiveService extends cds.ApplicationService {
async init () {
const { InvoiceDocs } = this.entities
this.on('READ', InvoiceDocs, async (req, next) => {
// /pdfContent 세그먼트 요청만 가로채고 나머지는 제네릭 위임
if (!req.req?.url.endsWith('/pdfContent')) return next()
const { ID } = req.params[0]
const meta = await SELECT.one
.from(InvoiceDocs, ID)
.columns('fileName', 'mimeType')
if (!meta) return req.reject(404, `문서 ${ID} 없음`)
const filePath = path.join('/mnt/invoices', `${ID}.pdf`)
const stream = fs.createReadStream(filePath)
stream.on('error', err => {
LOG.error('스트림 읽기 실패', { ID, code: err.code })
})
LOG.info('다운로드 시작', { ID, file: meta.fileName })
return {
value: stream,
$mediaContentType: meta.mimeType,
$mediaContentDispositionFilename: meta.fileName,
$mediaContentDispositionType: 'attachment'
}
})
return super.init()
}
}
주의할 점: createReadStream은 지연 오픈이므로 파일 부재(ENOENT)는 반환 이후 error 이벤트로 옵니다. 반드시 에러 리스너를 걸어 로그를 남기고, 사전 존재 확인이 필요하면 fs.promises.access를 먼저 호출하세요.
3단계 — 프로덕션: 오브젝트 스토어 + 백프레셔·테스트·보안
실무에서는 대용량 파일을 HANA에 넣지 않고 오브젝트 스토어(S3 호환)에 두는 경우가 많습니다. SDK 응답 스트림을 그대로 반환하면 됩니다.
this.on('READ', InvoiceDocs, async (req, next) => {
if (!req.req?.url.endsWith('/pdfContent')) return next()
const { ID } = req.params[0]
// 보안: 소유 부서 검증 (모델의 @restrict와 이중 방어)
const doc = await SELECT.one.from(InvoiceDocs, ID)
.columns('fileName', 'mimeType', 'costCenter')
if (doc?.costCenter !== req.user.attr.costCenter)
return req.reject(403, '접근 권한 없음')
const { Body } = await s3.send(new GetObjectCommand({
Bucket: process.env.INVOICE_BUCKET,
Key: `invoices/${ID}.pdf`
}))
// Body는 Node Readable — 백프레셔는 어댑터 pipe가 자동 처리
return { value: Body, $mediaContentType: doc.mimeType }
})
통합 테스트는 스트림 응답을 그대로 검증할 수 있습니다.
// test/archive.test.js
const cds = require('@sap/cds')
const { GET } = cds.test(__dirname, '..')
it('PDF를 청크 스트림으로 응답한다', async () => {
const res = await GET(
`/odata/v4/archive/InvoiceDocs(${SEED_ID})/pdfContent`,
{ responseType: 'stream', auth: { username: 'alice' } }
)
expect(res.status).toBe(200)
expect(res.headers['content-type']).toMatch('application/pdf')
let bytes = 0
for await (const chunk of res.data) bytes += chunk.length
expect(bytes).toBeGreaterThan(0)
})
성능 포인트: 클라이언트가 중간에 연결을 끊으면 소스 스트림도 정리돼야 합니다. 어댑터가 대부분 처리하지만, 외부 SDK 스트림은 req.req.on('close', () => Body.destroy())로 명시 정리하는 것이 안전합니다.
삽질 노트
- Q1. 응답이 스트림이 아니라 base64 JSON으로 나옵니다. — 엔티티 전체(
GET /InvoiceDocs(ID))를 조회하면 미디어 필드는 기본 제외되거나 인라인 직렬화됩니다. 반드시/pdfContent처럼 미디어 프로퍼티 세그먼트로 직접 요청해야 스트리밍 경로를 탑니다. - Q2. 커스텀 핸들러 추가 후 목록 조회(
GET /InvoiceDocs)가 깨졌습니다. —on('READ')는 목록·단건·미디어 요청을 전부 가로챕니다. 2단계 예제처럼 미디어 세그먼트가 아닐 때return next()로 제네릭 핸들러에 위임하는 분기가 필수입니다. - Q3. 큰 파일 다운로드 중 간헐적으로 응답이 잘립니다. — 소스 스트림
error이벤트를 처리하지 않으면 프로세스가 죽거나 연결이 유실됩니다. 에러 리스너 + 로깅을 항상 걸고, 게이트웨이(App Router) 타임아웃도 대용량에 맞게 조정하세요. - Q4. 업로드한 파일이 메모리를 잡아먹습니다. — 핸들러에서
req.data.pdfContent를 Buffer로 변환(concat)하고 있지 않은지 확인하세요. PUT 수신 스트림도 변환 없이 그대로 DB/스토어에 pipe해야 합니다.
핵심 한 줄
@Core.MediaType + { value: ReadableStream } 반환 — 이 두 가지만 지키면 CAP에서 파일 크기와 무관하게 일정한 메모리로 대용량 응답이 가능합니다.
더 파볼 주제
- CAP Attachments 플러그인(
@cap-js/attachments) — 오브젝트 스토어 연동을 애너테이션만으로 처리 - SAP BTP Object Store 서비스 바인딩과 자격증명 로테이션
- OData 스트리밍 업로드(PUT)와 멀티파트 처리, 바이러스 스캔 연계
- HTTP Range 요청 기반 부분 다운로드·재개(resume) 설계
더 읽어볼 자료
댓글 0
아직 댓글이 없습니다.