Medium - Node.js Streams; Everything you need to know
in Trend
Trend 파악을 위한 Medium 기고문 포스팅 - 노드 스트림; 당신이 알아야 할 모든 것
노트의 스트림은 작업하기 어렵고 이해하기는 더욱 어려운 것으로 알려져 있습니다. 좋은 소식은 이제 그렇지 않을 것이라는 겁니다. 많은 시간동안 개발자들은 스트림으로 작업하는 걸 더욱 쉽게 만들기 위한 목적으로 많은 패키지들을 개발했습니다. 그러나 이 기사에서는 네이티브 노드 스트림 API에 대해 초점을 맞춰 얘기하겠습니다.
노드의 스트림은 가장 잘못 이해되고 있는 아이디어 입니다. - Dominic Tarr
What exactly are streams?
스트림은 대이터의 집합으로 배열이나 문자열과 같습니다. 차이점은 스트림은 한번에 모두 사용가능한 것이 아니고 메모리에 다 올라와 있을 필요도 없다는 것이죠. 이것이 큰 데이터를 처리할 때 스트림을 정말로 강력하게 만들어주는 이유입니다. 데이터를 외부에서 하나의 덩어리고 가져올 때도 마찬가지죠.
그러나 스트림은 큰 데이터로 작업할 때만 쓰이는 것이 아닙니다. 스트림을 사용하면 우리 코드에 Composability를 부여합니다. 리눅스에서 파이프를 사용해서 다른 작은 명령어들로 구성되는 것처럼 노트 스트림에서도 똑같이 할 수 있습니다.
Composability with Linux commands
노드에 있는 많은 내장모듈들은 스트리밍 인터페이스를 가지고 있습니다.
Screenshot captured from my Pluralsight course — Advanced Node.js
위에 있는 리스트에는 읽기/쓰기가 가능한 스트림인 노드 네이티브 객체들을 나타냅니다. TCP 소켓이나 zlib, 암호화 스트림은 읽고 쓰기가 모두 가능합니다. 객체들이 또한 밀접하게 연관되어 있다는 것을 주목하세요. HTTP response는 클라이언트에서 읽을 수 있는 스트림이고 서버에다가 쓸 수 있는 스트림입니다. 이것은 HTTP가 기본적으로 하나의 객체로부터 읽어오고(http.IncomingMessage) 다른 객체에 쓰기 때문입니다.(http.ServerResponse)
그리고 stdio 스트림들(stdin, stdout, stderr)이 자식 프로세스로 갈 때 어떻게 반대 스트림 타입을 갖게 되는지 확인해 보세요. 이것은 메인 프로세스의 스트림과 stdio 스트림을 정말 쉽게 연결할 수 있도록 해줄겁니다.
A Streams practical example
이론은 좋지만 항상 100% 맞는 것은 아니죠. 예제를 통해 다양한 스트림들이 메모리를 사용하며 코드에서 어떻게 동작하는지 알아봅시다. 먼저 큰 파일을 만듭시다.
큰 파일을 만들 때 쓰기 가능한 스트림을 사용해서 작업을 수행했습니다. fs모듈은 스트림 인터페이스를 사용해서 데이터를 읽어오고 파일을 쓰는데 사용됩니다. 위의 예제에서는 big.file에 루프마다 백만줄씩 작성했습니다. 해당 스크립트를 실행하면 생성되는 파일은 아마 400MB쯤 될 것입니다.
서버가 요청을 받게되면 큰 파일을 fs.readFile 비동기 메소드를 사용해서 보낼 것입니다. 그러면 우리가 이벤트 루프나 다른 어떤 것도 멈추게 하지 않고 다 잘 동작하겠죠? 그렇죠? 흠.. 서버를 돌려보고 연결해서 메모리를 모니터링 하면서 어떤 일이 일어나는 지 알아봅시다. 제가 처음에 서버를 돌렸을 때 일반적인 메모리 사용은 8.7MB 였습니다.
그리고 서버에 연결을 하고나서 메모리 사용에 뭔가가 일어났죠
메모리 사용이 434.8MB로 뛰었습니다. 기본적으로 response 객체에 써서 내보내기 전에 big.file의 전체를 메모리에 올립니다. 이것은 매우 비효율적이죠. HTTP response 객체 (res) 또한 쓰기 가능한 스트림 입니다. 이 말은 우리가 big.file의 내용을 나타내는 읽기 가능한 스트림이 있다면 위의 response의 스트림과 연결시키면 400MB의 메모리 사용없이 동일한 결과를 만들 수 있다는 것입니다.
노드의 fs 모듈은 createReadStream 메소드를 이용해서 우리에게 읽기 가능한 스트림을 제공해 줍니다. 그리고 우리는 그것을 response 객체에 연결할 것입니다.
이제 다시 서버에 연결해보면 놀라운 일이 생깁니다.(메모리 사용량을 한번 보세요.)
무슨일이 일어난 것일까요? 클라이언트가 big file을 요청하면 한번에 한 덩어리를 스트림해 줍니다. 이것은 메모리 버퍼를 전혀 하지 않는 다는 것이죠. 메모리 사용은 25MB 언저리까지 올라가고 그게 다입니다. 이 예제를 한계까지 몰아붙일 수도 있습니다. big.file의 내용을 백만 대신에 오백만으로 대체하시면 파일 용량이 2GB까지 올라갈 것이고 노드의 기본 버퍼에서는 제약사항이 있을 정도로 큰 것이죠.
만약 같은 것을 fs.readFile을 사용해서 보낸다면 그냥 할 수 없습니다.(제한 설정을 바꿀 수 있습니다). 그러나 fs.createReadStream은 2GB의 데이터 요청이 와도 아무 문제없이 스트림으로 보낼 수 있습니다. 가장 좋은 점은 프로세스 메모리 사용량이 거의 비슷하다는 것이죠. 이제 스트림에 대해서 배울 준비가 되셨나요?
Streams 101
노드에는 4개의 기본 스트림 종류가 있습니다: 읽기가능, 쓰기가능, Duplex, 변환 스트림입니다.
- 읽기가능 스트림은 데이터를 읽어들일 수 있는 소스의 추상화입니다. fs.createReadStream 메소드가 해당 스트림 중 하나입니다.
- 쓰기가능 스트림은 데이터를 쓸 수 있는 목적지에 대한 추상화입니다. fs.createWriteStream 메소드가 해당 스트림의 예입니다.
- Duplex 스트림은 읽기/쓰기 모두 가능합니다. TCP 소켓이 그 예입니다.
- Transform 스트림은 기본적으로 듀플렉스 스트림이며 데이터를 읽거나 쓸 때 수정하거나 변환시키는데 사용됩니다. zlib.createGzip 스트림이 그 예로 데이터를 gzip을 사용해서 압축합니다. 쓰기 스트림의 인풋으로 원하는 함수를 넘겨줘서 스트림을 변환해서 출력시킬 수 있습니다. 이러한 transform 스트림은 through streams 이라고 불리기도 합니다.
모든 스트림들은 EventEmitter의 인스턴스들입니다. 이벤트를 뱉어내고 데이터를 읽고 쓰는데 사용할 수 있습니다. 그러나 우리는 pipe 메소드를 사용해서 더욱 쉽게 데이터를 소비할 수 있습니다.
The pipe method
다음은 여러분이 꼭 기억해야할 마법같은 한 줄입니다.
이 간단한 한줄은 우리가 읽기 스트림의 아웃풋을 연결했습니다. 데이터의 소스를 쓰기 스트림의 인풋으로 연결한 것이죠. 데이터의 소스는 읽기 스트림이어야 하고 목적지는 쓰기 스트림이어야 합니다. 당연히 이렇게하면 듀플렉스/트랜스폼 스트림도 될 수 있습니다. 사실 우리가 듀플렉스 스트림으로 연결하면 우리가 리눅스에서 하는 것처럼 파이프 호출을 연결할 수 있습니다.
pipe 메소드는 우리가 위에서 연결한 목적지 스트림을 리턴합니다. 스트림 a(readable), b,c(duplex), d(writable)이 있다면 다음과 같이 할 수 있습니다.
파이프 메소드는 스트림을 사용하는 가장 쉬운 방법입니다. 일반적으로 파이프 메소드를 사용하거나 이벤트로 스트림을 소비하도록 추천하지만 두 개를 섞어 쓰는 것은 피하도록 하세요. 일반적으로 파이프 메소드를 쓰면 이벤트를 사용할 필요가 없습니다. 그러나 스트림을 다양한 방식으로 소비할 필요가 있다면 이벤트가 훨씬 좋은 선택일 것입니다.
Stream events
읽기 가능한 스트림 소스에서 데이터를 읽어오고 쓰기 가능한 스트림 목적지에 데이터를 쓰는 것 말고도 pipe 메소드는 자동적으로 관리해주는 것들이 있습니다. 예를 들어 에러를 처리하고, 파일의 끝과 스트림이 다른 것보다 느리거나 빠를 때 등을 처리해줍니다.
그리고 스트림은 이벤트를 사용해서 직접적으로 소비될 수 있습니다. 다음은 pipe 메소드가 데이터를 읽고 쓸 때 주로 하는 작업을 이벤트로 단순하게 구현한 것립니다.
다음의 리스트는 읽기/쓰기 스트림과 함께 사용할 수 있는 중요한 이벤트와 함수들입니다.
Screenshot captured from my Pluralsight course - Advanced Node.js
이벤트와 함수들은 대개 같이 사용되기 때문에 연관되어 보입니다. 읽기 스트림에서 가장 중요한 이벤트는 다음과 같습니다.
- data 이벤트, 스트림이 데이터 덩어리를 소비자에게 넘겨줄 때 마다 이벤트를 뱉어냅니다
- end 이벤트, 스트림에 더 이상 소비할 데이터가 없을 때 이벤트를 뱉어냅니다.
쓰기 스트림에서 가장 중요한 이벤트는 다음과 같습니다.
- data 이벤트, 쓰기 스트림이 데이터를 더 받을 수 있을 때 신호를 보냅니다.
- finish 이벤트, 시스템에 모든 데이터들이 비워졌을 때 이벤트를 뱉어냅니다.
이벤트들과 함수들은 조합을 통해 스트림을 커스텀하고 최적화 할 수 있습니다. 읽기 스트림을 소비하기 위해 pipe/unpipe 메소드를 사용할 수 있고 read / unshift / resume 메소드를 사용할 수 있습니다. 쓰기 스트림을 소비하기 위해서는 목적지에 pipe / unpipe 하거나 write 메소드를 사용해 쓰기를 하다가 작업이 끝났을 때 end 메소드를 호출하면 됩니다.
Paused and Flowing Modes of Readable Streams
읽기 스트림은 우리가 스트림을 소비하는 데 영향을 주는 두개의 메인 모드가 있습니다. paused / flowing 모드입니다. 이런 메소드들은 pull / push 모드라고도 불립니다. 모든 쓰기 스트림은 paused 모드가 기본으로 되어있으나 쉽게 flowing으로 바뀌고 다시 되돌릴 수 있습니다. 때로는 자동적으로 바뀌기도 합니다.
쓰기 스트림이 paused 모드에 있을 때 우리는 read()메소드를 사용해서 우리가 원할 때 스트림을 읽어올 수 있습니다. 반면에 flowing 모드라면 데이터가 끊임없이 들어오고 우리는 해당 이벤트를 대기하며 소비해야 합니다. flowing 모드에서 사용가능한 소비자가 처리하지 않는다면 데이터가 실제로 유실될 수 있습니다. 이 때문에 flowing 모드에서 읽기 스트림에 data 이벤트 핸들러가 필요한 이유입니다.
사실 data 이벤트 핸들러를 추가하면 paused 스트림이 flowing 모드로 변경되고 data 이벤트 핸들러를 삭제하면 pasued 모드로 바뀝니다. 이런 것 중 몇몇은 예전 노드 스트림 인터페이스와의 호환성을 위해 수행됩니다. 수동으로 두 스트림 모드를 바꾸기 위해서 resume()과 pause() 메소드를 사용할 수 있습니다.
Screenshot captured from my Pluralsight course — Advanced Node.js
pipe 메소드를 사용해서 읽기 스트림을 소비할 때 우리는 모드에 대해서 걱정할 필요가 없습니다. pipe 가 알아서 관리해주기 때문입니다.
Implementing Streams
노드에서 스트림에 대해 애기할 때 두 가지의 주요 작업이 있습니다. 하나는 스트림을 구현하는 것이고 다른 것은 소비하는 것입니다. 그러니까 지금까지 얘기한 것은 스트림을 소비한 것에 대해서만 한 것이죠. 스트림을 구현해 봅시다! 스트림은 대개 stream 모듈을 require 해서 구현됩니다.
Implementing a Writable Stream
쓰기 스트림을 구현하기 위해서 우리는 스트림모듈의 Writable 생성자를 사용할 겁니다. 쓰기 스트림을 다양한 형태로 구현할 수 있습니다. 여기서는 Writable 생성자를 커스텀 할 것입니다.
그러나 저는 간단한 생성자 접근법을 선호합니다. Writable 생성자로부터 객체를 만들고 거기에 여러개의 옵션을 전달할 것입니다. 필수 옵션은 write 함수로서 데이터 덩어리가 쓰여질 곳입니다.
write가 입력받는 세개의 매개변수 중 callback은 데이터 덩어리를 처리하고 난 뒤에 호출됩니다. 쓰기가 성공적이었는지 아닌지 알려줍니다. 실패했다면 에러 객체와 함께 콜백을 호출합니다. outStream에서는 간단히 console.log를 통해 chunk를 찍어내고 에러 없이 성공적이었다는 것을 callback을 호출하며 알려줍니다. 이것은 매우 간단하며 에코 스트림처럼 사용할 수 있습니다.
이 스트림을 간단히 소비하기 위해서 읽기 스트림은 process.stdin을 우리의 output 스트림의 안으로 연결하였습니다. 우리가 위의 코드를 실행시키면 process.stdin으로 들어오는 모든 타이핑이 outStream 의 console.log 라인으로 되돌아갑니다. 이것은 구현하기에 그렇게 유용한 것은 아닌데 왜냐하면 이미 내장된 것이 있기 때문입니다. 이것은 process.stdout과 비슷하며 stdin을 stdout에 연결하면 똑같은 에코 피처를 볼 수 있습니다. process.stdin.pipe(process.stdout)
Implement a Readable Stream
읽기 스트림을 구현하기 위해 Readable 인터페이스가 필요하고 거기서 객체를 만들고 핻아 객체의 설정 매개변수에서 read()메소드를 구현해야 합니다.
읽기 스트림을 구현하는 간단한 방법이 있습니다. 우리가 소비하고 싶어하는 소비자에게 데이터를 직접적으로 push 하는 것입니다.
null 객체를 푸시하면 이 말은 해당 스트림에 더이상 데이터가 없다는 것을 의미합니다. 이러한 간단한 쓰기 스트림을 소비하기 위해 우리는 process.stdout 안으로 연결할 수 있습니다. 우리가 코드를 실행하면 inStream의 모든 데이터를 읽어들여서 standard out으로 되돌려 줄 것입니다. 매우 간단하지만 효율적이진 않죠.
우리는 기본적으로 process.stdout에 연결하기 전에 모든 데이터를 스트림에 푸시했습니다. 더욱 나은 방식은 필요할 때마다 데이터를 푸시하는 것입니다. 이렇게 구현하기 위해서는 객체 설정에 read() 메소드를 구현하면 됩니다.
일기 스트림에서 read 메소드가 호출되면 구현부가 큐에 데이터 일부를 밀어넣을 수 있습니다. 예를 들어 한번에 하나의 단어를 밀어 넣을 수 있으며 65 아스키 코드로 시작합니다. 그리고 매번 푸시할 때마다 값을 증가시킵니다.
소비자가 읽기 스트림을 읽는 동안 read 메소드는 계속해서 발생하고 우리는 더 많은 단어를 push할 것입니다. 우리는 어디선가 사이클을 멈춰야 하고 그 때문에 현재문자값이 90이 넘으면 널을 푸시하는 이유입니다. 이 코드는 처음에 간단하게 시작했지만 이제는 소비자가 요청할 때마다 요구에 맞춰 데이터를 푸싱해 줍니다. 여러분은 항상 이것을 하게 될 것입니다.
Implementing Duplex/Transform Streams
듀렉스 스트림에서 우리는 읽기/쓰기 스트림을 같은 객체로 구현할 수 있습니다. 마치 양쪽 인터페이스를 상속받은 것과 비슷하죠. 다음은 듀플렉스 스트림의 예제로 위의 읽기/쓰기 스트림의 구현내용을 조합한 것입니다.
메소드 조합을 통해 우리는 듀플렉스 스트림으로 A-Z까지 단어를 읽어들이고 에코 기능으로 사용할 수 있습니다. 읽기 가능한 stdin 스트림을 이 듀플렉스 안으로 연결하여 에코 피처로 사용하고 듀플렉스 스트림 자체를 쓰기 가능한 stdout 스트림안으로 연결하여 A-Z까지 단어들을 볼 수 있습니다.
듀플렉스 스트림의 읽기/쓰기 영역이 완벽하게 독립적으로 다른 것과 동작한다는 것을 이해하는 것이 중요합니다. 이것은 두개의 피처를 그냥 하나의 객체로 그룹핑 한 것입니다. 트랜스폼 스트림은 듀플렉스 스트림보다 더욱 재밌는데 왜냐면 출력이 입력을 계산해서 나오는 것이기 때문입니다.
트랜스폼 스트림에서 우리는 read/write 메소드를 구현할 필요가 없습니다. 우리는 그냥 트랜스폼 메소드를 구현하면 되는데 해당 메소드가 read/write의 조합입니다. write 메소드의 시그니쳐가 있고 데이터를 푸시하는 데 사용할 수 있습니다. 다음은 간단한 트랜스폼 스트림의 예제로 입력된 것을 대문자 형태로 변환하여 출력합니다.
트랜스폼 스트림에서 이전 듀플렉스 스트림에 나왔던 예제와 똑같은 것을 소비하고 있습니다. 단지 그것을 transform()메소드에 구현했을 뿐이죠. 해당 메소드에서 우리는 chunk를 대문자 형태로 변환하고 readable 영역에 푸시합니다.
Streams Object Mode
기본적으로 스트림은 버퍼/문자열 값을 기대합니다. objectMode 플래그를 설정해서 우리는 스트림이 자바스크립트의 어떤 객체라도 수락하게 할 수 있습니다. 다음은 시연을 하는 간단한 예제입니다. 다음의 트랜스폼 스트림 조합은 쉼표로 구분된 문자열을 자바스크립트 객체로 매핑합니다. 그래서 “a, b, c, d”는 {a: b, c: d}가 됩니다.
입력된 문자열을 commaSplitter에게 [“a”, “b”, “c”, “d”] 배열 형태로 푸시합니다. 해당 스트림에 readableObjectMode 플래그를 추가하는 것은 꼭 필요한데 우리가 문자열을 푸시하는 것이 아니라 객체를 푸시하기 때문입니다.
그런다음 arrayToObject 스트림으로 연결합니다. writableObjectMode 플래그를 설정해야 해당 스트림이 객체를 받을 수 있도록 할 수 있습니다. 객체를 푸시할 것이기 때문에 거기에 readableObjectMode 플래그도 있는 이유입니다. 마지막 objectToString 스트림은 객체를 받아서 문자열로 내보내기 때문에 writableObjectMode 플래그만 필요로 합니다. 읽기 부분은 평범하게 문자열 입니다.
Usage of the example above
Node’s built-in transform streams
노드는 유용하게 쓸 수 있는 내장 트랜스폼 스트림이 있습니다. zlib과 crypto 스트림입니다. 다음의 예제는 zlib.createGzip()스트림과 fs readable/writable 스트림을 조합하여 파일 압축 스크립트를 만듭니다.
해당 스크립트는 매개변수로 넘겨준 어떤 파일이라도 gzip을 할 수 있습니다. 파일을 위한 읽기 스트림을 zlib 내장 트랜스폼 스트림에 연결하고 쓰기 스트림을 새로운 gzipped 파일에 연결합니다. 간단하죠.
파이프를 사용하는 것의 멋진 점은 우리가 실제적으로 필요한 이벤트만 조합해서 사용할 수 있다는 것입니다. 예를들어 제가 스크립트가 동작중임을 나타내는 프로그래스를 사용자에게 보여주고 싶고 스크립트가 끝났을 때 Done 메시지를 보여주려고 합니다. pipe 메소드가 목적지 스트림을 리턴하기 때문에 이벤트 핸들러 등록을 다음과 같이 연결할 수 있습니다.
그래서 pipe메소드를 사용하면 쉽게 스트림을 소비할 수 있고 필요하다면 이벤트를 함께 사용해서 인터렉션을 커스텀할 수 있습니다. pipe 메소드에서 정말 멋진 것은 우리의 프로그램 조각조각을 조합할 수 있다는 것으로 가독성을 더욱 향상 시켜줍니다. 게다가 위에서 data 이벤트를 대기하는 것 대신에 경과를 보고하는 트랜스폼 스트림을 만들어서 on() 호출하는 대신에 또다른 .pipe()호출로 대체할 수 잇습니다.
reportProgress 스트림은 간단히 지나가는 스트림이지만 표준출력으로 진척도를 보고합니다. 제가 콜백함수에서 두번째 매개변수로 데이터를 푸시하는 것을 주목해주세요. 처음에 푸시했던 데이터와 동일합니다. 스트림의 조합의 응용은 무한합니다. 예를들어 압축하기 전, 후에 암호화가 필요하다면 또 다른 트랜스폼 스트림을 우리가 필요한 순서에 맞게 연결하면 됩니다. 노드의 crypto 모듈을 통해 그것을 구현할 수 있습니다.
위의 스크립트는 암축을 하고 넘겨진 파일을 암호화 하고 오직 시크릿을 가진 사람이 결과 파일을 사용할 수 있습니다. 암호화 되어 있기 때문에 보통 unzip 프로그램을 사용해서 압축해제를 할 수 없습니다. 위의 스크립트의 결과물을 압축해제 하기 위해서는 암호화 및 압축에 쓰인 스트림을 반대의 순서대로 사용하면 됩니다.
전달된 파일이 압축된 버전이라고 가정했을 때 위의 코드는 해당 파일에 대한 읽기 스트림을 만들고 crypto 스트림 안으로 연결합니다(동일한 암호 사용), 출력을 zlib의 스트림으로 연결하고 확장자 부분을 지우고 쓰기를 수행합니다.
Summary
- Node.js 스트림에 관한 정리
- 스트림은 쓰기 작업에 있어서 메모리에 모든 내용을 올려놓고 작업하는 것이 아니기 때문에 큰 파일 처리에 적합하다.
- 스트림의 강력한 기능은 파이프에 있다. 입력과(readable)/출력(writable), duplex, trasform 스트림을 잘 연결해서 작업을 처리하면 훨씬 가독성이 좋은 코드를 만들 수 있다.
- 노드 네이티브에서 이를 위해 지원해주는 스트림 메소드 들이 있다. 해당 메소드들은 파이프로 연결할 수 있다.