コールバック地獄とその問題点

コールバック関数は非同期処理の基本だが、処理が連鎖すると深いネストが生まれる。この状態は「コールバック地獄(Callback Hell)」と呼ばれ、コードの可読性・保守性を著しく損なう。Promise が生まれた最大の理由がここにある。

コールバック地獄の典型例

たとえばユーザー情報を取得し、その結果をもとに注文履歴を取得し、さらに各注文の詳細を取得する、という 3 段階の処理を考えてみよう。

getUser(userId, function(user) {
  getOrders(user.id, function(orders) {
    getOrderDetail(orders[0].id, function(detail) {
      console.log(detail);
    }, function(err) {
      console.error("詳細取得失敗:", err);
    });
  }, function(err) {
    console.error("注文取得失敗:", err);
  });
}, function(err) {
  console.error("ユーザー取得失敗:", err);
});

ネストが 3 段階になっただけで、どの function がどの処理に対応しているのか追いにくくなる。実際のアプリケーションではさらに多くの非同期処理が連鎖するため、インデントが右にどんどん深くなっていく。その見た目から「破滅のピラミッド(Pyramid of Doom)」とも呼ばれている。

何が問題なのか

コールバック地獄の問題は単なる見た目の悪さだけではない。

可読性の低下

ネストが深くなるほど処理の流れを追うのが困難になる。3 段階を超えると、多くの開発者がコードの意図を把握するまでに時間がかかるようになる。

エラーハンドリングの分散

各コールバックに個別のエラー処理を書く必要があり、エラーハンドリングがコード全体に散らばる。ひとつでも書き忘れると、エラーが黙殺されてデバッグが難しくなる。

制御フローの複雑化

条件分岐やループと組み合わせると指数関数的に複雑度が増す。「A が成功したら B を実行、B が失敗したら C を実行、ただし A が特定の値なら D を実行」のような分岐をコールバックで表現すると、コードが爆発的に膨らむ。

エラーハンドリングの問題を具体的に見る

コールバック方式では try/catch が使えないという根本的な問題がある。非同期コールバックは呼び出し元のスタックフレームを離れて実行されるため、外側の try/catch では捕捉できない。

// これは動かない
try {
  setTimeout(function() {
    throw new Error("非同期エラー");
  }, 1000);
} catch (e) {
  // ここには到達しない
  console.error(e);
}

そのため、Node.js ではエラーファーストコールバックという規約が生まれた。コールバックの第 1 引数をエラーオブジェクトにする、というパターンだ。

function readFile(path, callback) {
  // エラーがあれば第1引数に、なければ null
  if (!path) {
    callback(new Error("パスが指定されていません"), null);
    return;
  }
  callback(null, "ファイルの内容");
}

readFile("/data.txt", function(err, data) {
  if (err) {
    console.error(err.message);
    return;
  }
  console.log(data);
});

このエラーファーストコールバックは Node.js のコア API 全体で採用されている規約であり、コールバック規約の事実上の標準となった。

コールバックの第 1 引数を err とし、エラーがなければ null を渡す約束。

しかし、エラーファーストコールバックであっても、処理が連鎖するとエラーチェックの繰り返しでコードが冗長になる。

readFile("/config.json", function(err, config) {
  if (err) { console.error(err); return; }
  parseJSON(config, function(err, parsed) {
    if (err) { console.error(err); return; }
    connectDB(parsed.dbUrl, function(err, db) {
      if (err) { console.error(err); return; }
      console.log("接続成功");
    });
  });
});

毎回 if (err) を書く必要があり、本来やりたい処理の流れが if 文の中に埋もれていく。

回避策とその限界

コールバック地獄を軽減するテクニックとして、コールバックを名前付き関数に分離する方法がある。

function handleDetail(detail) {
  console.log(detail);
}

function handleOrders(orders) {
  getOrderDetail(orders[0].id, handleDetail);
}

function handleUser(user) {
  getOrders(user.id, handleOrders);
}

getUser(userId, handleUser);

ネストは浅くなるが、処理の順序を把握するには関数の定義を追いかける必要があり、コードの流れが分断される。根本的な解決にはならない。

コールバックの連鎖でネストが深くなる

可読性とエラーハンドリングが悪化する

Promise による解決が求められるようになった

こうした問題を解決するために ES2015 で導入されたのが Promise だ。Promise はコールバックの連鎖を .then() メソッドでフラットに書けるようにし、エラーハンドリングも .catch() で一箇所にまとめられるようにした。コールバック地獄の苦しみが、JavaScript の非同期処理を進化させる原動力となったといえる。