メインコンテンツまでスキップ

ORM Basics & N+1 Problem

ORM (Object-Relational Mapping) とは

ORM(Object-Relational Mapping)は、オブジェクト指向プログラミング言語のオブジェクトと、リレーショナルデータベースのレコードを対応づける(マッピングする)技術です。

メリット

  • 生産性の向上: SQLを直接記述せずに、プログラミング言語の構文でデータベース操作が可能。
  • 保守性の向上: データベースの変更(カラム追加など)がコード側のモデル変更で吸収しやすい。
  • セキュリティ: パラメータ化クエリが自動的に使用されるため、SQLインジェクションのリスクが低減する。

デメリット

  • パフォーマンス: 複雑なクエリの場合、手書きのSQLよりも非効率なSQLが生成されることがある。
  • 学習コスト: ORM特有の挙動やAPIを理解する必要がある。

.NETにおける主要なORM

Entity Framework Core (EF Core)

Microsoftが提供する公式のORMです。多機能で、LINQを使用して直感的にクエリを記述できます。

  • 特徴: Change Tracking(変更追跡)、Migration(スキーマ管理)、Lazy Loading(遅延読み込み)などの豊富な機能。
  • 用途: 一般的な業務アプリケーション、CRUD操作が中心のシステム。

EF CoreのChange Tracking(変更追跡)

EF Coreの最も重要な機能の一つが Change Tracking(変更追跡) です。この機能により、エンティティの状態変化を自動的に追跡し、SaveChanges() を呼び出すだけで適切なSQL(INSERT/UPDATE/DELETE)を自動生成・実行できます。

エンティティの状態

EF Coreは各エンティティを以下の状態で管理します:

状態説明SaveChanges()時の動作
Added新規追加されたエンティティINSERT文が実行される
Unchangedデータベースから取得後、変更されていない何も実行されない
Modifiedデータベースから取得後、プロパティが変更されたUPDATE文が実行される
Deleted削除対象としてマークされたDELETE文が実行される
DetachedDbContextによって追跡されていない何も実行されない
動作例
using var context = new ApplicationDbContext();

// 1. データベースから取得(状態: Unchanged)
var user = await context.Users.FindAsync(1);
Console.WriteLine(context.Entry(user).State); // Unchanged

// 2. プロパティを変更(状態: Modified)
user.Name = "新しい名前";
Console.WriteLine(context.Entry(user).State); // Modified

// 3. SaveChanges()でUPDATE文が自動生成・実行される
await context.SaveChangesAsync();
// SQL: UPDATE Users SET Name = '新しい名前' WHERE Id = 1
Change Trackingのメリット
  1. コードの簡潔性: 手動でUPDATE文を書く必要がない
  2. 部分更新の最適化: 変更されたプロパティのみがUPDATE文に含まれる
  3. トランザクション管理: 複数のエンティティ変更を1つのトランザクションで処理
パフォーマンスへの影響

Change Trackingは便利ですが、オーバーヘッドが存在します。特に以下の場合は注意が必要です:

  • 大量データの読み取り専用クエリ: 変更追跡が不要な場合
  • パフォーマンスが重要なバッチ処理

このような場合は AsNoTracking() を使用して変更追跡を無効化します:

// 変更追跡を無効化(読み取り専用)
var users = await context.Users
.AsNoTracking() // Change Trackingを無効化
.ToListAsync();

// この場合、プロパティを変更してもSaveChanges()では保存されない
users[0].Name = "変更";
await context.SaveChangesAsync(); // 何も実行されない

AsNoTracking() を使用すべき場面:

  • 読み取り専用のクエリ
  • APIのGETエンドポイントでのデータ取得
  • レポート生成や集計処理
  • 大量データの一括読み込み

パフォーマンス改善効果:

  • メモリ使用量の削減(追跡情報を保持しない)
  • クエリ実行速度の向上(約10-30%高速化)

Dapper

Stack Overflowが開発したマイクロORMです。軽量で高速な動作が特徴です。

  • 特徴: 生のSQLを記述し、結果をオブジェクトにマッピングすることに特化している。EF Coreよりも高速。
  • 用途: パフォーマンスが重要なバッチ処理、複雑な検索クエリが必要な箇所。
  • 保守性の懸念: 生のSQLを文字列として記述するため、以下の問題が発生しやすい:
    • コンパイル時のチェックが効かず、実行時にエラーが発覚する
    • テーブル名やカラム名の変更時に、すべてのSQL文字列を手動で修正する必要がある
    • リファクタリングツールが使えず、変更漏れが発生しやすい
    • SQLインジェクションのリスクが、EF Coreと比較してやや高まる(適切なパラメータ化を開発者が意識する必要がある)

EF CoreのRaw SQL機能との比較

EF Coreにも FromSqlExecuteSql を使用して生のSQLを実行する機能があります。「Dapperを導入せずにEF CoreのRaw SQL機能を使えば良いのでは?」という疑問に対する比較は以下の通りです。

