ASP.NET Core のパフォーマンス設計:構造最適化が実行速度を左右する理由と実装ポイント

Published on: | Last updated:

最近よく考えてたんだけど、ASP.NET Coreのパフォーマンスチューニングの話になると、みんな結構細かいところに行きがちだよね。LINQをforループに変えるとか、文字列の結合方法とか、メモリプーリングがどうとか。そういうミクロな最適化も、まあ、無駄じゃないんだけど、正直、それでアプリが劇的に速くなることって稀じゃない?

本当に効いてくるのって、そういう小手先のテクニックじゃなくて、もっと根本的な…そう、アーキテクチャの選択だと思うんだ。開発の初期段階で決めた構造が、後々のスケーラビリティにものすごく影響する。だから今日は、構文の微調整とかじゃなくて、本当にパフォーマンスを左右する「構造」の話をしてみようかなって。

クリーンアーキテクチャが逆に性能を落とす?っていう話

クリーンアーキテクチャ、みんな好きだよね。関心事が分離されてて、テストもしやすいし、保守性も高い。でもね、これ、パフォーマンスを全く考えずに教科書通りに作ると、結構なオーバーヘッドを生むことがあるんだ。

例えば、よくある`Controller → Service → Repository → Database`っていう階層構造。それぞれの層で独自のモデルを持ってて、層をまたぐたびにデータのマッピングが発生する。こういうアプリで、ただ顧客データを1件取ってくるだけのシンプルなAPIの応答時間を測ってみると、全体で120ミリ秒くらいかかってたりする。で、そのうちデータベースのクエリ自体は15ミリ秒だったりして。じゃあ残りの100ミリ秒以上はどこに消えたの?って話になる。

クリーンアーキテクチャでよく見るパフォーマンスの落とし穴
クリーンアーキテクチャでよく見るパフォーマンスの落とし穴

プロファイラで中身を覗いてみると、だいたいこんな感じに時間が分散してる。

  • オブジェクトのマッピング(層間のモデル変換)に35ミリ秒くらい。
  • DIコンテナによる依存関係の解決に28ミリ秒くらい(特に依存が深くなってる場合)。
  • あと、複数の層で重複してるバリデーションとかビジネスロジックに42ミリ秒くらい。

これを見ると、データベースがボトルネックじゃないのは明らかだよね。じゃあ、クリーンアーキテクチャやめればいいのかっていうと、それも違う。もっと戦略的に、構造を少し見直すだけで、劇的に改善できることが多いんだ。

じゃあ、どうやって構造を最適化するの?

いくつか方法はあるんだけど、代表的なのはこんな感じ。

  1. 一部のモデルを共有する: 隣接するレイヤー間で、全部マッピングするんじゃなくて、一部のモデルは共有しちゃう。これでマッピングのコストを減らせる。
  2. 依存関係をフラットにする: サービスの依存関係が深くなりすぎないように、構成を見直す。ネストが深いと、インスタンス生成のオーバーヘッドが馬鹿にならないからね。
  3. CQRSを導入する: これが個人的には一番効果的だと思う。要は、データの参照(Query)と更新(Command)のパスを完全に分ける考え方。参照系の処理って、複雑なビジネスロジックやバリデーションがいらないことが多いでしょ?そういう場合は、余計なサービス層とかをバイパスして、直接データベースから必要なデータだけをDTO(Data Transfer Object)に詰めて返す。

特にCQRSは効くよ。さっきの120ミリ秒の例で言うと、こういうアーキテクチャの見直しをするだけで、応答時間が45ミリ秒くらいまで、つまり6割から7割くらい削減できたりする。アルゴリズムを変えたり、トリッキーなコードを書いたりしなくてもね。

構造を変えるだけで、ここまで応答時間は短縮できる
構造を変えるだけで、ここまで応答時間は短縮できる

コードで示すと、違いは一目瞭然。

// 変更前:典型的なサービスクラス。複数の依存とマッピングがある。
public async Task GetCustomerAsync(int id)
{
    var customerEntity = await _customerRepository.GetByIdAsync(id);
    // ここでEntityからビジネスモデルへのマッピングが発生
    var customerModel = _mapper.Map(customerEntity); 
    await _validationService.ValidateCustomerAsync(customerModel);
    // ここでビジネスモデルからDTOへのマッピングが発生
    return _mapper.Map(customerModel); 
}

