Node.jsを利用して高性能なアプリケーションを開発する際、避けて通れないのが「Stream(ストリーム)」の理解です。

大量のデータをメモリに溜め込むことなく、断続的に処理を進めるこの仕組みは、サーバーサイドの開発において極めて重要な役割を果たします。

現代のWebアプリケーションでは、動画配信や巨大なログファイルの解析、リアルタイムなデータ通信など、ストリーム技術が不可欠なシーンが増え続けています。

本記事では、Node.jsに標準搭載されている4種類のStreamについて、それぞれの役割と現場でどのように使い分けるべきかを詳しく紹介します。

Node.jsにおけるStreamの基本概念

Streamとは、一言で言えば「データの流れ」を扱うための抽象的なインターフェースです。

通常、ファイルを読み込む際は、ファイル全体の内容を一度メモリ(Buffer)に読み込んでから処理を開始します。

しかし、数GBを超えるような大容量ファイルをこの方法で扱うと、メモリ不足(Out of Memory)を引き起こす危険性があります。

Streamを使用すれば、データを小さな断片(チャンク)に分割し、読み込みながら同時に処理を行うことが可能になります。

これにより、メモリ消費量を最小限に抑えつつ、高速なレスポンスを実現できます。

Node.jsの内部モジュールの多く、例えば http モジュールのリクエストや fs モジュールのファイル操作などは、このStreamの仕組みをベースに構築されています。

Streamを利用する最大のメリット

Streamを利用する主なメリットは、「時間効率」と「メモリ効率」の向上にあります。

時間効率の面では、データのすべての読み込みが完了するのを待たずに、届いたデータから順次処理を開始できるため、全体の実行時間を短縮できます。

メモリ効率の面では、必要な分だけのチャンクをメモリに保持するため、大規模なデータセットでも一定のメモリ使用量で安定して動作します

特にクラウド環境やコンテナ環境ではメモリリソースが限られているため、Streamを活用した設計はコスト削減にも直結します。

Streamの4つの主要な種類

Node.jsのStreamには、大きく分けて4つのカテゴリーが存在します。

開発の現場では、これらを組み合わせてデータのパイプラインを構築します。

1. Readable Stream(読み取り専用)

Readable Streamは、ソースからデータを読み取るためのストリームです。

例えば、ファイルシステムからデータを読み出す fs.createReadStream() や、HTTPリクエストの req オブジェクトがこれに該当します。

読み取りストリームには「停止モード(Paused)」と「流動モード(Flowing)」の2つの状態があります。

基本的には data イベントをリスンするか、pipe() メソッドを使用することで、自動的にデータが流れ始めます。

JavaScript
const fs = require('node:fs');

// Readable Streamの作成
const readable = fs.createReadStream('sample.txt', { encoding: 'utf8', highWaterMark: 1024 });

readable.on('data', (chunk) => {
    console.log(`データを読み込みました: ${chunk.length}文字`);
});

readable.on('end', () => {
    console.log('すべての読み込みが完了しました。');
});
実行結果
データを読み込みました: 1024文字
データを読み込みました: 512文字
すべての読み込みが完了しました。

2. Writable Stream(書き込み専用)

Writable Streamは、特定の宛先にデータを書き込むためのストリームです。

ファイルにデータを書き出す fs.createWriteStream() や、HTTPレスポンスの res オブジェクトが代表例です。

write() メソッドを使ってデータを送り込み、すべてのデータを送り終えたら end() メソッドを呼び出すのが基本的な流れです。

書き込み速度が読み込み速度に追いつかない場合に発生する「背圧(Backpressure)」を適切に処理することが重要です。

JavaScript
const fs = require('node:fs');

// Writable Streamの作成
const writable = fs.createWriteStream('output.txt');

writable.write('Node.js Streamの学習中です。\n');
writable.write('書き込みを継続します。\n');
writable.end('書き込みを終了します。');

writable.on('finish', () => {
    console.log('ファイルへの書き込みがすべて完了しました。');
});
実行結果
ファイルへの書き込みがすべて完了しました。

3. Duplex Stream(読み書き両用)

Duplex Streamは、ReadableとWritableの両方の機能を一つのオブジェクトで備えているストリームです。

ただし、入力と出力が独立しているのが特徴です。