特徴DapperEF Core (Raw SQL)
パフォーマンス非常に高速。オーバーヘッドが最小限。Dapperよりわずかに遅い(コンテキストのオーバーヘッドがある)。AsNoTracking()で改善可能。
マッピング柔軟。任意のクラス、構造体、プリミティブ型へ簡単にマッピング可能。Multi Mappingも強力。基本的にエンティティ型へのマッピングが主。EF Core 8以降で非エンティティ型へのマッピングも強化されたが、Dapperほど柔軟ではない場合がある。
LINQとの組み合わせ不可。結果は IEnumerable<T> として返るため、その後の処理はメモリ上で行われる。可能FromSql の結果に対してLINQで WhereOrderBy を追加すると、DB側で実行されるSQLに組み込まれる(Composability)。
依存関係追加のNuGetパッケージが必要。EF Coreに含まれている。

結論:

  • 既存のEF Coreプロジェクトで、少しだけ複雑なSQLを書きたい場合は EF CoreのRaw SQL で十分です。
  • パフォーマンスが最優先されるバッチ処理や、エンティティとは無関係な複雑な集計結果(DTO)へのマッピングが多発する場合は Dapper が適しています。

EF CoreとDapperの使い分けのベストプラクティス

実際のプロジェクトでは、EF CoreとDapperを併用することで、それぞれの強みを活かすことができます。

推奨される使い分け

EF Coreを使用する場面:

  • CUD操作(Create, Update, Delete): Change Trackingによる変更追跡とトランザクション管理が優れている
  • ビジネスロジックが複雑なドメイン操作: モデルの整合性を保ちやすい
  • 単純なCRUD操作: 生産性が高く、保守性に優れる

Dapperを使用する場面:

  • Read操作(Query)のみ: 特に大量データの検索や集計処理
  • 複雑な結合や集計を伴うレポート生成
  • パフォーマンスが重要な読み取り専用のクエリ

実装例

public class UserService
{
private readonly ApplicationDbContext _context;
private readonly IDbConnection _connection;

public UserService(ApplicationDbContext context, IDbConnection connection)
{
_context = context;
_connection = connection;
}

// EF Core: CUD操作
public async Task<User> CreateUserAsync(User user)
{
_context.Users.Add(user);
await _context.SaveChangesAsync();
return user;
}

public async Task UpdateUserAsync(User user)
{
_context.Users.Update(user);
await _context.SaveChangesAsync();
}

// Dapper: 複雑な検索クエリ
public async Task<IEnumerable<UserStatistics>> GetUserStatisticsAsync()
{
var sql = @"
SELECT
u.Id,
u.Name,
COUNT(p.Id) AS PostCount,
AVG(p.ViewCount) AS AvgViews
FROM Users u
LEFT JOIN Posts p ON u.Id = p.UserId
GROUP BY u.Id, u.Name
HAVING COUNT(p.Id) > 10
ORDER BY PostCount DESC";

return await _connection.QueryAsync<UserStatistics>(sql);
}
}

テスト戦略

Unit Test:

  • EF CoreのCUD操作は、InMemoryデータベースまたはSQLiteインメモリDBを使用してテスト
  • Dapperのクエリ部分は、モック化したIDbConnectionを使用してテスト(必要に応じて)
// EF Core Unit Test例
public class UserServiceTests
{
[Fact]
public async Task CreateUser_ShouldAddUserToDatabase()
{
// Arrange
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: "TestDb")
.Options;

using var context = new ApplicationDbContext(options);
var service = new UserService(context, null);
var user = new User { Name = "Test User" };

// Act
await service.CreateUserAsync(user);

// Assert
Assert.Equal(1, await context.Users.CountAsync());
}
}

Integration Test:

  • 実際のデータベース(TestContainersやDocker上のSQL Server)を使用
  • EF CoreとDapperの両方が実際のDBに対して正しく動作することを確認
  • トランザクションの整合性やパフォーマンスを検証
// Integration Test例
public class UserServiceIntegrationTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;

public UserServiceIntegrationTests(DatabaseFixture fixture)
{
_fixture = fixture;
}

[Fact]
public async Task GetUserStatistics_ShouldReturnCorrectData()
{
// Arrange
var context = _fixture.CreateContext();
var connection = _fixture.CreateConnection();
var service = new UserService(context, connection);

// Seed test data with EF Core
var user = new User { Name = "Test User" };
await service.CreateUserAsync(user);
for (int i = 0; i < 15; i++)
{
context.Posts.Add(new Post { UserId = user.Id, ViewCount = i * 100 });
}
await context.SaveChangesAsync();

// Act
var statistics = await service.GetUserStatisticsAsync();

// Assert
var stat = statistics.First();
Assert.Equal(15, stat.PostCount);
Assert.Equal(700, stat.AvgViews); // (0+100+200+...+1400)/15
}
}

