본문 바로가기
C.W.K.
Stream
Lesson 03 of 05 · published

Duplex와 Transform — 두 방향을 잇는 스트림

~12 min · streams, duplex, transform

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Duplex는 읽기와 쓰기를 한 객체에 담고, Transform은 그 두 방향을 변환 규칙으로 이어. 모양은 비슷하지만 데이터가 흐르는 까닭은 달라."

Duplex에는 독립된 입구와 출구가 있어

Duplex 스트림은 읽기 쪽과 쓰기 쪽을 함께 제공해. 하지만 쓴 데이터가 반드시 읽기 쪽으로 돌아오는 건 아냐. 두 방향이 같은 자원을 공유할 뿐 서로 독립적으로 움직일 수 있어.

네트워크 소켓이 대표적인 예야. 쓰기 쪽으로 요청 바이트를 보내고 읽기 쪽에서 응답 바이트를 받아. 둘은 같은 TCP 연결 위에 있지만, 나가는 흐름과 들어오는 흐름은 별개지. 자식 프로세스와 주고받는 표준 입출력도 같은 관점으로 볼 수 있어.

import { createConnection } from 'node:net';

const socket = createConnection({ host: 'example.com', port: 80 });
socket.write('GET / HTTP/1.1\r\nHost: example.com\r\n\r\n');

for await (const chunk of socket) {
  process.stdout.write(chunk);
}

Transform은 입력과 출력을 규칙으로 연결해

Transform도 읽기와 쓰기를 모두 제공하지만, 안으로 들어온 데이터가 정해진 변환을 거쳐 밖으로 나와. 압축기는 원본 바이트를 받아 압축 바이트를 내보내고, 암호화 스트림은 평문을 받아 암호문을 내보내.

  • zlib.createGzip()은 원본을 gzip 형식으로 압축해.
  • zlib.createGunzip()은 gzip 바이트를 다시 풀어.
  • crypto.createHash('sha256')는 들어온 데이터로 해시를 계산해.
  • crypto.createCipheriv(...)는 평문을 암호문으로 바꿔.

이 단계를 pipeline()으로 이으면 파일을 읽는 동안 압축하고 암호화해 바로 보낼 수 있어. 입력 전체를 중간 배열에 쌓지 않아도 각 단계가 자기 청크만 처리해.

사용자 정의 Transform은 완료 신호가 핵심이야

_transform(chunk, encoding, callback)은 받은 청크를 처리한 뒤 반드시 콜백으로 완료를 알려야 해.
import { Transform } from 'node:stream';

class UppercaseUtf8 extends Transform {
  _transform(chunk, _encoding, callback) {
    const upper = chunk.toString('utf8').toUpperCase();
    callback(null, upper);
  }
}

process.stdin
  .pipe(new UppercaseUtf8())
  .pipe(process.stdout);
callback(null, output)은 결과를 내보내면서 다음 청크를 받을 준비가 됐다고 알려. 출력이 여러 개라면 this.push()로 먼저 내보내고 마지막에 callback()을 호출할 수 있어. 콜백을 잊으면 다음 청크가 오지 않아 스트림이 멈춰.

객체 모드로 구조화된 값을 흘려보내

객체 모드의 Transform은 청크를 바이트가 아니라 JavaScript 값으로 다뤄. 줄 분리기는 바이트를 문자열 한 줄씩 바꾸고, CSV 파서는 문자열을 행 객체로 바꾸며, 필터는 조건에 맞는 객체만 다음 단계로 보내.

import { Transform } from 'node:stream';

class ParseCsvRow extends Transform {
  constructor() {
    super({ objectMode: true });
    this.columns = null;
  }

  _transform(line, _encoding, callback) {
    const fields = String(line).split(',');
    if (!this.columns) {
      this.columns = fields;
      callback();
      return;
    }
    callback(null, Object.fromEntries(
      this.columns.map((column, index) => [column, fields[index]])
    ));
  }
}

한 단계가 맡는 변환을 작게 유지하면 CSV 대신 JSONL을 읽게 바꿔도 필터와 저장 단계는 그대로 둘 수 있어. 스트림은 메모리 사용을 줄이는 도구이면서 처리 책임을 분리하는 조립 도구이기도 해.

Pippa의 고백

처음 큰 파일을 다룰 때는 읽기, 변환, 쓰기를 한 함수와 여러 배열에 몰아넣었어. 아빠가 같은 일을 줄 분리, 파싱, 필터, 저장 단계로 나눠 보여 줬지. 메모리만 줄어든 게 아니었어. 어느 단계가 틀렸는지 바로 보이고, 각 단계만 따로 시험할 수 있더라. 이제 Transform을 보면 "빠른 스트림"보다 "책임 하나를 맡은 부품"부터 떠올려.

Code

압축 바이트를 관찰하며 파일로 저장하는 네 단계·javascript
// 읽기 → 압축 → 해시 관찰 → 파일 저장
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';
import { createHash } from 'node:crypto';
import { Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';

function hashObserver(algorithm) {
  const hash = createHash(algorithm);
  return new Transform({
    transform(chunk, _encoding, callback) {
      hash.update(chunk);
      callback(null, chunk);
    },
    flush(callback) {
      this.digest = hash.digest('hex');
      callback();
    },
  });
}

const observer = hashObserver('sha256');
await pipeline(
  createReadStream('big.json'),
  createGzip(),
  observer,
  createWriteStream('big.json.gz')
);
console.log('sha256:', observer.digest);
바이트를 줄 단위 문자열로 바꾸는 Transform·javascript
// 바이트 입력을 문자열 한 줄씩 내보낸다.
import { Transform } from 'node:stream';
import { createReadStream } from 'node:fs';

class LineSplitter extends Transform {
  constructor() {
    super({ readableObjectMode: true });
    this.pending = '';
  }

  _transform(chunk, _encoding, callback) {
    this.pending += chunk.toString('utf8');
    const lines = this.pending.split('\n');
    this.pending = lines.pop();
    for (const line of lines) this.push(line);
    callback();
  }

  _flush(callback) {
    if (this.pending) this.push(this.pending);
    callback();
  }
}

const lines = createReadStream('huge.log').pipe(new LineSplitter());
for await (const line of lines) {
  if (line.includes('ERROR')) console.log(line);
}

External links

Exercise

큰 CSV 파일을 읽어 행 객체로 바꾸고, amount > 100인 행만 남긴 뒤 JSONL 파일로 쓰는 처리 흐름을 만들어. 줄 분리, CSV 파싱, 필터, JSON 직렬화를 각각 독립된 Transform으로 만들고 pipeline()으로 이어. 큰 입력에서도 메모리가 입력 크기에 따라 계속 늘지 않는지 측정해.
Hint
첫 단계는 바이트를 줄 문자열로, 둘째는 문자열을 객체로 바꿔. 셋째는 객체 모드에서 조건에 맞는 값만 콜백으로 넘기고, 넷째는 JSON.stringify(value) + "\n"을 바이트로 내보내면 돼. 필터에서 버릴 청크는 출력 없이 callback()만 호출해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.