NestJS StreamableFile이란?
NestJS v8에서 도입된 StreamableFile은 파일과 대용량 데이터를 스트리밍으로 응답하는 공식 방법이다. 전체 데이터를 메모리에 올리지 않고 청크 단위로 전송하므로, 수백 MB 파일이나 수만 행의 CSV도 일정한 메모리로 처리할 수 있다. Express와 Fastify 모두 동일한 API로 동작한다.
기본 파일 다운로드
import { Controller, Get, StreamableFile, Res, Header } from '@nestjs/common';
import { createReadStream } from 'fs';
import { join } from 'path';
import type { Response } from 'express';
@Controller('files')
export class FileController {
// 방법 1: StreamableFile 반환 (권장)
@Get('download')
@Header('Content-Type', 'application/pdf')
@Header('Content-Disposition', 'attachment; filename="report.pdf"')
getFile(): StreamableFile {
const file = createReadStream(join(process.cwd(), 'uploads', 'report.pdf'));
return new StreamableFile(file);
}
// 방법 2: @Res()로 직접 제어
@Get('download-manual')
getFileManual(@Res() res: Response) {
const filePath = join(process.cwd(), 'uploads', 'large-file.zip');
res.set({
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="archive.zip"',
});
const stream = createReadStream(filePath);
stream.pipe(res);
// 에러 처리 필수
stream.on('error', (err) => {
res.status(500).json({ message: 'File streaming failed' });
});
}
}
StreamableFile을 반환하면 NestJS가 자동으로 스트림을 응답에 파이프한다. @Res()를 사용하면 Interceptor가 동작하지 않으므로, 가능하면 StreamableFile을 사용한다.
동적 파일 생성 스트리밍
import { Readable, PassThrough } from 'stream';
@Controller('export')
export class ExportController {
constructor(private readonly orderService: OrderService) {}
// CSV 스트리밍: 메모리에 전체를 올리지 않음
@Get('orders/csv')
@Header('Content-Type', 'text/csv; charset=utf-8')
@Header('Content-Disposition', 'attachment; filename="orders.csv"')
async exportOrdersCsv(): Promise<StreamableFile> {
const passThrough = new PassThrough();
// BOM + 헤더 작성
passThrough.write('uFEFF'); // UTF-8 BOM (Excel 한글 깨짐 방지)
passThrough.write('주문ID,회원명,금액,상태,날짜n');
// 비동기로 데이터를 청크 단위로 스트리밍
this.streamOrders(passThrough).catch((err) => {
passThrough.destroy(err);
});
return new StreamableFile(passThrough);
}
private async streamOrders(stream: PassThrough): Promise<void> {
const BATCH_SIZE = 1000;
let offset = 0;
while (true) {
const orders = await this.orderService.findBatch(offset, BATCH_SIZE);
if (orders.length === 0) break;
for (const order of orders) {
const line = `${order.id},${order.memberName},${order.amount},${order.status},${order.createdAt}n`;
// backpressure 처리: 버퍼가 차면 drain 대기
if (!stream.write(line)) {
await new Promise<void>((resolve) => stream.once('drain', resolve));
}
}
offset += BATCH_SIZE;
}
stream.end();
}
}
핵심은 backpressure 처리다. stream.write()가 false를 반환하면 내부 버퍼가 가득 찬 것이므로, drain 이벤트를 기다려야 한다. 이를 무시하면 메모리가 무한히 증가한다.
JSON 스트리밍: 대용량 배열
// 수만 건의 JSON 배열을 스트리밍
@Get('users/json-stream')
@Header('Content-Type', 'application/json')
async exportUsersJson(): Promise<StreamableFile> {
const passThrough = new PassThrough();
this.streamUsersAsJson(passThrough).catch((err) => {
passThrough.destroy(err);
});
return new StreamableFile(passThrough);
}
private async streamUsersAsJson(stream: PassThrough): Promise<void> {
stream.write('['); // JSON 배열 시작
const BATCH_SIZE = 500;
let offset = 0;
let isFirst = true;
while (true) {
const users = await this.userService.findBatch(offset, BATCH_SIZE);
if (users.length === 0) break;
for (const user of users) {
const prefix = isFirst ? '' : ',';
isFirst = false;
const chunk = prefix + JSON.stringify({
id: user.id,
name: user.name,
email: user.email,
});
if (!stream.write(chunk)) {
await new Promise<void>(resolve => stream.once('drain', resolve));
}
}
offset += BATCH_SIZE;
}
stream.write(']'); // JSON 배열 끝
stream.end();
}
// NDJSON (Newline Delimited JSON) — 클라이언트가 한 줄씩 파싱 가능
@Get('users/ndjson')
@Header('Content-Type', 'application/x-ndjson')
async exportUsersNdjson(): Promise<StreamableFile> {
const passThrough = new PassThrough();
this.streamUsersAsNdjson(passThrough).catch(err => passThrough.destroy(err));
return new StreamableFile(passThrough);
}
private async streamUsersAsNdjson(stream: PassThrough): Promise<void> {
let offset = 0;
while (true) {
const users = await this.userService.findBatch(offset, 500);
if (users.length === 0) break;
for (const user of users) {
const line = JSON.stringify(user) + 'n';
if (!stream.write(line)) {
await new Promise<void>(resolve => stream.once('drain', resolve));
}
}
offset += 500;
}
stream.end();
}
NDJSON(Newline Delimited JSON)은 각 줄이 독립적인 JSON 객체이므로, 클라이언트가 전체 다운로드를 기다리지 않고 한 줄씩 파싱할 수 있다. 대시보드나 로그 뷰어에 적합하다.
Excel 스트리밍: ExcelJS
import * as ExcelJS from 'exceljs';
@Get('orders/excel')
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@Header('Content-Disposition', 'attachment; filename="orders.xlsx"')
async exportExcel(): Promise<StreamableFile> {
const passThrough = new PassThrough();
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
stream: passThrough,
useStyles: true,
});
const sheet = workbook.addWorksheet('주문내역');
// 헤더
sheet.columns = [
{ header: '주문ID', key: 'id', width: 10 },
{ header: '회원명', key: 'name', width: 20 },
{ header: '금액', key: 'amount', width: 15 },
{ header: '상태', key: 'status', width: 12 },
{ header: '날짜', key: 'date', width: 20 },
];
// 스트리밍 모드: 한 행씩 쓰고 즉시 커밋
let offset = 0;
while (true) {
const orders = await this.orderService.findBatch(offset, 1000);
if (orders.length === 0) break;
for (const order of orders) {
sheet.addRow({
id: order.id,
name: order.memberName,
amount: order.amount,
status: order.status,
date: order.createdAt,
}).commit(); // 즉시 스트림에 flush
}
offset += 1000;
}
sheet.commit();
await workbook.commit();
return new StreamableFile(passThrough);
}
ExcelJS의 WorkbookWriter는 스트리밍 모드를 지원한다. row.commit()으로 각 행을 즉시 스트림에 쓰므로, 수십만 행의 Excel도 일정 메모리로 생성할 수 있다.
S3 프록시 스트리밍
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
@Controller('storage')
export class StorageController {
constructor(private readonly s3: S3Client) {}
@Get(':key')
async proxyS3File(
@Param('key') key: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const command = new GetObjectCommand({
Bucket: 'my-bucket',
Key: key,
});
const s3Response = await this.s3.send(command);
res.set({
'Content-Type': s3Response.ContentType || 'application/octet-stream',
'Content-Length': s3Response.ContentLength?.toString(),
'Cache-Control': 'public, max-age=86400',
});
// S3 Body는 Readable Stream → 바로 StreamableFile로 전달
return new StreamableFile(s3Response.Body as Readable);
}
}
@Res({ passthrough: true })를 사용하면 응답 헤더를 직접 설정하면서도 StreamableFile을 반환할 수 있다. S3 → 서버 → 클라이언트로 데이터가 메모리에 버퍼링되지 않고 직접 흐른다. 파일 업로드 관련 심화 내용은 NestJS 파일 업로드 Multer·S3 심화 글을 참고하자.
Interceptor와 StreamableFile
// StreamableFile을 감지하여 로깅하는 Interceptor
@Injectable()
export class DownloadLogInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap((data) => {
if (data instanceof StreamableFile) {
// StreamableFile의 내부 스트림에 이벤트 리스너 추가
const stream = data.getStream();
let bytes = 0;
stream.on('data', (chunk: Buffer) => {
bytes += chunk.length;
});
stream.on('end', () => {
console.log({
path: request.url,
duration: Date.now() - start,
bytes,
status: 'completed',
});
});
stream.on('error', (err) => {
console.error({
path: request.url,
duration: Date.now() - start,
bytes,
status: 'failed',
error: err.message,
});
});
}
}),
);
}
}
// 글로벌 적용
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: DownloadLogInterceptor },
],
})
export class AppModule {}
StreamableFile은 Interceptor를 정상적으로 통과한다. @Res()를 직접 사용하면 Interceptor가 우회되므로, 로깅·모니터링이 필요하면 반드시 StreamableFile을 사용해야 한다. Interceptor 패턴에 대한 자세한 내용은 NestJS Interceptor RxJS 심화 글을 참고하자.
에러 처리와 타임아웃
// 스트리밍 중 에러 처리 패턴
@Get('safe-download/:id')
async safeDownload(
@Param('id') id: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const filePath = await this.fileService.getPath(id);
if (!existsSync(filePath)) {
throw new NotFoundException('File not found');
}
const stream = createReadStream(filePath);
// 스트림 에러 → 연결 종료
stream.on('error', (err) => {
if (!res.headersSent) {
res.status(500).json({ message: 'Stream error' });
} else {
res.destroy(); // 이미 헤더 전송 후라면 연결 강제 종료
}
});
// 클라이언트 연결 끊김 감지 → 리소스 정리
res.on('close', () => {
if (!stream.destroyed) {
stream.destroy();
}
});
const stat = statSync(filePath);
res.set({
'Content-Type': 'application/octet-stream',
'Content-Length': stat.size.toString(),
});
return new StreamableFile(stream);
}
// 스트리밍 타임아웃 설정
@Get('long-export')
async longExport(@Res({ passthrough: true }) res: Response) {
// Express 기본 타임아웃은 2분 → 대용량 스트리밍 시 늘려야 함
res.setTimeout(600_000); // 10분
const passThrough = new PassThrough();
// ... 스트리밍 로직
return new StreamableFile(passThrough);
}
Range 요청: 부분 다운로드
// 동영상 스트리밍, 이어받기를 위한 Range 지원
@Get('video/:id')
async streamVideo(
@Param('id') id: string,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const filePath = await this.fileService.getVideoPath(id);
const stat = statSync(filePath);
const fileSize = stat.size;
const range = req.headers.range;
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
res.status(206);
res.set({
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize.toString(),
'Content-Type': 'video/mp4',
});
const stream = createReadStream(filePath, { start, end });
return new StreamableFile(stream);
}
// Range 없으면 전체 파일
res.set({
'Content-Length': fileSize.toString(),
'Content-Type': 'video/mp4',
'Accept-Ranges': 'bytes',
});
return new StreamableFile(createReadStream(filePath));
}
HTTP 206 Partial Content를 구현하면 브라우저 비디오 플레이어의 시크(seek)와 이어받기가 정상 동작한다.
마무리
| 패턴 | 사용 시점 | 핵심 |
|---|---|---|
| StreamableFile + ReadStream | 정적 파일 다운로드 | 가장 간단 |
| PassThrough 스트림 | 동적 CSV/JSON/Excel 생성 | backpressure 처리 필수 |
| S3 프록시 | 클라우드 스토리지 파일 | 버퍼링 없이 직접 파이프 |
| Range 요청 | 비디오, 이어받기 | 206 Partial Content |
| NDJSON | 실시간 대량 데이터 | 줄 단위 파싱 가능 |
StreamableFile은 NestJS에서 대용량 응답을 처리하는 공식이자 유일한 정답이다. 핵심은 메모리에 전체를 버퍼링하지 않는 것, 그리고 backpressure(drain 이벤트)를 반드시 처리하는 것이다. 이 두 원칙만 지키면 수 GB 파일도 50MB 메모리로 스트리밍할 수 있다.