推奨事項:

  • Dapperは読み取り専用のクエリに限定することで、保守性のリスクを最小化
  • CUD操作はEF Coreに統一することで、Change TrackingとMigrationの恩恵を受ける
  • Integration Testで両方の動作を検証し、データの整合性を保証する

N+1問題

ORMを使用する際、特に注意が必要なのが「N+1問題」です。これは、データ取得の際に大量のSQLが発行されてしまい、パフォーマンスが著しく低下する現象です。

1回のクエリで済むはずの処理が、関連データを取得するために追加でN回のクエリを発行してしまうことから「N+1問題」と呼ばれます。

具体例

ユーザー(User)と、そのユーザーが投稿した記事(Post)という1対多のリレーションを持つテーブルを例に考えます。

シナリオ

「全ユーザーの一覧を表示し、それぞれのユーザーが投稿した記事のタイトルも一緒に表示したい」という要件があるとします。

問題のあるコード(C# / Entity Framework Core)

以下のようなコードを書くと、N+1問題が発生します。

// 1. 全ユーザーを取得 (1回)
var users = context.Users.ToList();

foreach (var user in users)
{
// 2. 各ユーザーの投稿を取得 (ユーザー数 N 回)
// ここで毎回SQLが発行される(遅延ロード)
foreach (var post in user.Posts)
{
Console.WriteLine($"{user.Name}: {post.Title}");
}
}

発行されるSQL

この時、データベースに対して以下のようなSQLが発行されます。

  1. ユーザー一覧の取得(1回)
SELECT * FROM Users;
  1. 各ユーザーの投稿の取得(ユーザー数 N 回)
-- ユーザーAの投稿
SELECT * FROM Posts WHERE UserId = 1;
-- ユーザーBの投稿
SELECT * FROM Posts WHERE UserId = 2;
-- ... ユーザーNまで続く

ユーザーが100人いれば、合計で 1 + 100 = 101回 のSQLが実行されます。

対策

この問題を解決するには、Eager Loading(熱心な読み込み) を使用して、最初のクエリで関連データもまとめて取得します。

改善後のコード(C# / Entity Framework Core)

Include メソッドを使用することで、JOIN句を用いた1つのSQLでデータを取得できます。

// UsersとPostsをまとめて取得 (1回)
var users = context.Users
.Include(u => u.Posts) // 関連データを即時ロード
.ToList();

foreach (var user in users)
{
// メモリ上にロード済みなのでSQLは発行されない
foreach (var post in user.Posts)
{
Console.WriteLine($"{user.Name}: {post.Title}");
}
}

発行されるSQL(改善後)

SELECT u.*, p.*
FROM Users u
LEFT JOIN Posts p ON u.Id = p.UserId;

このように、SQLの発行回数を1回(または最適化された少数のクエリ)に抑えることができ、パフォーマンスが大幅に向上します。

まとめ

  • 原因: ループ内で関連テーブルへのアクセスを行うことで、都度SQLが発行される(遅延ロード)。
  • 影響: データ量が増えるにつれてSQL発行回数が線形に増加し、アプリケーションの応答速度が低下する。
  • 対策: O/Rマッパーの機能(Include, Join, Fetchなど)を使い、必要なデータを事前にまとめて取得する(Eager Loading)。

コラム: ORMはコンピュータサイエンスのベトナム戦争?

Jeff Atwood(Stack Overflowの共同創設者)は、自身のブログ記事「Object-Relational Mapping is the Vietnam of Computer Science」において、ORMを「コンピュータサイエンスのベトナム戦争」と表現しました。

これは、Ted Newardの言葉を引用したもので、ORMが抱える「インピーダンスミスマッチ(Impedance Mismatch)」の根深さを指摘しています。

インピーダンスミスマッチとは

オブジェクト指向言語(グラフ構造、継承、同一性)とリレーショナルデータベース(集合論、正規化、外部キー)は、根本的に異なる原理で動作しています。ORMはこの溝を埋めようとしますが、完全な抽象化は不可能であり、しばしば「漏れのある抽象化(Leaky Abstractions)」となります。

解決策のトレードオフ

記事では、この問題に対するいくつかのアプローチと、それぞれの痛みを伴うトレードオフが紹介されています:

  1. オブジェクトを捨てる: リレーショナルモデルに特化した手続き型コードを書く。
  2. リレーショナルDBを捨てる: オブジェクトデータベース(現代ならNoSQLなど)を使用する。
  3. 手動マッピング: ORMを使わず、SQLを書いて手動でマッピングする(Dapperのアプローチに近い)。
  4. ORMの限界を受け入れる: 80%はORMで楽をして、残り20%の複雑な部分は生のSQLやストアドプロシージャで解決する。

現代の.NET開発において、EF Core(80%)とDapper(20%)を併用するというベストプラクティスは、まさにこの「4. ORMの限界を受け入れる」という現実的な解に相当します。