Node.js · 2025-12-28

Node.js 파일 스트리밍과 대용량 다운로드 최적화

Node.js 스트림을 이용한 파일 스트리밍과 대용량 다운로드에서 발생하는 버퍼링, 백프레셔, 범위 응답 처리와 압축 전략을 다루는 기술적 설명서. 서버 성능 최적화

작성일 : 2025-12-28 ㆍ 작성자 : 관리자
post
목차

개요

대용량 파일을 클라이언트로 전송할 때 단순히 파일을 메모리에 올려 전송하면 서버가 금방 부담을 받는다. Node.js 스트림은 메모리 사용을 낮추고 I/O 효율을 높이는 도구다. 이 글에서는 스트림의 동작 원리, 백프레셔 문제, highWaterMark 조정, Range 요청 처리, 그리고 실제 코드 예제로 최적화 방법을 설명한다.

스트림 기본 개념

읽기와 쓰기 스트림

Node.js는 데이터를 청크 단위로 처리한다. fs.createReadStream으로 파일을 읽고, HTTP 응답(res)에 pipe하면 청크가 순차적으로 전송된다. 이 방식은 전체 파일을 메모리에 올리지 않아도 되므로 메모리 사용량이 일정하게 유지된다.

백프레셔(backpressure)

백프레셔는 소비자가 생산자보다 느리게 데이터를 처리할 때 발생한다. pipe는 자동으로 pause와 resume을 호출해 백프레셔를 제어한다. 그렇다고 해서 모든 문제가 해결되지는 않는다. 클라이언트 네트워크 상태가 불안하면 서버가 많은 청크를 버퍼에 쌓으며 latency와 메모리 사용이 늘어날 수 있다.

핵심 최적화 기법

1) 적절한 highWaterMark 설정

highWaterMark는 내부 버퍼의 최대 크기다. 기본값이 항상 최적은 아니다. 대역폭이 넓고 네트워크 레이턴시가 낮으면 큰 값을, 약한 네트워크나 메모리 제약이 있다면 작은 값을 권장한다.

2) pipeline과 에러 처리

stream.pipeline은 스트림 연결 시 에러 전파와 정리를 안전하게 처리한다. pipe보다 예외 상황에서 누수를 방지한다.

const fs = require('fs');
const { pipeline } = require('stream');
const http = require('http');

http.createServer((req, res) => {
  const file = fs.createReadStream('large.bin', { highWaterMark: 64 * 1024 });
  res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
  pipeline(file, res, (err) => {
    if (err) console.error('Pipeline failed', err);
  });
}).listen(3000);

3) Range 요청(부분 전송) 처리

대용량 파일에서 클라이언트가 중간부터 다운로드하거나 재시작할 때 Range 헤더를 처리하면 효율적이다. 서버는 요청에 따라 start와 end를 지정해 createReadStream을 만든다.

const fs = require('fs');
const http = require('http');

http.createServer((req, res) => {
  const path = 'large.bin';
  const stat = fs.statSync(path);
  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) : stat.size - 1;
    res.writeHead(206, {
      'Content-Range': `bytes ${start}-${end}/${stat.size}`,
      'Accept-Ranges': 'bytes',
      'Content-Length': (end - start) + 1,
      'Content-Type': 'application/octet-stream'
    });
    const stream = fs.createReadStream(path, { start, end });
    stream.pipe(res);
  } else {
    res.writeHead(200, { 'Content-Length': stat.size, 'Content-Type': 'application/octet-stream' });
    fs.createReadStream(path).pipe(res);
  }
}).listen(3000);

4) 압축과 인라인 전송

전송 대역폭이 제한적이라면 gzip 또는 brotli로 압축해 전송하면 유리하다. 다만 이미 압축된 파일(예: ZIP, MP4 등)은 오히려 비효율적일 수 있다. 적절한 콘텐츠 유형 판단이 필요하다.

const zlib = require('zlib');
const { pipeline } = require('stream');
// 예: 텍스트 파일을 gzip으로 압축해 전송
pipeline(fs.createReadStream('large.txt'), zlib.createGzip(), res, (err) => {
  if (err) console.error('Compression pipeline failed', err);
});

버퍼링 문제 진단과 해결

버퍼가 예상보다 커지는 지표들은 메모리 사용 급증, 응답 지연, 파일 전송 지연 등이다. 해결책은 다음과 같다.

  • highWaterMark 값을 감소시켜 내부 버퍼 크기 제한
  • pipeline 사용으로 스트림 자원 정리 보장
  • 클라이언트 측 재시도 로직과 Range 요청 결합
  • 네트워크 혼잡에 맞춘 전송률 제한(rate limiting) 적용

실무에서의 권장 설정과 체크리스트

다음 항목을 점검해 최적화 수준을 높인다.

  • 파일 유형에 맞는 압축 적용 여부
  • highWaterMark 조정으로 메모리-성능 균형 설정
  • pipeline으로 예외 상황 처리 보장
  • Range 요청 지원으로 다운로드 재개 및 병렬화 허용
  • CDN과의 연계로 네트워크 부하 분산

마무리

Node.js 스트림은 대용량 파일 전송에서 필수 도구다. 핵심은 백프레셔를 이해하고 적절한 버퍼링과 에러 처리를 구현하는 것이다. Range 응답과 압축 전략을 더하면 사용자 경험과 서버 자원 활용 모두 개선된다. 실제 서비스에 적용할 때는 로그와 모니터링으로 병목 지점을 지속적으로 확인하는 습관이 중요하다.

Node.js 파일 스트리밍 예제 stream 대용량 파일 전송 Node Node 스트림 버퍼링 문제 Node.js 파일 스트리밍 백프레셔 highWaterMark Range 요청