// 変更後:CQRSのクエリハンドラ。めちゃくちゃシンプル。
public async Task Handle(GetCustomerQuery query)
{
    // DBから直接レスポンス用のDTOにプロジェクションする
    return await _dbContext.Customers
        .Where(c => c.Id == query.CustomerId)
        .Select(c => new CustomerResponseDto { 
            Id = c.Id, 
            Name = c.Name, 
            // 他のプロパティ
        })
        .FirstOrDefaultAsync();
}

後のコード、すっきりしてるでしょ。余計な層がないから、速いのは当たり前なんだよね。

サービスのライフタイム管理、これ、隠れたパフォーマンスキラーだから

ASP.NET Coreのパフォーマンスで見過ごされがちなのが、サービスのライフタイム。`Singleton`、`Scoped`、`Transient`のどれを選ぶか。これ、メモリ使用量とスループットにめちゃくちゃ影響する。

ざっくり言うと、

  • Singleton: アプリケーション全体でインスタンスは一個だけ。ずっと使い回される。
  • Scoped: HTTPリクエストごとにインスタンスが一個作られる。そのリクエストの中では使い回される。
  • Transient: 毎回、DIコンテナから要求されるたびに新しいインスタンスが作られる。

何も考えずに全部`Scoped`とか`Transient`にしてると、リクエストが多いときにインスタンスの生成と破棄(ガベージコレクション)のコストが積み重なって、パフォーマンスが頭打ちになる。特に`Transient`は要注意。

ステートレスなサービス、つまり状態を持たないサービスなら、積極的に`Singleton`にできないか検討すべき。例えば、設定情報を読むだけのサービスとか、マッピング定義とか、ステートレスなユーティリティとかね。Microsoftの公式ドキュメントでも推奨されてるけど、意外と現場では意識されてないことが多い気がする。

実際に負荷テストツール、例えば`BenchmarkDotNet`とかで測ってみると、その差は歴然とする。どのサービスを`Singleton`にできるか注意深く見直すだけで、スループットが6割とか7割向上するケースも全然珍しくない。マジで。

じゃあ、どう使い分けるのがいいの?

個人的な指針はこんな感じかな。

  • 基本は`Scoped`: DBコンテキスト(`DbContext`)に依存するサービスとか、リクエスト単位で状態を管理したいものは、だいたいこれ。
  • 積極的に`Singleton`: 状態を持たないサービス。キャッシュサービス、ロガー、設定管理、`IHttpClientFactory`とか。ただし、`Scoped`なサービスに依存するものは`Singleton`にできないから注意。
  • `Transient`は慎重に: 利用するたびに必ずユニークな状態が必要な、本当に特殊なケースだけ。例えば、一時的な計算結果を保持するビルダーオブジェクトとかかな。ほとんどのケースでは不要なはず。
// 最適化されたサービス登録のパターン例
// ↓↓↓ ステートレスなものはSingletonにできないか検討する
services.AddSingleton();
services.AddSingleton(GetConfiguredMapper());
services.AddSingleton(CreateLogger());

// ↓↓↓ DB接続やリクエスト単位のものはScopedが基本
services.AddScoped();
services.AddScoped();

// ↓↓↓ Transientは本当に必要な時だけ
services.AddTransient();

この辺の整理整頓をするだけで、アプリの見通しも良くなるし、パフォーマンスも上がるし、一石二鳥だと思う。

知ってた?ミドルウェアの順番もパフォーマンスに効くんだよ

これも地味だけど効果的なやつ。`Program.cs`(昔の`Startup.cs`)に書くミドルウェアの順番。あれ、実はすごく大事。

例えば、認証ミドルウェア (`UseAuthentication`) の置き場所。これをパイプラインのかなり早い段階に置いちゃうと、すべてのリクエスト、そう、画像やCSSファイルへのリクエスト、ヘルスチェックのエンドポイントへのリクエストまで、全部認証の処理が走っちゃう。これ、無駄だよね。

// 変更前:認証がすべてのリクエストで実行される
app.UseHttpsRedirection();
app.UseAuthentication();  // ←静的ファイルのリクエストにもこれが走る
app.UseAuthorization();
app.UseStaticFiles();
app.UseRouting();
// ...

静的ファイルへのアクセスとか、認証が不要なエンドポイントは、認証ミドルウェアよりも前に処理を完了させるべき。順番を入れ替えるだけ。