最も身近な例は、TCPソケットを扱う net.Socket です。

クライアントからデータを送りつつ、同時にサーバーからの応答を受け取るような双方向通信の場面で使用されます。

4. Transform Stream(変換用)

Transform StreamはDuplexの一種ですが、「入力したデータを加工して出力する」という役割に特化しています。

データの圧縮(zlib)や暗号化(crypto)、またはCSVデータをJSONに変換するような処理で多用されます。

パイプラインの途中に挟み込むことで、データ変換のロジックをきれいに分離できます。

JavaScript
const { Transform } = require('node:stream');

// 文字列を大文字に変換するTransform Stream
const upperCaseTransform = new Transform({
    transform(chunk, encoding, callback) {
        this.push(chunk.toString().toUpperCase());
        callback();
    }
});

process.stdin.pipe(upperCaseTransform).pipe(process.stdout);

現場目線での使い分けと実践的なユースケース

実際の開発現場では、これらのストリームを単体で使うのではなく、組み合わせて「パイプライン」を構築することがほとんどです。

大規模なログファイルのフィルタリング

数GBあるログファイルから特定のキーワードを含む行だけを抽出する場合、ファイル全体をメモリに載せることは不可能です。

この場合、fs.createReadStream でログを読み込み、Transform Streamでフィルタリングを行い、結果を fs.createWriteStream で新しいファイルに書き出す構成をとります。

APIレスポンスのストリーミング

データベースから大量のレコードを取得し、JSONとしてクライアントに返す場合もStreamが有効です。

DBのカーソル機能を使い、取得したデータから順次 res.write() を行うことで、クライアント側での「待ち時間」を最小化できます。

外部APIとのデータ同期

外部サービスから取得した巨大なデータを加工して自社のDBに保存するようなETL処理(Extract, Transform, Load)にもStreamは適しています。

ネットワークの遅延を考慮しながら、メモリを圧迫せずに安定した速度でデータ転送を行うことができます。

pipeline() を活用した堅牢なエラーハンドリング

以前は pipe() メソッドが頻繁に使われていましたが、これには「ストリームが途中でエラーになった際に他のストリームが自動的に閉じられない」という課題がありました。

現在のNode.jsでは、node:stream モジュールに含まれる pipeline() または stream/promises の利用が推奨されています。

pipeline() を使うと、エラーが発生した際にすべてのストリームを安全にクリーンアップし、メモリリークを防ぐことができます。

JavaScript
const { pipeline } = require('node:stream/promises');
const fs = require('node:fs');
const zlib = require('node:zlib');

async function compressFile() {
    try {
        await pipeline(
            fs.createReadStream('large-file.dat'),
            zlib.createGzip(),
            fs.createWriteStream('large-file.dat.gz')
        );
        console.log('圧縮が正常に完了しました。');
    } catch (err) {
        console.error('パイプライン処理中にエラーが発生しました:', err);
    }
}

compressFile();

2026年現在のトレンド:Async Iteratorsとの統合

2026年現在のNode.js開発において、Streamはより現代的な for await...of 構文(Async Iterators)と深く統合されています。

従来のイベントリスナ(.on('data', ...))を使うスタイルに比べ、コードが直感的になり、非同期処理の制御が容易になりました。

Readable Streamはイテレータとして振る舞うことができるため、以下のような書き方が一般的になっています。

JavaScript
async function processStream() {
    const readable = fs.createReadStream('input.txt', { encoding: 'utf8' });
    
    for await (const chunk of readable) {
        // チャンクごとに非同期処理が可能
        await doSomethingAsync(chunk);
    }
}

このアプローチにより、Stream特有の難解さが軽減され、非同期ロジックの一部として組み込みやすくなっています。

まとめ

Node.jsのStreamは、リソース効率を最大限に引き出すための強力なツールです。

読み取り専用のReadable、書き込み専用のWritable、双方向のDuplex、そして変換を行うTransformの4種類を理解し、適切に組み合わせることが重要です。

特に pipeline()Async Iterators を活用することで、エラーに強く、メンテナンス性の高いコードを記述することができます。

データのサイズにかかわらず安定したパフォーマンスを発揮するアプリケーションを目指して、ぜひStreamを積極的に活用してみてください。