Node.js 스트림 이해하기: 데이터 처리 최적화 비법

작성일 :

Node.js 스트림 이해하기: 데이터 처리 최적화 비법

Node.js는 비동기 I/O(입출력) 작업과 이벤트 기반 처리를 통해 뛰어난 성능을 자랑합니다. 이러한 성능의 핵심 요소 중 하나가 바로 스트림(Stream)입니다. 스트림은 대용량 데이터를 처리하는 데 있어 매우 중요한 역할을 하며, 데이터가 전송되는 동안 부분적으로 데이터에 접근하고 처리할 수 있게 해줍니다. 이 글에서는 Node.js의 스트림 개념을 이해하고 이를 활용해 효율적인 데이터 처리를 구현하는 방법에 대해 설명합니다.

스트림의 기본 개념

스트림은 데이터의 흐름을 표현하는 추상화된 개념으로, 데이터 조각(chunk)을 순차적으로 처리할 수 있게 도와줍니다. 이를 통해 메모리 효율성을 높일 수 있으며, 입력과 출력 작업 동안 비동기적으로 데이터를 처리할 수 있습니다. Node.js에서 스트림의 종류는 크게 네 가지로 나뉩니다:

  1. Readable Streams: 읽기 전용 스트림으로, 데이터 소스로부터 데이터 조각을 읽어들입니다.
  2. Writable Streams: 쓰기 전용 스트림으로, 데이터를 특정 목적지로 쓰는 역할을 합니다.
  3. Duplex Streams: 양방향 스트림으로, 읽기와 쓰기를 모두 수행할 수 있습니다. 예로는 TCP 소켓이 있습니다.
  4. Transform Streams: 데이터를 읽고 쓰는 동시에 데이터를 변환하는 스트림입니다.

Readable Stream 사용하기

Readable Stream은 외부 데이터 소스로부터 데이터를 읽어들이는 기능을 합니다. 예를 들어, 파일 시스템에서 파일을 읽는 것을 생각해볼 수 있습니다. Node.js에서는 fs.createReadStream 메서드를 사용하여 파일 시스템의 파일을 읽는 Readable Stream을 생성할 수 있습니다.

javascript
const fs = require('fs');
const readableStream = fs.createReadStream('example.txt', {
  encoding: 'utf8',
  highWaterMark: 16 * 1024 // 16KB 청크 사이즈
});

readableStream.on('data', (chunk) => {
  console.log('New chunk received:', chunk);
});

readableStream.on('end', () => {
  console.log('No more data to read.');
});

readableStream.on('error', (err) => {
  console.error('An error occurred:', err.message);
});

위 코드에서는 example.txt 파일을 16KB씩 읽어들이는 데모를 보여줍니다. data 이벤트 리스너는 새 데이터 덩어리를 받을 때마다 호출되며, end 이벤트는 모든 데이터를 다 읽었을 때 호출됩니다.

Writable Stream 사용하기

Writable Stream은 데이터를 특정 대상에 기록하는 역할을 합니다. 파일에 데이터를 쓰는 예제를 살펴보겠습니다. 마찬가지로 Node.js의 fs 모듈을 이용합니다.

javascript
const fs = require('fs');
const writableStream = fs.createWriteStream('output.txt');

writableStream.write('Hello, world!
');
writableStream.write('Writing to a file using streams is efficient!
');

writableStream.end('This is the end of the stream.
');

writableStream.on('finish', () => {
  console.log('All data written to file.');
});

writableStream.on('error', (err) => {
  console.error('An error occurred:', err.message);
});

이 코드는 output.txt 파일에 데이터를 쓰는 데모입니다. write 메서드를 통해 데이터를 스트림으로 쓰며, end 메서드는 스트림이 더 이상 쓸 데이터가 없음을 나타냅니다.

Duplex 및 Transform Streams 사용하기

Duplex 스트림은 읽기와 쓰기를 동시에 할 수 있는 스트림입니다. 예를 들어 TCP 소켓 서버의 예제를 들어 보겠습니다.

javascript
const net = require('net');

const server = net.createServer((socket) => {
  console.log('Client connected');
  socket.on('data', (data) => {
    console.log('Received data:', data.toString());
    socket.write('Echo: ' + data);
  });

  socket.on('end', () => {
    console.log('Client disconnected');
  });

  socket.on('error', (err) => {
    console.error('An error occurred:', err.message);
  });
});

server.listen(8080, () => {
  console.log('Server listening on port 8080');
});

이 예제에서는 클라이언트와 서버 간의 양방향 데이터 통신을 제공합니다. 클라이언트가 데이터를 보낼 때 서버는 해당 데이터를 받아서 처리하고, 다시 클라이언트로 돌려보내는(에코) 기능을 수행합니다.

Transform 스트림은 읽은 데이터를 변환하여 다시 쓰는 스트림입니다. 예를 들어, 데이터의 내용을 대문자로 변환하는 예제를 보겠습니다.

javascript
const { Transform } = require('stream');

class UpperCaseTransform extends Transform {
  _transform(chunk, encoding, callback) {
    this.push(chunk.toString().toUpperCase());
    callback();
  }
}

const uppercase = new UpperCaseTransform();

process.stdin.pipe(uppercase).pipe(process.stdout);

위 코드는 stdin에서 입력된 데이터를 대문자로 변환하여 stdout으로 출력합니다. Transform 스트림은 데이터를 실시간으로 변형하여 처리할 수 있는 매우 강력한 도구입니다.

스트림 사용의 장점

  1. 메모리 효율성: 스트림을 사용하면 데이터를 작은 청크 단위로 처리할 수 있어 메모리 사용량이 적습니다.
  2. 비동기 처리: I/O 작업을 비동기로 처리하여 높은 처리 성능을 제공합니다.
  3. 파이프라인 구성: 스트림을 체인으로 연결하여 데이터 파이프라인을 구성할 수 있습니다.
  4. 광범위한 활용성: 파일, 네트워크, 소켓, 그리고 다른 I/O 소스에 대해 쉽게 사용할 수 있습니다.

결론

Node.js 스트림은 대용량 데이터를 효율적으로 처리할 수 있는 강력한 도구입니다. 기본적인 개념을 이해하고 다양한 스트림을 활용하면 메모리 효율적이고 성능 좋은 애플리케이션을 개발할 수 있습니다. 본문에서 제공한 예제들을 통해 직접 스트림의 기능을 실습해 보세요.