우리는 한다, 개발을.

Node.js의 stream

⌛ 9 mins

Node.js의 stream

이번에 내가 책임을 다하게 된 프로젝트에서 대량의 데이터를 CSV파일로 (후에 xlsx 파일로 바뀌었다.) 다운로드 하는 기능이 있었다. Nest.js 로 파일 다운로드 기능 구현을 직접 하는 건 처음이라 이것저것 블로그 글을 찾아보며 다운로드 되게는 만들었지만 부장님께서 file download 할 때에는 stream을 필수로 사용해야 하고 특히 용량이 큰 파일은 더욱 사용해야 한다고 하셨다.

stream..

옛날 JAVA 공부 할 때 stream을 썼던 기억이,,,

그러고보니 nest.js file upload, download 공부 할 때에도 stream이라는 녀석을 본 기억이,,,

한 번,,, 찾아봐볼까,,,

Stream이란 무엇인가?

뭔가 스트림을 배웠음에도 불구하고 스트림이 뭐냐는 말에는 확실하게 대답을 못하는 거 같다… 스트림은 항상 파일 관련 작업에서 썼던 기억이 나는데 게임 스트리밍, 실시간 스트리밍 이런 단어로 많이 접했던 거 같아서 실시간으로.. 무언가를 하는.. 그런건가..? 싶었다.

스트림이란 배열이나 문자열 같은 데이터 컬렉션이며 순차적인 데이터, 흘러가는 데이터이다.

이게 뭐냐면 크기가 중요하지 않은 데이터인 것이다. 왜 데이터의 크기가 중요하지 않은거지..?

  • 왜냐면 메모리를 아끼기 위해서!

클라이언트가 큰 파일을 요청했을 때 모든것을 메모리에 버퍼로 잡지 않고 이것을 한번에 한 청크 씩 스트림으로 흘려보낸다.

예를 들면 100GB의 큰 데이터를 압축하는 프로그램은 처음부터 끝까지 모든 데이터를 전부 읽지 않고 앞에서부터 차례로 요청을 처리한다.

또다른 예시로 블로그 웹서버는 클라이언트로부터 요청이 들어오면 게시물을 클라이언트에게 보내는데 이 ‘요청’ 이라는 데이터는 블로그에 계속해서 유입되는 클라이언트가 있기에 그 총량이 정해지지 않는다. 데이터의 총량과 상관 없이 요청 하나에 대하여 제대로 처리하는 것이 중요하다.

이처럼 스트림은 데이터가 끝이 날수도 있고 안 날수도 있는 것이다. 예시 중 파일을 압축하는 사례의 경우 파일의 끝이 명백히 정해져 있지만 웹서버의 요청이란것은 블로그를 폐쇄하기 전 까지 계속 받아야 하며 외부로부터 요청을 받아들일 수 있는 상태가 중요하다.

스트림에는 일반적으로 읽기 전용 스트림쓰기 전용 스트림이 있다. Node.js 에서는 읽기 전용을 Readable, 쓰기 전용을 Writable 클래스를 활용하여 구현하고 있다.

스트림을 사용하는 개발자 입장에서는 읽을 수 있는(Readable) 스트림이란 데이터가 스트림 내부에서 외부로 빠져나올 수 있다는 뜻이고, 쓸 수 있는(Writable) 스트림이란 갖고 있던 데이터를 스트림 내부로 전달시킨다는 뜻이다.


Stream 종류

  • Readable: 읽을 수 있는 스트림(ex. fs.createReadStream())
  • Writable: 쓸 수 있는 스트림(ex. fs.createWriteStream())
  • Duplex: 읽고 쓸 수 있는 스트림(ex. net.Socket)
  • Transform: 데이터를 쓰고 읽을 때 데이터를 수정하거나 변형할 수 있는 Duplex 스트림

스트림이벤트

좀 더 커스텀한 방법으로 스트림을 사용하려면 이벤트를 사용해야 한다. Readable 는 on 을 이용해 이벤트 핸들러를 등록하여 데이터를 처리한다.

readable.on("data", chunk => {
  writable.write(chunk)
})

readable.on("end", () => {
  writable.end()
})

주요 이벤트 그리고 스트림과 함께 사용할 수 있는 함수 목록은 다음과 같다.

  • readable - 스트림에서 데이터 청크를 읽을 수 있을 때 생성
  • data - 데이터 이벤트 핸들러가 추가된 것을 제외하면 readable 유사. 스트림이 소비자에게 데이터 청크를 전송 할 때 발생.
  • end - 스트림에서 데이터가 더 이상 제공되지 않을 때 생성.
  • close - 파일 같은 기본 리소스가 닫힐 때 생성.
  • error - 데이터를 수신 시 오류가 발생한 경우 생성

  • drain - writeable stream이 더 많은 데이터를 수신할 수 있다는 신호. writable stream이 추가로 데이터 쓰기 작업이 가능할 때 발생.
  • finish - 모든 데이터가 비워지고 더 이상 쓸 데이터가 없을 경우 생성된다.
  • pipe - Readable stream에 Writable 목적지가 추가되면 pipe() 함수 호출 후 생성된다. (readable stream이 writeable stream을 목적지로)
  • unpipe - Readable stream에서 Writable 목적지제거를 위해 unpipe() 호출 후 생성된다.

