最近、.NETのプロジェクトでよく思うんだけど…僕らって、すごく便利なライブラリに頼りがちじゃない? AutoMapperとか、MediatR、それにEntity Framework Core。もう、ほとんどのプロジェクトに入ってる定番のやつら。開発は楽になるし、コードも綺麗になる。それは間違いない。
でもね、その「便利さ」には、実は隠れたコストがあるんだ。多くの開発者が、本番環境でパフォーマンスの問題にぶち当たるまで、そのことに気づかない。うん、僕もそうだった。正直、痛い目を見たこともある。
先說結論
便利なライブラリは、何も考えずに使うと、アプリケーションのパフォーマンスを静かに蝕んでいく可能性がある。だから、「なぜ、どこで、何を」使うのか、ちゃんと自分の頭で考える必要があるってこと。今回はその話を、ちょっとだけ深掘りしてみようかな。
なんで人気ライブラリが「地雷」になるの?
そもそも、人気なのには理由があるんだよね。よくある問題を解決してくれるし、お決まりのコードを書かなくて済むし、メンテナンス性も上がる。最高じゃないか、って思う。
でも、その人気が逆に「思考停止」を生むことがある。「みんな使ってるから大丈夫だろう」みたいな。正直、これが一番危ない。ああいうライブラリって、生のパフォーマンスよりも、柔軟性とか使いやすさを優先して作られてることが多いから。
ほとんどのアプリではそれでも問題ない。でも、パフォーマンスが本当に重要な場面とか、ライブラリが想定してないような使い方をしたときに、問題が顔を出すんだ。
具体例1:AutoMapper、便利だけど…重いよね
パフォーマンスの話で、たぶん一番やり玉にあげられるのがAutoMapper。オブジェクトからオブジェクトへの面倒なマッピングを一行で書いてくれる、あの魔法みたいなやつ。
でも、その魔法の裏側では、結構ヘビーな処理が動いてる。主な原因はこんな感じ。
- リフレクションの多用: 実行時に「えーっと、このプロパティとこのプロパティは…」って感じでプロパティを探してる。そりゃあ、コンパイル時に解決されてるコードより遅いよね。
- 式のコンパイル: 複雑なマッピングルールを作ると、それを実行するために内部で式をコンパイルしたりする。これも初回実行時とかに結構コストがかかる。
- メモリ確保: マッピングの過程で、どうしても新しいオブジェクトが内部でたくさん作られる。GC(ガベージコレクション)の負担が増えるってこと。
試しに、BenchmarkDotNetで簡単なベンチマークを走らせてみると、その差は歴然だよ。
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net80)]
public class MappingBenchmark
{
private readonly Mapper _mapper;
private readonly UserDto _userDto;
public MappingBenchmark()
{
var config = new MapperConfiguration(cfg =>
cfg.CreateMap<UserDto, User>());
_mapper = new Mapper(config);
_userDto = new UserDto { Id = 1, Name = "John Doe", Email = "john@example.com" };
}
[Benchmark]
public User AutoMapperMapping() => _mapper.Map<User>(_userDto);
[Benchmark]
public User ManualMapping() => new User
{
Id = _userDto.Id,
Name = _userDto.Name,
Email = _userDto.Email
};
}
// User と UserDto は同じプロパティを持つシンプルなクラス
public class User { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } }
public class UserDto { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } }
結果を見ると、ほんの数個のプロパティをマッピングするだけでも、手動マッピングはAutoMapperより5倍から10倍くらい速い。メモリ確保量も全然違う。APIのエンドポイントみたいに、一秒間に何千回も呼ばれるような場所でこの差は…うん、無視できないよね。
具体例2:MediatR、キレイなコードの裏側
MediatRも、CQRSパターンを実装するのにもはや定番だよね。リクエストとハンドラを分離して、コードがすごくクリーンになる。僕も好きでよく使う。
ただ、これも「タダ」じゃない。リクエストを処理するために、MediatRは内部でいくつかのステップを踏む。
- リクエストが送信される。
- DIコンテナ(サービスロケーター)を使って、対応するハンドラを探す。
- 見つけたハンドラを呼び出す。
この「ハンドラを探して呼び出す」っていうワンクッションが、パフォーマンスのオーバーヘッドになる。直接サービスクラスのメソッドを呼び出すのに比べたら、やっぱり遅くなる。
これもベンチマークを取ると、だいたい2倍から4倍くらいの差が出ることが多いかな。ほとんどのケースでは問題にならない程度の差だけど、これもAutoMapperと同じで、ホットパス(頻繁に呼ばれる処理)では真剣に考える必要がある。
具体例3:Entity Framework Core、一番よく踏むワナ
EF Coreは、昔に比べたらめちゃくちゃ速くなった。本当に。でも、やっぱりORM(Object-Relational Mapper)である以上、パフォーマンスの罠はたくさんある。
一番よくあるのが、たぶん「追跡(Tracking)」のオーバーヘッド。EF Coreは、DBから取得したエンティティをデフォルトで「追跡」してるんだ。つまり、そのオブジェクトのプロパティが変更されたかどうかをずっと監視してる。後で `SaveChanges()` を呼んだときに、変更を検知してUPDATE文を生成するためだね。
でも、データをただ表示するだけの読み取り専用のクエリで、この追跡機能は完全に無駄。コストがかかるだけ。`AsNoTracking()` をつけるだけで、パフォーマンスが劇的に改善することがあるのは、このため。
[MemoryDiagnoser]
public class EFBenchmark
{
private readonly AppDbContext _context;
// Dapper用の設定は省略
[Benchmark]
public async Task<List<User>> EFQuery_WithTracking()
{
// これがデフォルトの挙動
return await _context.Users
.Where(u => u.IsActive)
.ToListAsync();
}
[Benchmark]
public async Task<List<User>> EFQuery_NoTracking()
{
// AsNoTracking() をつけるだけで変わる
return await _context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.ToListAsync();
}
[Benchmark]
public async Task<List<User>> DapperQuery()
{
// 参考:軽量なDapperの場合
return (await _dapperConnection.QueryAsync<User>(
"SELECT * FROM Users WHERE IsActive = 1")).ToList();
}
}
他にも、N+1問題とか、複雑すぎるLINQが非効率なSQLに変換されちゃうとか、EF Coreには注意点がたくさんある。Qiitaとか見てても、やっぱりEF Coreのパフォーマンスで悩んでる人、多いよね。便利なんだけど、内部で何が起こってるかを知らないと、本当に足をすくわれる。
じゃあ、どうやって選べばいいの?
ここまで「危ないよ」って話ばっかりしてきたけど、じゃあどうすればいいのか。個人的には、ライブラリの特性を理解して、適材適所で使い分けるしかないと思ってる。その判断材料として、簡単な比較表を作ってみた。
| ライブラリ | 良いところ(こういう時に使う) | 注意点(こういう時は疑う) | 代替案とか |
|---|---|---|---|
| AutoMapper | 開発スピードが最優先の画面。例えば、管理画面のCRUDとか。プロパティ数が多くて手で書くのが馬鹿らしい時。 | APIのエンドポイントとか、リクエスト数が多いところ。正直、ちょっとしたマッピングでも結構メモリ食う。気づいたら遅くなってるパターン。 | 手動マッピングが一番速い。あとはMapsterみたいな、より高速な代替ライブラリや、Source Generatorを使う方法もあるね。 |
| MediatR | アーキテクチャを綺麗に保ちたい時。関心の分離ができて、テストもしやすくなる。チーム開発でコードの書き方を統一したい時とかは、すごくいい。 | 単純なメソッド呼び出しで済むような簡単な処理。マイクロサービスで、1ミリ秒でも速くしたい時。オーバーヘッドが気になるなら、採用は慎重に。 | 普通にサービスクラスをDIして直接メソッドを呼ぶ。ASP.NET CoreのMinimal APIなら、エンドポイントハンドラで十分かも。 |
| Entity Framework Core | ほとんどのCRUD操作。LINQで直感的にクエリが書けるのは、やっぱり生産性が高い。簡単なアプリなら、もうこれで十分。 | 読み取り専用の大量データ取得。`AsNoTracking()` を忘れると痛い目見る。あとは、超複雑な集計クエリとか。生成されるSQLがヤバいことになりがち。 | 読み取り専用なら `AsNoTracking()` は必須。もっとパフォーマンスが必要なら、Dapperみたいな軽量ORMや、最終手段として生のADO.NETを使う。 |
結局、全部やめるべき?
いや、そういうことでもないんだよね。僕が言いたいのは「全部やめろ」じゃなくて、「思考停止で使うな」ってこと。大事なのは、ハイブリッドアプローチ、つまり使い分けだと思う。
例えば、こんな感じ。
- 管理画面みたいに、パフォーマンスより開発スピードが重要なところは、AutoMapperやEF Coreをフル活用してサクッと作る。
- 公開APIのエンドポイントみたいに、レスポンス速度が命のところは、マッピングは手で書いて、データアクセスはDapperや `AsNoTracking()` を使ったEF Coreにする。
- 複雑なビジネスロジックが絡むところだけMediatRを使って、それ以外の単純な処理は直接サービスを呼ぶ。
こんなふうに、アプリケーションの「どこ」がパフォーマンス的にクリティカルなのかをちゃんと見極めて、ツールを選ぶ。それが、たぶん一番現実的で、賢いやり方なんじゃないかな。
パフォーマンスチューニングって、沼にハマるとキリがない。でも、アプリケーションの「一番遅いところ」を一つ改善するだけで、ユーザー体験は劇的に良くなったりする。その「一番遅いところ」が、もしかしたら、あなたが何気なく使っている便利なライブラリかもしれない、っていう話でした。
…あなたのプロジェクトでは、どうしてる? パフォーマンスのために、あえて便利なライブラリの採用を見送った、みたいな経験があったら、ぜひ教えてほしいな。
