NestJS StreamableFile 스트리밍

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 메모리로 스트리밍할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux