BFF設計で429エラーと戦った話

BFF設計で429エラーと戦った話

  • 日本語
  • Rust
  • Leptos
  • Axum
  • BFF
  • API設計
  • GCP

Note この記事のコード例は、BFF設計の解説用にTODOアプリを題材として作成したものです。また、記事内で登場するレート制限(10 req/s)は、Cloud RunやCloud SQLへの負荷軽減と課金最適化のためにバックエンドで独自実装したものであり、GCPのデフォルト制限ではありません。

#発端:「なんか画面が表示されないんですけど」

ある日、ユーザーから報告が届いた。

ダッシュボード画面を開くと「データが見つかりません」と表示される

ログを確認してみると、大量の 429 Too Many Requests が発生していた。

「あれ、レート制限? でもそんなに大量のリクエストを送った覚えは…」

Network タブを開いて絶句した。

text
/api/tasks/today      → 200
/api/projects/stats   → 429
/api/activities       → 429
/api/team/status      → 429
/api/notifications    → 429

5つのAPIが同時発火していた。

フロントエンドの各コンポーネントが、それぞれ独立してAPIを呼び出していたのだ。しかもページ表示時に一斉に。

レート制限(10 req/s)に余裕で引っかかる構成である。

#なぜこうなった?

Reactや Vue、そして今回使っていた Leptos(Rust製のフロントエンドフレームワーク)では、コンポーネントごとにデータ取得ロジックを持つのが一般的だ。

TODOアプリのダッシュボードを例にすると:

rust
// 今日のタスク一覧コンポーネント
Effect::new(move |_| {
    spawn_local(async move {
        api.get_today_tasks(user_id).await;  // API呼び出し①
    });
});

// プロジェクト進捗コンポーネント
Effect::new(move |_| {
    spawn_local(async move {
        api.get_project_stats(user_id).await;  // API呼び出し②
    });
});

// 最近のアクティビティコンポーネント
Effect::new(move |_| {
    spawn_local(async move {
        api.get_activities(user_id).await;  // API呼び出し③
    });
});

// チームステータスコンポーネント
Effect::new(move |_| {
    spawn_local(async move {
        api.get_team_status(team_id).await;  // API呼び出し④
    });
});

各コンポーネントは自分のことしか知らない。隣のコンポーネントが同時にAPIを叩いているなんて、知ったこっちゃない。

結果、ページ表示時に5つのAPIが同時発火 → レート制限に到達 → 429エラー → 画面表示失敗

という流れである。

#救世主:BFF(Backend for Frontend)パターン

調べていくうちに出会ったのが BFF(Backend for Frontend) パターンだった。

#BFFとは

Sam Newman氏の Backends For Frontends によると、BFFは「特定のユーザーインターフェース(UI)ごとに専用のバックエンドサービスを配置する」アーキテクチャパターンだ。

同氏は “one experience, one BFF” というガイドラインを提唱しており、各UIタイプが独立したバックエンドを持つべきことを示唆している。

text
【Before】
Frontend → /api/tasks/today     → Backend
        → /api/projects/stats  → Backend
        → /api/activities      → Backend
        → /api/team/status     → Backend
        → /api/notifications   → Backend
(5回のリクエスト)

【After】
Frontend → /api/dashboard/init → Backend(内部で5つのデータを集約)
(1回のリクエスト)

#BFFの主なメリット

  1. API呼び出し数の削減 - レート制限対策、パフォーマンス向上
  2. ラウンドトリップの削減 - ネットワークレイテンシの大幅改善
  3. フロントエンドの簡素化 - データ集約ロジックをバックエンドに移動
  4. クラウド運用コストの削減 - リクエスト課金の最適化

Decathlonの事例 では、BFF導入により「フロントエンドのスパゲッティコード削減」「JSバンドルサイズ削減」「ブラウザの計算リソース削減」を実現したとのこと。

#どんな場面で有効?

BFFが特に効果を発揮するのは:

  • ダッシュボード系UI - 複数のデータソースを1画面に表示
  • レート制限がある環境 - 外部APIやクラウドサービスの制限
  • モバイル対応 - 通信回数を減らしてバッテリー消費を抑制
  • マイクロサービス構成 - 複数サービスからのデータ集約

逆に、シンプルなCRUD画面(一覧・詳細・編集)では過剰設計になりがち。

#実装:BFFエンドポイントを作る

TODOアプリのダッシュボード用BFFを実装してみる。

#Before(個別API)

rust
// 個別のレスポンス型
pub struct TodayTasksResponse { ... }
pub struct ProjectStatsResponse { ... }
pub struct ActivitiesResponse { ... }
pub struct TeamStatusResponse { ... }

#After(BFFで集約)

