본문 바로가기

Back-end/Node.js

스트림 코딩 (Stream Coding) - (1) 버퍼(buffer)와 스트림(stream)

* 이 글은 Mario Casciaro, Luciano Mammino가 저서한 <Node.js 디자인 패턴 바이블> 서적을 참고한 게시글입니다.

 

서적 정보 : http://www.kyobobook.co.kr/product/detailViewKor.laf?mallGb=KOR&ejkGb=KOR&barcode=9788931464283

 

Node.js 디자인 패턴 바이블 - 교보문고

검증된 패턴과 기술을 이용한 수준 높은 Node.js | 이 책은 이미 Node.js를 처음 접한 후 이제 생산성, 디자인 품질 및 확장성 측면에서 최대한 활용하고자 하는 개발자를 대상으로 합니다. 이 책은

www.kyobobook.co.kr

 


 

이번엔 스트림 & 파이프 패턴에 대해 여러 글에 나누어 정리해보고자 한다.

Node.js에서 굉장히 중요해 보이지만, 은근 다루기 어려운 내용인 것 같아 이 기회에 확실히 알아보자. 

 

 

 

0.  버퍼 (buffer)

 

버퍼(buffer)는 데이터 전송 패러다임의 한 종류이다. 데이터가 들어오면(혹은 입력되면) 별도로 할당해 둔 임시 메모리 공간(버퍼)에 저장하다가, 더이상 데이터가 들어오지 않는 경우 해당 데이터 blob을 전송한다.

 

가볍게 그림으로 보자면 다음과 같다.

 

버퍼링의 데이터 전송 메커니즘

 

여기서 핵심은 입력이 종료될 때까지는 바로 전송하지 않고 메모리(버퍼)에 묶어둔다는 점이다.

 

요즘 대부분의 라이브 플레이어들은 버퍼링이 잘 없다. (물론 스트리밍으로 바꿔서지만) 우리가 지독하게도 싫어했던 '버퍼링'을 제공하는 쪽에서도 싫어했나보다..

 

보기만해도 열이 받는다.

 

그럼 스트리밍은 어떤 데이터 전송 메커니즘을 가지고 있을까.

 

 

 

 

1.  스트림 (stream)

 

스트림은 버퍼와는 반대로 리소스(메모리)에 데이터가 도착하자마자 바로 전송(처리)할 수 있다.

 

그림을 살펴보자면,

 

특정 chunk마다 바로바로 데이터를 처리한다.

 

일정한 크기의 chunk에 데이터가 차면 바로바로 처리해준다.

 

스트림은 버퍼에 비해 공간효율성, 시간효율성 측면에서 모두 이점을 가지는데, 하나씩 살펴보자.

 

 

1. 공간효율성

    파일을 버퍼링으로 읽어 압축하는 경우, 파일의 크기가 굉장히 크다면(그리고 그 크기가 버퍼의 사이즈를 넘는다면) 애초에 압축이              되지 않고 오류가 날 것이다. 반대로, 스트림에서는 데이터 chunk가 올 때마다 바로바로 파일에 쓰기 때문에 그럴 걱정이 없다.

 

    스트림으로 간단하게 구현해보자면 다음과 같다.

 

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

const file = process.argv[2];

// 파일을 스트림으로 읽는 객체 생성
fs.createReadStream(file)
    // 파이프로 stream 간에 read와 write event들을 연결해준다.
    // 여러 개의 파이프를 연결하는 것도 가능
    .pipe(zlib.createGzip())
    .pipe(fs.createWriteStream(file + ".gz"))
    .on("finish", () => {
        console.log("File successfully compressed");
    });

 

   상세한 내용들은 다음 포스트들에서 다룰 예정이니 대충 느낌만 보자면,

 

   fs.createReadStream(file)으로 파일을 스트림으로 읽는 객체를 생성하고, 파일을 압축하는 zlib.createGzip()으로 연결(pipe)했다.

   그리고 파일을 스트림으로 쓰는 객체 fs.createWriteStream()을 연결했다. 

 

   파일 읽기 -> 압축 형태의 데이터로 치환 -> 파일 쓰기의 과정을 모두 연결하여 코드가 굉장히 간결하다.

 

 

2. 시간효율성

    이번엔 파일을 압축하고 HTTP 서버에 업로드한 다음, 서버에서는 받은파일을 압축해제하고 파일 시스템에 저장하는 어플리케이션을 생      각해보자.

    버퍼링의 경우 읽기(클라이언트)->압축(클라이언트)->전송(클라이언트)=수신(서버)->압축해제(서버)->쓰기(서버)의 과정을 통해 파      일 전체가 이동한다.

    반대로 스트림의 경우는 해당 과정이 데이터 chunk마다 일어날 수 있다는 점이다. 

    '결국 걸리는 모든 시간을 다 합하면 똑같지 않느냐'는 의문이 들 수 있지만, 아래 그림을 보자. 

 

 

윗부분은 버퍼링을, 아랫부분은 스트리밍을 보여주고 있다.

 

우리가 스트리밍을 통해 chunk 데이터를 압축하고, 보내는 동안에 서버에서는 압축 해제, 수신 등 다른 동작을 할 수 있기에 전체적으로 시간이 크게 단축됨을 확인할 수 있다.

 

CPU의 파이프라이닝과 비슷하게 시간효율을 챙기는 메커니즘으로 보인다.

 

 

 

3. 조립성

    앞의 코드에서 확인해보았지만, 스트림은 pipe() 함수로 서로 다른 스트림끼리 연결할 수 있기에 조립성이 우수하다.

    만약 앞의 코드에서 파일을 쓰기 전에 데이터를 암호화하고 싶다면 다음과 같은 연결만 추가해주면 된다.

 

const fs = require("fs");
const zlib = require("zlib");
const { createCipheriv, randomeBytes } = require("crypto");

const file = process.argv[2];

fs.createReadStream(file)
    .pipe(zlib.createGzip())
    // 암호화를 스트림으로 연결
    .pipe(createCipheriv("aes192", "my_secret", randomBytes(16)))
    .pipe(fs.createWriteStream(file + ".gz"))
    .on("finish", () => {
        console.log("File successfully compressed");
    });

 

복호화할 때도 pipe(createDecipheriv(...))로 연결만 해주면 간단히 가능하다.

 

이제 스트림의 개요(?) 정도는 알았으니 다음부터는 스트림의 추상 클래스 Readable, Writable 등등에 대해 알아보겠다.