이벤트 기반 플랫폼인 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 |