// 変更後:認証が不要なリクエストは先に処理する
app.UseHttpsRedirection();
app.UseStaticFiles();      // ←認証の前に静的ファイルを返す
app.UseRouting();

// ヘルスチェックなど、認証不要なエンドポイントを先に定義
app.MapHealthChecks("/health"); 

// この後に認証・認可ミドルウェアを置く
app.UseAuthentication();
app.UseAuthorization();

app.UseEndpoints(...);

たったこれだけのことで、特にトラフィックの多いサイトだと、サーバーのCPU負荷が目に見えて下がる。コスト削減にも直結するから、やらない手はないよね。

Minimal APIはただの省略記法じゃない

.NET 6で登場したMinimal API。最初は「コントローラー書くのが楽になるシンタックスシュガーでしょ?」くらいに思ってたんだけど、いやいや、全然違う。これもパフォーマンスに効くアーキテクチャの選択なんだ。

なんで速いかっていうと、従来のコントローラーベースのAPIが持っていた、いろんな「おもてなし」機能をバイパスするから。例えば、コントローラーの探索、アクションメソッドの選択、フィルターの実行パイプライン…こういうのを全部すっ飛ばせる。シンプルなAPIにとっては、この辺の仕組みはただのオーバーヘッドでしかないからね。

じゃあ、どっちを使えばいいの?っていう話になると思うから、簡単な比較表を作ってみた。あくまで個人的な見解だけど。

コントローラーとMinimal API、どっちを選ぶ?
コントローラーとMinimal API、どっちを選ぶ?

結論としては、使い分けが大事ってこと。例えば、アプリケーションの大部分は従来のコントローラーで作っておいて、特にアクセスが集中するエンドポイント(例えば、商品一覧とか)だけをMinimal APIで実装する、みたいなハイブリッドなアプローチがいいんじゃないかな。

反例とよくある誤解

ここまで最適化の話ばっかりしてきたけど、じゃあ「常に最速のアーキテクチャが正義なのか?」っていうと、そうじゃない。ここが大事なところで。

  • 誤解1: クリーンアーキテクチャは悪である。
    そんなことはない。システムの寿命が長くて、ビジネスロジックが複雑で、保守性が何よりも重要なプロジェクトなら、レイヤー間のマッピングのオーバーヘッドは許容すべきコスト。むしろ、CQRSを無理やり導入してコードが複雑になる方が問題。
  • 誤解2: すべてのサービスはSingletonにすべきである。
    これもダメ。状態を持ってしまっているサービスを無理やりSingletonにすると、ユーザー間でデータが混線したり、とんでもないバグの原因になる。スレッドセーフじゃないものをSingletonにするのは自殺行為。
  • 誤解3: コントローラーはもう古い。全部Minimal APIにすべき。
    さっきも言ったけど、これも違う。認証、バリデーション、ロギングみたいな横断的な関心事をフィルターで綺麗に処理したいなら、コントローラーの仕組みはすごくよくできてる。Minimal APIで同じことをやろうとすると、結構ごちゃごちゃしがち。

要するに、銀の弾丸はないってこと。パフォーマンスは大事だけど、それは保守性、開発速度、コードの可読性といった、他の要素とのトレードオフなんだよね。自分のプロジェクトの特性を理解して、どこにコストを払うか決めるのが、アーキテクトの仕事なんだと思う。

結局、コードの数行をいじるより、全体の設計図を見直す方が、よっぽど大きなインパクトがある。そして、その設計図に唯一絶対の正解はない。…うん、それが一番言いたかったことかな。

皆さんのプロジェクトでは、どのアーキテクチャのボトルネックに一番悩まされていますか?クリーンアーキテクチャのレイヤーマッピング?それともサービスのライフタイム管理?もしよかったら、コメントで教えてください。

Related to this topic:

Comments

  1. Guest 2025-08-01 Reply
    へえ、パフォーマンス最適化って奥が深いですよね。特にSignalRとか興味あるんですが、実際の現場ではどんな工夫してるんでしょう?リアルタイム通信って難しそう…
  2. Guest 2025-06-06 Reply
    パフォーマンス最適化って本当に奥が深いよね。グローバルな視点から見ると、アーキテクチャの設計次第で劇的に変わるって感じ。実践的なアプローチが大切だと思う!