본문 바로가기

Backend/Node.js

스트림 (Stream)

이벤트 기반 플랫폼인 Node.JS 에서 I/O 를 처리하는 효율적인 방법은 실시간으로 입력을 바로 사용하고 출력을 즉시 내보내는 것이며, 이는 스트림을 사용해 아주 쉽게 구현할 수 있습니다.

 

Node.JS 에서 대부분의 비동기 API 는 버퍼를 사용합니다. 모든 데이터를 버퍼에 수집하고 모두 읽은 후 콜백에 전달하는 방식이죠. 하지만 스트림은 도착한 리소스를 버퍼에 기다리지 않고 바로 처리할 수 있어 굉장히 효율적입니다.

 

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

const file = '---';

console.time('buffer');
fs.readFile(file, (err, buffer) => {
  zlib.gzip(buffer, (err, buffer) => {
    fs.writeFile('tmp.gz', buffer, err => {
      console.timeEnd('buffer');
    });
  });
});

 

대략 600MB 정도 되는 파일을 예시로 버퍼를 사용하는 API 를 통해 읽고, zlib 로 압축한 후 저장해봤습니다.

환경마다 다르겠지만 대략 15000ms 정도 걸렸고, 이 프로그램이 사용하는 메모리는 1.2G 정도까지 올라갔습니다.

파일이 더 컸더라면 버퍼에 들어가지 못하거나 프로세스가 터졌을 것입니다.

 

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

const file = '---';

console.time('stream');
fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream('tmp.gz'))
  .on('finish', () => {
    console.timeEnd('stream');
  });

 

동일한 내용을 stream API 를 사용해 작성해봤습니다. 이번에도 비슷하게 15000ms 정도 걸렸지만, 사용하는 메모리는 100MB 도 넘지 않았습니다. 아주 일정한 양의 메모리만 사용하고, 이는 Node.JS 프로세스에 전혀 문제될 수준이 아닙니다. 파일이 더 컸더라도 아무런 문제가 되지 않았겠죠.

위의 짧은 예시에서 볼 수 있듯이 pipe 메서드를 통한 구성은 코드 자체도 간결하고, 훨씬 안정적인 프로그램을 만들 수 있습니다.

 

- Readable Stream

 

Node.JS 의 스트림은 Readable / Writable / Duplex / Transform 4가지의 구현체가 있습니다.

각 클래스는 EventEmitter 의 인스턴스이기도 해서 end, error 등의 이벤트를 제공하기도 합니다.

또한 스트림은 아래의 두 가지 동작 모드를 지원합니다.

 

  • 바이너리 : 데이터가 버퍼 또는 문자열과 같은 청크 형태로 스트리밍 됨
  • 객체 : 스트리밍 데이터가 별도 객체로 취급 됨

스트림에 대해선 이정도로 각설하고 Readable 스트림에 대해 보도록 하겠습니다. Readable 스트림은 데이터 소스를 나타내며, 데이터를 수신하는 방법은 non-flowing, flowing 두 가지가 있습니다.

 

xxx.on('readable', () => {
  ...
  xxx.read()
  ...
})
.on('end', () => ...);

 

readable 이벤트에 대해 리스너를 등록하는 것은 Readable 스트림에서 읽기의 기본 패턴이며 non-flowing 모드로 동작합니다.

이 리스너 내부에선 read 메서드를 수행할 수 있고, 이를 통해 스트림으로부터 명시적으로 데이터를 가져올 수 있습니다.

 

xxx.on('data', chunk => {
  ...
})
.on('end', () => ...);

 

다른 하나의 방법은 data 이벤트에 대해 리스너를 등록하는 것입니다. 이것이 flowing 모드이며 non-flowing 모드처럼 read 메서드를 별도 호출하지 않고 데이터가 오자마자 바로 리스너에 전달됩니다. (chunk 로 받은 것처럼)

non-flowing 모드에 비해 데이터 흐름 제어력이 떨어지는데, data 이벤트를 일시 중지 하기 위해선 pause 메서드를 호출해야 하고 이때 들어오는 데이터는 내부 버퍼에 캐시됩니다.

 

