Node.js 스트림을 활용한 효율적인 대용량 파일 및 네트워크 데이터 처리 마스터하기
Lukas Schneider
DevOps Engineer · Leapcell

소개
웹 애플리케이션 및 백엔드 서비스의 세계에서 대용량 데이터를 효율적으로 처리하는 것은 끊임없는 과제입니다. 기가바이트 단위의 로그 파일, 고화질 비디오 스트리밍, API에서 방대한 데이터셋 처리 등, 전체 파일을 메모리에 로드하는 전통적인 접근 방식은 곧바로 심각한 결과를 초래할 수 있습니다. 바로 메모리 부족 오류, 느린 애플리케이션 성능, 전반적으로 좋지 않은 사용자 경험입니다. Node.js 서버가 파일을 처리하기 전에 10GB 파일을 메모리로 읽으려고 시도하는 시나리오를 상상해 보세요. 이는 재앙을 불러오는 지름길입니다. 바로 여기서 Node.js Streams API가 빛을 발하며, 데이터를 처리하기 위한 강력하고 우아하며 메모리 효율적인 패러다임을 제공합니다. 스트림은 데이터를 청크 단위로 처리함으로써 시스템 리소스를 과도하게 사용하지 않고도 불가능해 보이는 데이터 볼륨을 처리할 수 있게 합니다. 이 글에서는 Node.js Streams API를 깊이 파고들어 핵심 개념을 설명하고, 실제 애플리케이션 사례를 보여주며, 개발자가 강력하고 확장 가능한 데이터 중심 애플리케이션을 구축할 수 있도록 지원하는 방법을 보여줄 것입니다.
스트림 패러다임 이해
본질적으로 Node.js의 스트림은 데이터가 한 지점에서 다른 지점으로 흐르는 것을 작업하기 위한 추상 인터페이스입니다. 데이터를 단일의 연속적인 블록으로 처리하는 대신, 스트림은 데이터를 작고 관리하기 쉬운 청크로 나눕니다. 이러한 청크 단위 처리는 효율성의 핵심입니다. 컨베이어 벨트를 상상해 보세요. 데이터 항목(청크)이 그 위를 흐르고, 다양한 지점에서 각 항목이 통과하면서 작업이 수행되며, 벨트의 전체 내용을 한 번에 제공할 필요가 없습니다.
세부 사항으로 들어가기 전에 Node.js 스트림과 관련된 몇 가지 주요 용어를 정의해 보겠습니다.
- 스트림 (Stream): Node.js 객체의 많은 부분에서 구현되는 추상 인터페이스입니다. 메모리 사용량을 줄이는 청크 단위 데이터 처리를 가능하게 하는 데이터 처리 기본 요소입니다.
- Readable Stream: 데이터를 읽을 수 있는 스트림입니다. 파일의 경우
fs.createReadStream
, 클라이언트로부터의 HTTP 응답,process.stdin
등이 예시입니다. - Writable Stream: 데이터가 쓰여질 수 있는 스트림입니다. 파일의 경우
fs.createWriteStream
, 서버로부터의 HTTP 요청,process.stdout
등이 예시입니다. - Duplex Stream: Readable이면서 동시에 Writable인 스트림입니다.
net.Socket
및zlib
스트림이 표준적인 예시입니다. - Transform Stream: 입력에 기반하여 출력이 계산되는 Duplex 스트림입니다. 데이터를 통과하면서 변환합니다.
zlib.createGzip
(데이터 압축용) 또는crypto.createCipher
(데이터 암호화용) 등이 예시입니다. - Pipe: Readable 스트림의 출력을 Writable 스트림의 입력에 연결하는 메커니즘입니다. 데이터 흐름과 백프레셔를 자동으로 처리하여 스트림 작업을 매우 간단하고 효율적으로 만듭니다.
스트림 작동 방식: 데이터의 흐름
스트림의 기본 원칙은 비동기적이고 이벤트 기반적인 특성입니다. Readable 스트림에서 데이터가 사용 가능해지면 'data'
이벤트를 발생시킵니다. 더 이상 읽을 데이터가 없을 때는 'end'
이벤트를 발생시킵니다. 마찬가지로 Writable 스트림은 더 많은 데이터를 수락할 준비가 되었을 때 'drain'
이벤트를 발생시키거나, 모든 데이터가 성공적으로 쓰여졌을 때 'finish'
이벤트를 발생시킬 수 있습니다.
스트림을 서로 pipe()
할 때 진정한 위력이 드러납니다. pipe()
메서드는 데이터 흐름과, 결정적으로 backpressure
를 자동으로 관리합니다. 백프레셔는 빠른 생산자(예: 디스크에서 읽는 빠른 Readable
스트림)가 느린 소비자(예: 네트워크 소켓에 쓰는 느린 Writable
스트림)를 압도하지 못하도록 하는 메커니즘입니다. 소비자가 따라가지 못할 때, pipe()
메서드는 자동으로 Readable
스트림을 일시 중지시켜 메모리 버퍼가 넘치는 것을 방지합니다. 소비자가 준비되면 Readable
스트림을 재개합니다.
실제 적용: 효율적인 대용량 파일 복사
일반적인 사용 사례인 대용량 파일 복사를 통해 스트림의 강력함을 설명해 보겠습니다.
전통적인 접근 방식 (메모리 집약적):
const fs = require('fs'); function copyFileBlocking(sourcePath, destinationPath) { fs.readFile(sourcePath, (err, data) => { if (err) { console.error('Error reading file:', err); return; } fs.writeFile(destinationPath, data, (err) => { if (err) { console.error('Error writing file:', err); return; } console.log('File copied successfully (blocking)!'); }); }); } // 'large-file.bin'이 5GB라고 가정해 봅시다. 이 코드는 5GB를 메모리에 로드합니다. // copyFileBlocking('large-file.bin', 'large-file-copy-blocking.bin');
이 접근 방식은 대용량 파일을 처리하기 전에 large-file.bin
전체를 Buffer
로 메모리에 읽습니다. 파일이 작을 때는 문제가 없지만, 파일이 클 경우에는 재앙입니다.
스트림 기반 접근 방식 (메모리 효율적):
const fs = require('fs'); function copyFileStream(sourcePath, destinationPath) { const readableStream = fs.createReadStream(sourcePath); const writableStream = fs.createWriteStream(destinationPath); readableStream.pipe(writableStream); readableStream.on('error', (err) => { console.error('Error reading from source stream:', err); }); writableStream.on('error', (err) => { console.error('Error writing to destination stream:', err); }); writableStream.on('finish', () => { console.log('File copied successfully (streamed)!'); }); } // 이것은 전체 파일을 메모리에 로드하지 않고 청크 단위로 파일을 복사합니다. // copyFileStream('large-file.bin', 'large-file-copy-stream.bin');
스트림 기반 접근 방식에서는 fs.createReadStream
이 데이터를 청크 단위로 읽고, fs.createWriteStream
이 데이터를 청크 단위로 씁니다. pipe()
메서드는 이 프로세스를 조정하고 백프레셔를 자동으로 처리합니다. 5GB 파일을 복사하면서도 수 메가바이트의 메모리 사용량만으로 충분하여 매우 효율적입니다.
고급 활용: 스트림을 사용한 데이터 변환
스트림은 데이터를 이동시키는 것뿐만 아니라 변환하는 데도 사용됩니다. 예를 들어, 복사되는 동안 대용량 파일을 실시간으로 압축하고 싶다고 가정해 보겠습니다. 여기서 Transform
스트림이 매우 유용합니다.
const fs = require('fs'); const zlib = require('zlib'); // Node.js 내장 압축 모듈 function compressFileStream(sourcePath, destinationPath) { const readableStream = fs.createReadStream(sourcePath); const gzipStream = zlib.createGzip(); // 압축을 위한 Transform 스트림 const writableStream = fs.createWriteStream(destinationPath + '.gz'); readableStream .pipe(gzipStream) // 데이터를 gzip 변환 스트림으로 파이프 .pipe(writableStream); // 그런 다음 압축된 데이터를 쓰기 스트림으로 파이프 readableStream.on('error', (err) => console.error('Read stream error:', err)); gzipStream.on('error', (err) => console.error('Gzip stream error:', err)); writableStream.on('error', (err) => console.error('Write stream error:', err)); writableStream.on('finish', () => { console.log('File compressed successfully!'); }); } // 예시: 대용량 로그 파일 압축 // compressFileStream('access.log', 'access.log');
여기서 zlib.createGzip()
은 Transform
스트림 역할을 합니다. 압축되지 않은 데이터를 입력으로 받고 압축된 데이터를 출력합니다. pipe
체인은 데이터가 읽히고, gzip 압축되고, 마지막으로 새 파일에 쓰여지는 과정을 원활하게 보장합니다.
사용자 정의 Transform 스트림 구축
자신만의 사용자 정의 Transform
스트림을 만들 수도 있습니다. 예를 들어, 텍스트를 대문자로 변환하는 스트림입니다.
const { Transform } = require('stream'); class UppercaseTransform extends Transform { _transform(chunk, encoding, callback) { // 청크(Buffer)를 문자열로 변환하고, 대문자로 변환한 다음, 다시 Buffer로 변환합니다. const upperChunk = chunk.toString().toUpperCase(); this.push(upperChunk); // 변환된 데이터를 다음 스트림으로 푸시 callback(); // 이 청크가 처리되었음을 나타냅니다. } // 선택 사항: _flush는 스트림이 끝나기 전에 호출되며, // 버퍼링된 데이터를 플러시하는 데 유용합니다. _flush(callback) { callback(); } } // 사용 예시: const readable = fs.createReadStream('input.txt'); const uppercaseTransformer = new UppercaseTransform(); const writable = fs.createWriteStream('output_uppercase.txt'); readable.pipe(uppercaseTransformer).pipe(writable); readable.on('error', (err) => console.error('Read error:', err)); uppercaseTransformer.on('error', (err) => console.error('Transform error:', err)); writable.on('error', (err) => console.error('Write error:', err)); writable.on('finish', () => console.log('File transformed to uppercase!'));
이 사용자 정의 UppercaseTransform
클래스에서 _transform
메서드는 핵심 로직입니다. 데이터 chunk
를 받아 변환(대문자로 변환)을 수행한 다음 this.push()
를 호출하여 변환된 데이터를 하위 스트림으로 보냅니다. callback()
은 청크가 처리되었고 스트림이 다음 청크를 준비했음을 알립니다.
네트워크 데이터 흐름에서의 스트림 적용
로컬 파일 외에도 Node.js 스트림은 네트워크 작업 처리에 있어 기본 요소입니다. HTTP 요청 및 응답, WebSocket 연결, TCP 소켓은 모두 스트림의 예시입니다.
예시: HTTP 응답 스트리밍
전체 대용량 파일을 메모리에 로드한 다음 HTTP 응답으로 보내는 대신, 직접 스트리밍할 수 있습니다.
const http = require('http'); const fs = require('fs'); const server = http.createServer((req, res) => { if (req.url === '/large-file') { const filePath = './large-file.bin'; // 이 파일이 존재한다고 가정 const stat = fs.statSync(filePath); // Content-Length 헤더를 위한 파일 크기 가져오기 res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': stat.size // 클라이언트가 파일 크기를 알 수 있도록 하는 중요한 정보 }); const readStream = fs.createReadStream(filePath); readStream.pipe(res); // 파일 읽기 스트림을 HTTP 응답 스트림에 직접 사용 readStream.on('error', (err) => { console.error('Error reading large file:', err); res.end('Server Error'); }); } else { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); } }); server.listen(3000, () => { console.log('Server listening on port 3000'); }); // 테스트 방법: curl http://localhost:3000/large-file > downloaded-large-file.bin
이 예시에서 fs.createReadStream
은 데이터를 res
(HTTP 응답) 객체에 파이프하며, 이는 Writable 스트림입니다. 이를 통해 클라이언트는 즉시 데이터를 받기 시작할 수 있으며, 서버는 수 기가바이트의 파일을 제공할 때도 메모리 급증을 피할 수 있습니다.
결론
Node.js Streams API는 잠재적으로 큰 데이터 페이로드를 다루는 모든 개발자에게 필수적인 도구입니다. 관리 가능한 청크 단위로 데이터를 처리하는 패러다임을 채택함으로써, 스트림은 메모리 제약에 굴복하지 않고 대용량 파일 및 네트워크 데이터 흐름을 쉽게 처리할 수 있는 고도로 효율적이고 확장 가능하며 복원력 있는 애플리케이션을 구축할 수 있게 합니다. Readable, Writable, Duplex 및 Transform 스트림과 pipe()
메서드 및 내장된 백프레셔 처리를 효과적으로 이해하고 활용하면, 리소스 사용량을 최적화하고 애플리케이션 성능을 크게 향상시키는 강력한 기능을 활용할 수 있습니다. 스트림은 Node.js가 데이터 중심 환경에서 진정으로 빛을 발하도록 합니다.