Featured image of post JavaScriptのAsync/Awaitで避けるべきアンチパターン Featured image of post JavaScriptのAsync/Awaitで避けるべきアンチパターン

JavaScriptのAsync/Awaitで避けるべきアンチパターン

JavaScriptのAsync/Awaitで避けるべきアンチパターンと改善策を解説。直列実行による非効率、エラーハンドリングの漏れ、Promise.allの誤用など、非同期処理で陥りがちな問題を具体例とともに紹介し、クリーンで効率的なコードを書く方法を学びます。

はじめに

JavaScriptにおいて async/await がES2017で導入されて以来、Promiseチェーン(.then().catch())に比べて非同期処理を同期処理のように直感的に記述できるようになりました。

しかし、その「同期コードのように書ける」という強力なメリットの裏で、開発者が意図せずにパフォーマンスを低下させたり、エラー処理を疎かにしてしまうアンチパターンが多く存在します。

本記事では、特に本番環境で発生しやすい「JavaScriptのAsync/Awaitで避けるべきパターン」を抽出し、それらをどのように改善すべきかをステップバイステップで解説します。


アンチパターン1: 不必要なシリアル実行(並行処理の機会損失)

もっとも頻出するミスが、並行して実行できる複数の独立した非同期タスクを、すべて順番(直列)に実行してしまうパターンです。

避けるべきコード

// 3つの独立したAPIリクエストを直列に処理している
async function getUserProfile(userId) {
  const user = await fetchUser(userId);       // 1秒かかる
  const posts = await fetchPosts(userId);     // 1.5秒かかる
  const followers = await fetchFollowers(userId); // 0.8秒かかる

  return { user, posts, followers };
}

このコードでは、fetchUser が終わるまで fetchPosts が実行されず、さらにそれが終わるまで fetchFollowers が始まりません。全体の実行時間は 1 + 1.5 + 0.8 = 3.3秒 かかります。しかし、これら3つのリクエストは互いに依存していません。

改善されたコード(Promise.all の活用)

async function getUserProfile(userId) {
  // 並行してリクエストを開始し、すべての完了を待つ
  const [user, posts, followers] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
    fetchFollowers(userId)
  ]);

  return { user, posts, followers };
}

Promise.all を使用することで、すべての処理が同時に開始されます。全体の実行時間はもっとも時間のかかるタスク(fetchPosts の 1.5秒)に抑えられます。


アンチパターン2: try-catch ブロックの過剰ネスト

非同期処理のエラーハンドリングを行う際、従来のコールバック地獄を模したかのように、try-catch ブロックを細かくネストさせてしまうパターンです。

避けるべきコード

async function processData() {
  try {
    const rawData = await fetchData();
    try {
      const parsedData = await parseData(rawData);
      try {
        await saveToDatabase(parsedData);
      } catch (dbError) {
        console.error("Database save failed", dbError);
      }
    } catch (parseError) {
      console.error("Parsing failed", parseError);
    }
  } catch (fetchError) {
    console.error("Fetch failed", fetchError);
  }
}

せっかくの async/await のフラットな構造が、過剰なネストによって台無しになっています。

改善されたコード

同一の関数内でまとめてエラー処理ができる場合は、最外側の単一の try-catch に集約するのが基本です。エラーの種類を区別したい場合は、エラーオブジェクトのプロパティやカスタムクラスを利用するか、個別の非同期処理の背後に直接 .catch() を挟む手法(ヘルパー関数の利用)がクリーンです。

async function processData() {
  try {
    const rawData = await fetchData();
    const parsedData = await parseData(rawData);
    await saveToDatabase(parsedData);
  } catch (error) {
    // 発生したエラーを一元管理する
    handleProcessingError(error);
  }
}

アンチパターン3: async関数内での Array.prototype.forEach() の使用

配列内の各要素に対して非同期処理を行いたい場合、直感的に forEach メソッドを使用してしまうと、予期しないバグを誘発します。

避けるべきコード

// 各ユーザー宛てに非同期でメールを送信したい
async function sendEmails(users) {
  users.forEach(async (user) => {
    await mailer.send(user.email);
  });
  console.log("すべてのメール送信処理が完了しました!"); // <- 実際には完了前に実行される
}

Array.prototype.forEach は、コールバック関数の完了(Promiseの解決)を待ちません。同期処理的にコールバックを順次発火させるだけであるため、forEach のループを抜けた直後の console.log は、まだメールの送信が完了する前に実行されてしまいます。

改善されたコード(for…of または Promise.all の使用)

直列に一つずつ処理したい場合は for...of ループを使用します。

async function sendEmailsSerially(users) {
  for (const user of users) {
    await mailer.send(user.email); // 前の送信が終わるのを待つ
  }
  console.log("すべてのメールが送信されました");
}

並行して一括処理したい場合は mapPromise.all を組み合わせます。

async function sendEmailsInParallel(users) {
  const emailPromises = users.map(user => mailer.send(user.email));
  await Promise.all(emailPromises); // すべてのPromiseが解決されるのを待つ
  console.log("すべてのメールが送信されました");
}

アンチパターン4: await のない async 関数の作成

関数内で非同期処理を実行していない(Promiseを返していない)にもかかわらず、手当たり次第に async キーワードを付与してしまうパターンです。

避けるべきコード

// 単なる同期的な数値計算
async function calculateSum(a, b) {
  return a + b;
}

関数に async を付けると、ブラウザのV8エンジンなどのランタイムは、その戻り値を自動的に Promise オブジェクトでラップします。これには余分なメモリとマイクロタスクキューのスケジューリングコストが発生するため、純粋な同期処理に async を付与するのはパフォーマンス上避けるべきです。


まとめ

async/await は非常に読みやすいコードを実現しますが、その仕組みはPromiseの上に乗るシンタックスシュガーに過ぎません。

  1. 並行処理できるものは Promise.all でまとめる
  2. ループ処理内では forEach ではなく for...ofPromise.all を選ぶ
  3. エラーハンドリングはネストさせず、階層構造をシンプルに保つ

これらを徹底することで、安全で高速な非同期処理アプリケーションを設計できるようになります。