이벤트와 함수는 커스터마이징된 스트림을 사용하기 위해 함께 사용할 수 있다.

pipe 메소드

readableSrc.pipe(writeableDest)

readable stream의 출력과 writeable stream의 입력을 파이프로 연결하여 소스는 readable stream이 되고 목적지는 writeable stream이 된다. 그리고 이 둘은 듀플랙스, 트랜스폼 스트림이 될 수도 있다.

pipe메소드는 이외에도 에러처리, 파일 끝부분 처리, 어떤 스트림이 다른 것들에 비해 느리거나 빠른 경우를 처리한다.

보통 pipe메소드를 사용하거나 이벤트와 함께 스트림을 사용하는 것을 추천하지만 두 개를 같이 쓰는 것은 피해야 한다. pipe함수에는 .on(‘data’) .on(‘end’)를 할 필요없이 읽을 데이터를 쓰는 작업을 단순하게 처리할 수 있다.

pipe만 쓰지 않고 스트림 이벤트를 사용하는 이유는 뭘까?

읽고 쓸때의 상황을 체크하거나 그 때 처리해야 하는 상황이 있을 수 있기 때문이다.

이미 사용되고 있는 스트림들

process.stdout

console.log가 기본적으로 사용하는 스트림. Writable 스트림이기 때문에 write메소드가 있다.

process.stdout.write('Hello, world!\\n'); //Hello, world!
process.stdout.write('Hello, world! 2\\n'); //Hello, world! 2

스트림은 데이터가 전달되어도 그게 끝이 아닌 순차적인 데이터이기 때문에 계속해서 write메소드를 호출 할 수 있다.

createWriteStream

const fs = require("fs");
const stream = fs.createWriteStream('output.txt');
stream.write('hey');
stream.write('stack');

위의 코드를 실행시키면 output.txt파일이 생성된다. createWriteStream 함수는 파일을 작성할 수 있는 Writeable stream을 반환한다. 마찬가지로 write 메소드를 여러번 사용할 수 있다.

무한정 데이터를 주고 싶지 않을 때, 끝을 내고 싶을 때 end메소드를 사용할 수 있다.

process.stdout.write('Hello, world!\\n'); //Hello, world!
process.stdout.end(); // 스트림 끝
process.stdout.write('hi'); // 출력 안됨

위의 코드를 실행시키면 Hello, world!까지는 제대로 출력되지만 그 후 end 호출을 통해 스트림이 끝나기 때문에 wirte를 수행할 수 없다.

createReadStream

파일을 읽을 때 사용한다.

const { createReadStream } = require("fs");

const stream = createReadStream("./input.txt");
stream.on("data", (chunk) => {
  console.log(`chunk: ${chunk}`);
});

on 메소드를 사용하여 이벤트 핸들러를 등록시킨다. 이 이벤트의 이름은 ‘data’이고 chunk를 인수로 받아 출력 한다.

모든 스트림은 EventEmitter의 인스턴스이며 데이터를 일거나 쓸 때 사용할 이벤트를 방출한다. 하지만 pipe메소드를 이용해 더 간단하게 스트림 데이터를 사용할 수 있다.


Stream을 이용한 .xlsx 파일 다운로드

이제 스트림에 대해서는 어떤 것인지 이해를 했으니 본격적으로 스트림을 이용해 .xlsx 파일 다운로드를 구현해보려고 한다.

전체적인 흐름은

  1. 차트 데이터를 가공하여 .xlsx 파일의 데이터를 생성한다.
  2. readable stream 으로 해당 데이터로 .xlsx 파일을 생성한다.
  3. 프론트엔드에 해당 파일의 링크를 생성하여 클릭이벤트로 파일을 다운받는다.

프론트엔드에서는 파일 다운로드 url을 axios 방식으로 호출하는데 이 때 responseType을 blob 으로 설정해주어야 한다.

Blob객체는 파일류의 불변하는 미가공 데이터를 나타내며 이미지, 사운드, 비디오와 같은 멀티미디어 데이터를 다룰 때 사용한다. 텍스트와 이진 데이터 형식으로 읽을 수 있고, ReadableStream으로 변환해서 스트림 메서드를 사용할 수도 있다. 데이터의 크기, 및 MIME 타입을 알아내거나, 데이터를 송수신을 위한 작은 Blob 객체로 나누는 작업 등에서 사용한다. File이 Blob에 기반한 인터페이스로, Blob 인터페이스를 상속해 확장한 것이다.