rust
// BFF用の統合レスポンス型
pub struct DashboardInitData {
    pub today_tasks: Vec<Task>,
    pub project_stats: ProjectStats,
    pub activities: Vec<Activity>,
    pub team_status: TeamStatus,
    pub notifications: Vec<Notification>,
}

バックエンドでは、各データを取得する共通関数を切り出し、BFFエンドポイントから呼び出す形にした。

rust
pub async fn get_dashboard_init(
    State(pool): State<PgPool>,
    user: AuthenticatedUser,
) -> Result<Json<DashboardInitData>, StatusCode> {
    // 各データを取得
    let today_tasks = fetch_today_tasks(&pool, user.id).await?;
    let project_stats = fetch_project_stats(&pool, user.id).await?;
    let activities = fetch_activities(&pool, user.id, 20).await?;
    let team_status = fetch_team_status(&pool, user.team_id).await?;
    let notifications = fetch_notifications(&pool, user.id, 10).await?;

    Ok(Json(DashboardInitData {
        today_tasks,
        project_stats,
        activities,
        team_status,
        notifications,
    }))
}

#落とし穴①:タイミング問題

フロントエンドの実装で、予想外の問題が発生した。

rust
Effect::new(move |_| {
    // BFFデータを取得
    if let Some(data) = dashboard_data.get() {
        // 初期データを使用
        set_activities.set(data.activities.clone());
        return;
    }

    // BFFデータがなければAPIを呼ぶ
    api.get_activities(...).await;  // ← これが呼ばれてしまう!
});

問題は Effect が BFF データ取得完了より先に実行される こと。

BFFのAPIは非同期なので、Effect が最初に実行される時点では dashboard_data.get()None になっている。結果、個別APIが呼ばれてしまう。

#解決策

BFFデータが来るまで待つロジックを追加した。

rust
Effect::new(move |_| {
    let bff_data = dashboard_data.get();  // 監視対象に追加

    // BFFデータがまだない場合は待つ
    if bff_data.is_none() && is_initial_load {
        return;  // APIを呼ばずに return
    }

    // BFFデータがあれば使用
    if let Some(data) = bff_data {
        set_activities.set(data.activities.clone());
        return;
    }

    // ユーザーが設定を変更した場合のみAPIを呼ぶ
    api.get_activities(...).await;
});

ポイントは dashboard_data.get() を Effect 内で呼ぶこと。これにより dashboard_data の変更も監視対象になり、BFFデータが来たら Effect が再実行される。

#落とし穴②:関心の分離

「ついでにこのAPIもBFFに含めちゃえ」と思ったが、ここは慎重になるべきだった。

例えば、ヘッダーで使うユーザープロフィールAPI。これをダッシュボードのBFFに含めるべきか?

答えは No。

Microsoft Azure のドキュメント によると、BFFは 特定のクライアント体験に特化 させるべきとされている。

  • /api/dashboard/init → ダッシュボードページ専用
  • ユーザープロフィール → 全ページ共通のヘッダーコンポーネント

ユーザープロフィールをダッシュボードBFFに含めると:

  • 関心の分離が崩れる
  • 他のページでもBFFを呼ぶ必要が出てくる

BFFは「軽量に保つ」のがベストプラクティス。データ集約と変換に集中し、何でもかんでも詰め込まない。

#落とし穴③:キャッシュ無効化問題

BFFでパフォーマンスが改善したので、さらにフロントエンドにキャッシュを導入した。

rust
// キャッシュ設定
const CACHE_TTL_SECONDS: f64 = 300.0;  // 5分

// キャッシュヒット時は即座に返す
if let Some(cached) = cache.get_dashboard_data(user_id) {
    set_dashboard_data.set(Some(cached));
    return;
}

// キャッシュミス時のみAPIを呼ぶ
let data = api.get_dashboard_init().await?;
cache.set_dashboard_data(user_id, data.clone());

これで「戻る」操作時の体感速度が劇的に改善した。

しかし、新たな問題が発生。

タスクを完了にしたのに、ダッシュボードに戻ると未完了のまま表示される

原因は単純。データ変更時にキャッシュを無効化していなかった。

#解決策:イベントベースのキャッシュ無効化

Redis のドキュメントDesign Gurus の解説 によると、キャッシュ無効化には主に以下のアプローチがある:

  1. TTL(Time-To-Live)ベース - 一定時間で自動的に期限切れ
  2. イベントベース - データ変更時に明示的に無効化
  3. ハイブリッド - TTLをフォールバックとし、重要な変更はイベントで即座に無効化

今回は ハイブリッドアプローチ を採用した。