- Writable Stream

 

writable.write(chunk, [encoding], [callback]);
writable.end([chunk], [encoding], [callback]);

 

데이터를 Writable 스트림으로 밀어내는건 write 메서드를 사용하면 됩니다.

인코딩 인자나 콜백 함수는 옵셔널이며, 스트림에 write 할 게 없을 땐 end 메서드를 사용합니다.

여기까지 보니 일전에 봤던 EventEmitter 와 거의 동일해서 EventEmitter 의 인스턴스라고 했던 내용이 이해가 갑니다.

 

- 백프레셔 (Back-pressure)

 

Stream API 를 가끔 봐온 정도여서 무엇인지만 대충 알고 있었는데 Stream 에서 백프레셔라는 개념은 생소해서 적어봅니다.

실제로 Node.JS 에서 스트림이 소비하는 것보다 데이터를 더 빠르게 쓸 경우 병목 현상이 발생할 수 있습니다.

이를 해결하는 방법은 들어오는 데이터를 버퍼링하는 것인데, 이또한 무한정 버퍼링을 해버리면 점점 데이터가 쌓여 메모리가 터질 수 있습니다.

 

이런 상황이 발생하지 않도록 내부 버퍼는 제한을 초과하면 writable.write 가 false 를 반환합니다.

이 제한을 highWatermark 라고 하는데 Writable 스트림이 가지고 있는 속성이고 내부 버퍼의 크기 제한을 나타냅니다.

write 메서드가 false 를 반환하면 어플리케이션은 더이상 데이터를 쓰지 말아야 하고, 버퍼가 비워지는건 drain 이벤트를 통해 이를 알 수 있습니다. 이러한 메커니즘을 백프레셔 라고 합니다.

 

- Duplex Stream

 

양방향 스트림은 뭐 특별할 건 없고 Readable 과 Writable 스트림 둘 다 가능한 스트림입니다.

따라서 위에서 나왔던 read, write 다 가능하고 readable, data, drain 등의 이벤트를 전부 수신할 수 있습니다.

 

- Transform Stream

 

Transform 스트림은 데이터 변환을 처리할 수 있도록 특별히 설계된 이중 스트림입니다.

그냥 보기엔 양방향 스트림과 다를 바가 없어 보이지만, Writable 에서 받은 데이터에 이름 그대로 변형을 적용한 후 Readable 에서 사용할 수 있도록 보내줍니다. 다른 스트림들과 다른 점은 transform 과 flush 라는 메서드가 있습니다.

 

transform 메서드는 말 그대로 데이터를 변환하는 작업을 하고, flush 메서드는 모든 작업이 완료되면 호출되어 스트림을 마무리하거나 남은 데이터를 푸시하는 역할을 합니다.

 

- Pipe

 

readable.pipe(writable, [options])

 

위 처음 예시에서 잠깐 봤던 것처럼 Readable 스트림의 pipe 메서드는 Readable 과 Writable 을 연결할 수 있습니다.

pipe 메서드는 readable 에서 만들어진 데이터를 writable 스트림으로 보내주고, readable 스트림이 end 이벤트를 전달하면 writable 스트림도 종료됩니다.

 

두 스트림을 pipe 로 연결하면 서로의 흡착이 생겨 데이터가 자동으로 readable 에서 writable 로 흐릅니다.

이는 read, write 메서드를 따로 호출할 필요도 없어 백프레셔 제어 또한 필요 없습니다.

 

마지막으로 좀 오래되긴 했지만 스트림 작업에 용이한 모듈 2개의 링크를 남기면서 이번 포스팅을 마무리합니다.

 

'Backend > Node.js' 카테고리의 다른 글

Use Github private packages  (0) 2022.11.20
모듈과 의존성  (0) 2022.11.12
제네레이터 (Generator)  (0) 2022.08.27
Promise & Promisify  (0) 2022.08.20
Observer Pattern in Node.js  (0) 2022.08.13