//DashBoardView.vue

**await this.axios({
          url: '/chart/data-list',
          method: 'GET',
          params: sendData,
          responseType: 'blob',**
        }).then((response)=>{
          console.log(response);
          const href = URL.createObjectURL(response.data);
          const link = document.createElement('a');
          link.href = href;
          link.setAttribute('download', 'file.csv')
          document.body.appendChild(link);
          link.click();
          document.body.removeChild(link);
          URL.revokeObjectURL(href);
        });

서버에서 파일을 다운로드 하려면 헤더에 Content-Disposition 필드가 필요하다. Content-Dispotion 헤더의 타입에는 inline, attachment 2가지 종류가 있는데 inline은 컨텐츠를 display 하는 것이고 attachment는 다운로드를 강제하는 값이다. Content-Dispotion 헤더의 파라미터는 추가적인 옵셔널 값이다. 파일 이름에 관한 정보나, 파일의 생성된 날짜 등의 추가 정보를 포함시킬 수 있다. • 응답 헤더에는 Content-Dispotion: attachment; filename=image.gif 이런 식으로 표기된다. 앞에 타입을 명시하고 그 다음에 추가 파라미터를 적는다.

application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 는 .xlsx파일의 mime-type이다.

//chart.controller.ts

@Get('/xlsx-down')
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
async getXLSXDownload(@Query() body: ListChartDto){
  return this.chartService.getGuideList(body);
}
//chart.service.ts

...

const file = fs.createReadStream(fileName);
file.on('end', () => {
  if (fs.existsSync(fileName)) fs.unlinkSync(fileName);
});
return new StreamableFile(file);

위에서 fileName경로 xlsx 데이터를 생성하였고 이것을 createReadStream을 사용하여 파일을 읽어온다. 파일을 다 읽어오면 end이벤트가 발생하여 파일이 존재하는지 여부를 확인 후 파일이 있으면 파일을 삭제한다.

리턴 할 때에는 pipe 대신 NestJS에 내장된 StreamableFile 클래스를 이용하여 스트림을 파이핑 한다.

처음에는 file.pipe(res);의 방식으로 리턴을 하였으나 이 방법은 포스트 컨트롤러 인터셉터 논리에 대한 액세스 권한을 잃게된다고 한다. 따라서 이를 처리하기 위해 StreamableFile 클래스를 사용하며 이것은 내부적으로 프레임워크가 응답 파이핑을 처리한다.

//DashBoardView.vue

await this.axios({
          url: '/chart/data-list',
          method: 'GET',
          params: sendData,
          responseType: 'blob',
        }).then((response)=>{
          **const href = URL.createObjectURL(response.data);
          const link = document.createElement('a');
          link.href = href;
          link.setAttribute('download', 'file.csv')
          document.body.appendChild(link);
          link.click();
          document.body.removeChild(link);
          URL.revokeObjectURL(href);**
        });

리턴 받은 파일을 다운받는 태그 생성하여 클릭이벤트를 실행시키고 태그를 삭제한다.


정리

스트림은 이보다 더 방대한 내용이 있지만 우선은 내가 사용했던 파일 다운로드 기능 위주로 내용을 압축해보았다.

  • 스트림이란? 순차적인 데이터이다.
  • 스트림을 쓰는 이유는? 데이터를 순차적으로 처리할 수 있기 때문에 끝이 없거나 아주 거대한 데이터를 다루기 좋다.
  • 스트림에는 크게 읽기 전용 스트림(Readable), 쓰기 전용 스트림(Writable)이 있다.
  • 파일에 내용을 쓰는 스트림을 만드려면? createWriteStream 함수를 이용한다.
  • 스트림을 이용해 파일을 읽으려면? createReadStream 함수를 이용한다.
  • Readable stream은 on 을 이용해 이벤트 핸들러를 등록하여 데이터를 처리한다.
  • pipe를 이용해 읽고 쓰기를 간단하게 처리할 수 있다.

스트림을 이렇게 제대로 찾아보고 공부해본 적이 없다. 그냥 예전의 쓰임새에 맞춰서 썼던 기억 뿐… 이렇게 세세하게 공부해보니 앞으로 다른 상황에서 활용할 수 있을 것 같다.

참고

https://nodejs.org/api/stream.html

https://elvanov.com/2670

https://jeonghwan-kim.github.io/node/2017/07/03/node-stream-you-need-to-know.html

https://docs.nestjs.com/techniques/streaming-files