rust
// タスク完了時にキャッシュを無効化
match api.complete_task(task_id).await {
    Ok(_) => {
        // キャッシュを無効化
        cache.invalidate_dashboard_data(user_id);
        toast.success("タスクを完了しました");
    }
    Err(e) => toast.error(e),
}

これで:

  • 通常時 → TTL(5分)でキャッシュが効く
  • データ変更時 → 即座にキャッシュを無効化し、次回アクセス時に最新データを取得

BFFにキャッシュを導入する際は、必ずキャッシュ無効化戦略をセットで考える必要がある。

#副産物:クラウド運用コストの削減

BFF導入で思わぬ副産物があった。クラウドの月額運用コスト削減だ。

#Cloud Run のリクエスト課金

Cloud Run の料金体系 によると:

  • 無料枠: 月間200万リクエストまで無料
  • 超過分: 100万リクエストあたり $0.40(Tier 1リージョン)

一見安く見えるが、トラフィックが増えると無視できない金額になる。

#BFFによる削減効果

text
【Before】
ダッシュボード表示 = 5 API呼び出し
1ユーザー × 10回/日 × 30日 = 1,500リクエスト/月

100ユーザーなら = 150,000リクエスト/月

【After】
ダッシュボード表示 = 1 API呼び出し(BFF)
1ユーザー × 10回/日 × 30日 = 300リクエスト/月

100ユーザーなら = 30,000リクエスト/月

リクエスト数が約80%削減される計算だ。

さらに、DBへのコネクション数も削減される。5回のAPI呼び出しが1回になれば、コネクションプールの効率も上がる。

#副産物②:レイテンシの大幅改善

DreamFactory のガイド によると、APIレイテンシは以下の要素で構成される:

  1. ネットワーク往復時間(RTT) - クライアント↔サーバー間の通信時間
  2. サーバー処理時間 - リクエスト処理にかかる時間
  3. HTTPオーバーヘッド - ヘッダー解析、TLSハンドシェイク等

#BFFによるレイテンシ削減

text
【Before】5回のAPI呼び出し
RTT × 5 = 50ms × 5 = 250ms(ネットワークだけで)
+ HTTPオーバーヘッド × 5
+ サーバー処理時間 × 5

【After】1回のBFF呼び出し
RTT × 1 = 50ms × 1 = 50ms
+ HTTPオーバーヘッド × 1
+ サーバー処理時間 × 1(内部で並列処理可能)

Tyk API Gateway のブログ でも、リクエストバッチングによる「ラウンドトリップ削減」がレイテンシ改善の有効な手段として紹介されている。

実際、体感でも「サクサク感」が明らかに向上した。

#結果

text
【Before】
初期ロード時のAPIコール: 5〜6回
→ 429エラー発生
→ 「データが見つかりません」

【After】
初期ロード時のAPIコール: 1回(BFF)
→ 429エラー解消
→ 画面が正常に表示される
→ レイテンシ改善(体感でサクサク)
→ クラウドコスト削減

ユーザー操作(フィルター変更、ページング等)時のみ個別APIを呼び出す設計になり、初期ロードは爆速になった。

#学んだこと

#1. コンポーネント単位のAPI呼び出しは危険

各コンポーネントが独立してAPIを呼ぶ設計は、規模が大きくなると破綻する。特にレート制限がある環境では要注意。

#2. BFFは「初期ロード」と「ユーザー操作」で分けて考える

  • 初期ロード → BFFで一括取得
  • ユーザー操作 → 個別APIで差分取得

#3. BFFは軽量に保つ

何でもかんでもBFFに詰め込まない。特定のページ/機能に特化させる。Sam Newman氏の言う “one experience, one BFF” を意識する。

#4. タイミング問題に注意

非同期データ取得とEffect/リアクティブシステムの組み合わせは、実行順序に気をつける必要がある。

#5. キャッシュ導入時は無効化戦略をセットで

BFF + キャッシュは強力だが、データ変更時のキャッシュ無効化を忘れると「古いデータが表示される」問題が発生する。TTL + イベントベースのハイブリッドアプローチがおすすめ。

#6. BFFはコスト最適化にも効く

API呼び出し数の削減は、パフォーマンスだけでなくクラウド運用コストにも直結する。特にリクエスト課金のサービス(Cloud Run、Lambda等)では効果が大きい。

#まとめ

429エラーという一見シンプルな問題から、BFF設計の奥深さを学ぶことができた。

フロントエンドフレームワークが「コンポーネント単位の状態管理」を推奨する一方で、APIアーキテクチャは「ページ単位の最適化」が必要になることがある。

この2つのバランスを取るのがBFFパターンの役割なのかもしれない。

次に似たような問題に遭遇したら、迷わずBFFを検討しよう。


#参考資料