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 파일 다운로드를 구현해보려고 한다.
전체적인 흐름은
- 차트 데이터를 가공하여 .xlsx 파일의 데이터를 생성한다.
- readable stream 으로 해당 데이터로 .xlsx 파일을 생성한다.
- 프론트엔드에 해당 파일의 링크를 생성하여 클릭이벤트로 파일을 다운받는다.
프론트엔드에서는 파일 다운로드 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://jeonghwan-kim.github.io/node/2017/07/03/node-stream-you-need-to-know.html