コールバック地獄とは何か(まずイメージ)
コールバック地獄(callback hell)は、
「非同期処理をコールバックだけでつなぎまくった結果、コードが右に右にネストしまくって、読めない・直せない状態」
のことです。
やっていること自体はシンプルです。
「A が終わったら B」「B が終わったら C」「C が終わったら D」…と、
順番に非同期処理をしたいだけ。
ただそれを全部「コールバックのネスト」で書くと、
関数の中に関数、その中にまた関数…と、どんどんピラミッド型に深くなっていきます。
ここが重要です。
コールバック地獄は「書き方の問題」であって、「非同期処理そのものが悪い」わけではありません。
非同期をコールバックだけで制御しようとした結果、構造が崩壊していく状態 が地獄なんです。
どうやってコールバック地獄が生まれるのか
「順番にやりたい非同期処理」が増えていくとき
例えば、こういう要件を考えてみます。
ユーザー情報を取得する
そのユーザーの投稿一覧を取得する
その投稿の最初の1件のコメント一覧を取得する
全部「前の結果に依存している」ので、
順番にやらないといけません。
コールバックだけで素直に書くと、こうなります。
getUser((err, user) => {
if (err) {
console.error("ユーザー取得失敗");
return;
}
getPosts(user.id, (err, posts) => {
if (err) {
console.error("投稿取得失敗");
return;
}
getComments(posts[0].id, (err, comments) => {
if (err) {
console.error("コメント取得失敗");
return;
}
console.log("全部そろった:", user, posts, comments);
});
});
});
JavaScript最初のうちはまだ読めます。
でも、もしここに
「ログインチェック」
「権限チェック」
「スピナー表示/非表示」
みたいな処理を入れていくと、すぐにこうなります。
login((err, user) => {
if (err) {
showError("ログイン失敗");
return;
}
checkPermission(user, (err, ok) => {
if (err || !ok) {
showError("権限なし");
return;
}
showSpinner();
getPosts(user.id, (err, posts) => {
hideSpinner();
if (err) {
showError("投稿取得失敗");
return;
}
getComments(posts[0].id, (err, comments) => {
if (err) {
showError("コメント取得失敗");
return;
}
console.log("完成:", user, posts, comments);
});
});
});
});
JavaScript右へ右へとずれていき、
どの if がどの処理に対応しているのか、
どの return がどこまで効いているのか、
パッと見では把握しづらくなっていきます。
これが「地獄」の入り口です。
「中でさらに非同期を呼ぶ」ネストの階段
コールバック地獄の本質は、
「非同期処理 A のコールバックの中で、非同期処理 B を呼ぶ」
「その B のコールバックの中で、さらに非同期処理 C を呼ぶ」
という階段をひたすら下りていくことです。
それぞれの段では、
非同期処理が成功したときの処理
エラーだったときの処理
前の結果を次に渡す処理
などをその場で書くので、
ネストが深くなるほど「1 画面で追いきれない」塊が増えていきます。
ここが重要です。
「Aののち B、そののち C」という時間の流れを、「カッコのネスト」という空間の深さで表現してしまっている のが、コールバック地獄の根っこです。
コールバック地獄がなぜ問題なのか
可読性が極端に下がる
まず単純に、「読むのがしんどい」です。
右にずれたピラミッド型のコードは、
今どのコールバックを見ているのか
どの if がどの非同期処理に対応しているのか
どこまでが try 的な成功パスで、どこからがエラー処理なのか
が一目で分かりません。
特に初心者にとっては、
カッコの位置と対応を追うだけで疲れてしまい、
肝心の「何をしているコードなのか」が頭に入ってこないことが多いです。
修正・拡張がつらい
ネストが深いコードに、あとから処理を挟もうとすると、こうなります。
ここにもう一段ログを追加したい
ここで条件によって処理を変えたい
ここで一度キャンセル可能にしたい
このたびに、
カッコの対応を崩さないように
インデントのレベルを合わせながら
既存のエラー処理との整合性も考えて
という「壊しやすい作業」をしなければなりません。
ちょっと気を抜くと、
1つカッコを閉じ忘れたり、
意図しない return で処理が途中で終わってしまったりします。
エラー処理が散らばる・重複する
上の例でもそうですが、
ネストが深くなるほど、if (err) { ... } が何度も出てきます。
同じようなエラー表示
同じようなログ
同じような後片付け
を、段ごとに似たようなコードで書かざるを得ません。
「エラーになったら必ずこれをする」という共通処理も、
うっかり書き漏らしたり、場所によって微妙に違う挙動になったりします。
ここが重要です。
コールバック地獄は、単に「見た目がダサい」だけでなく、
バグが紛れ込みやすく、拡張に弱い構造になりやすい という、実務的なリスクをはらんでいます。
少しだけマシにするテクニック(それでも限界がある)
関数に分けて「横に広げる」
さっきの「ユーザー → 投稿 → コメント」の例を、関数に分けてみます。
function handleComments(user, posts) {
getComments(posts[0].id, (err, comments) => {
if (err) {
console.error("コメント取得失敗");
return;
}
console.log("全部そろった:", user, posts, comments);
});
}
function handlePosts(user) {
getPosts(user.id, (err, posts) => {
if (err) {
console.error("投稿取得失敗");
return;
}
handleComments(user, posts);
});
}
function main() {
getUser((err, user) => {
if (err) {
console.error("ユーザー取得失敗");
return;
}
handlePosts(user);
});
}
main();
JavaScriptネストそのものは内部に存在していますが、
見た目は「上から順に」読むスタイルに少し近づきます。
どの関数がどの非同期処理を担当しているかも分かりやすくなり、
「とりあえず main から追えば全体像が分かる」という状態にはできます。
共通のエラー処理を関数化する
同じようなエラー処理が多いなら、
関数に閉じ込めてしまうのも一つの手です。
function handleError(err, message) {
if (err) {
console.error(message);
return true;
}
return false;
}
getUser((err, user) => {
if (handleError(err, "ユーザー取得失敗")) return;
getPosts(user.id, (err, posts) => {
if (handleError(err, "投稿取得失敗")) return;
getComments(posts[0].id, (err, comments) => {
if (handleError(err, "コメント取得失敗")) return;
console.log("全部そろった:", user, posts, comments);
});
});
});
JavaScriptだいぶマシになりますが、
それでも右へのネストは残っていますし、
条件分岐や追加処理が増えるほど厳しくなっていきます。
ここが重要です。
こうした工夫は「応急処置」としては有効ですが、
コールバック地獄の根本的なつらさ(依存関係をネストで表現している問題)を解決してはいない ということを覚えておいてください。
なぜ Promise / async/await が「救い」になったのか
ここは次のステップの内容ですが、
コールバック地獄を理解するうえで、少し触れておきます。
やりたいことは本当はシンプルです。
ユーザーを取得する
次に投稿を取得する
次にコメントを取得する
これを「時間の流れ」と同じように、上から順に書きたい。
Promise + then なら(ざっくりイメージですが)こう書けます。
getUserPromise()
.then((user) => getPostsPromise(user.id))
.then((posts) => getCommentsPromise(posts[0].id))
.then((comments) => {
console.log("全部そろった:", comments);
})
.catch((err) => {
console.error("どこかで失敗:", err);
});
JavaScriptさらに async/await なら、もっと普通の同期コードに近い姿になります。
async function main() {
try {
const user = await getUserPromise();
const posts = await getPostsPromise(user.id);
const comments = await getCommentsPromise(posts[0].id);
console.log("全部そろった:", comments);
} catch (err) {
console.error("どこかで失敗:", err);
}
}
JavaScriptここで大事なのは、
「技術的に何をしているか」よりも先に、
「右にずれていたピラミッド構造が、
上から順に読む“縦のストーリー”に戻っている」
という事実です。
ここが重要です。
Promise / async/await は、「非同期処理をなくす魔法」ではなく、「コールバック地獄から抜け出すための書き方の進化」 だと捉えると、理解がずっと楽になります。
まとめ:コールバック地獄をどう扱うか
コールバック地獄を一言で言うと、
「順番に実行したい非同期処理を、全部ネストしたコールバックで書いた結果、コード構造がピラミッド地獄になること」
です。
押さえておいてほしいポイントは次の通りです。
コールバック地獄そのものは「非同期の仕組みの失敗」ではなく、「表現の仕方の失敗」である。
ネストが深くなるほど、可読性が落ち、ミスを誘発しやすくなり、変更が怖いコードになる。
一時的な対策として「関数に分ける」「共通処理をまとめる」はあるが、根本的な解決には実はなっていない。
Promise / async/await は、この地獄から抜け出すために生まれた、「非同期処理を線形に書くための道具」だと理解するとよい。
もし余裕があれば、実際にこういうことをやってみてください。
自分で 3〜4 段のネストしたコールバックを書いてみる(タイマーやフェイク API で OK)
「うわ、これ読みにくい」と感じたところで、「どうやったらマシにできるか」を少し考えてみる
その「モヤモヤ感」をちゃんと味わっておくと、
この先 Promise / async/await を学ぶときに、
「これは単に“新しい構文”ではなく、“この苦しさを解消するための進化”なんだ」
と実感をもって受け取れるようになります。
