N+1問題は「JOINすれば解決」ではない
まず、Spring BootやJPAを学び始めると、必ずと言っていいほど出てくるのがN+1問題です。
最初は「関連テーブルをJOINすればいいのでは?」と思いやすいです。しかし、JPAでは通常のJOINとJOIN FETCHの意味が違います。そのため、JOINを書いているのに追加SQLが大量に出ることがあります。
そこでこの記事では、Spring Boot・JPA初心者向けに、N+1問題の仕組み、Lazy Loading、JOIN FETCH、EntityGraph、DTO取得、MyBatisとの違いを実務目線で整理します。
この記事でわかること
- N+1問題とは何か
- なぜJPAやHibernateでN+1問題が起きるのか
- JOINとJOIN FETCHの違い
- EntityGraphとDTO取得の使い分け
- MyBatisでもN+1問題が起きる理由
N+1問題とは何か
まず、N+1問題とは、最初に親データを1回取得したあと、関連データを取得するSQLがデータ件数分だけ追加で実行されてしまう問題です。
例えば、社員一覧画面で社員名と部署名を表示するとします。この場合、社員が3人なら、まず社員一覧を取得するSQLが1回実行されます。
SELECT *
FROM employee;
次に、画面表示のために部署名へアクセスすると、社員ごとに部署取得SQLが追加で実行されることがあります。
SELECT *
FROM department
WHERE id = 1;
SELECT *
FROM department
WHERE id = 2;
SELECT *
FROM department
WHERE id = 3;
| 社員数 | 社員取得SQL | 部署取得SQL | 合計 |
|---|---|---|---|
| 3人 | 1回 | 3回 | 4回 |
| 10人 | 1回 | 10回 | 11回 |
| 100人 | 1回 | 100回 | 101回 |
| 1000人 | 1回 | 1000回 | 1001回 |
つまり、N+1の「1」は親データ取得の1回、「N」は親データの件数分だけ発行される関連データ取得SQLを表します。件数が少ないうちは気づきにくいものの、データが増えると画面表示やAPIレスポンスが急に遅くなります。
N+1問題が起きる原因はLazy Loadingにある
次に、N+1問題の主な原因であるLazy Loadingを見ていきます。つまり、Lazy Loadingとは、関連データを最初から取得せず、必要になったタイミングで取得する仕組みです。
@Entity
public class Employee {
@Id
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
}
例えば、このEntityで社員一覧を取得すると、基本的にはemployeeテーブルへのSQLだけが実行されます。
List<Employee> employees = employeeRepository.findAll();
一方で、その後に関連Entityへアクセスすると、departmentを取得するSQLが追加で発行されます。
for (Employee employee : employees) {
String departmentName = employee.getDepartment().getName();
}
N+1が発生する流れ
社員一覧を取得 ↓ Employeeは取得済み ↓ Departmentはまだ取得されていない ↓ getDepartment().getName()を呼ぶ ↓ 社員の件数分だけ部署取得SQLが出る
なお、Lazy Loading自体が悪いわけではありません。例えば、詳細画面のように必要なときだけ関連データを取得したい場面では有効です。問題は、一覧画面や検索結果のような大量データを扱う場面で、意図せず関連Entityへアクセスしてしまうことです。
通常のJOINだけではN+1問題が解決しないことがある
ここで、SQLだけで考えると、JOINすれば社員名と部署名をまとめて取得できます。
SELECT
e.name,
d.name
FROM employee e
JOIN department d
ON e.department_id = d.id;
一方で、JPAのJPQLで通常のJOINを書いた場合、それは主に検索条件や絞り込みのためのJOINです。関連EntityをJPAの管理対象として初期化する、という意味にはならない場合があります。
@Query("""
SELECT e
FROM Employee e
JOIN e.department d
WHERE d.name = :departmentName
""")
List<Employee> findByDepartmentName(String departmentName);
重要な違い
通常のJOIN ↓ 検索条件や絞り込みのために使う JOIN FETCH ↓ 関連Entityも一緒に取得するために使う
つまり、N+1問題を避けるには、単にJOINを書くのではなく、「関連Entityを最初から取得する」と明示する必要があります。
JOIN FETCHは関連Entityを最初から取得する方法
次に、JOIN FETCHは、JPAに対して「Employeeだけでなく、Departmentも最初から取得しておいてください」と伝える書き方です。
@Query("""
SELECT e
FROM Employee e
JOIN FETCH e.department
""")
List<Employee> findAllWithDepartment();
| 書き方 | 目的 | 関連Entityの取得 | N+1対策 |
|---|---|---|---|
| JOIN | 検索条件や絞り込み | 取得されるとは限らない | 不十分な場合がある |
| JOIN FETCH | 関連Entityを一緒に取得 | 取得される | 有効 |
例えば、社員一覧の各行で部署名を表示することが最初から決まっているなら、JOIN FETCHを使うことで追加SQLを避けやすくなります。
ただし、JOIN FETCHを何でも使えばよいわけではありません。特にOneToManyのようなコレクションをJOIN FETCHすると、親データが重複した行として返りやすくなります。そのうえ、ページングとコレクションのJOIN FETCHは相性が悪いケースがあります。
| 注意点 | 内容 |
|---|---|
| 取得データが増える | 不要な関連Entityまで取得すると重くなる |
| OneToManyに注意 | 親データが重複して見えることがある |
| ページングに注意 | コレクションfetch joinとページングは設計を分けた方がよい場合がある |
| 複雑なクエリに注意 | JOINが増えるとSQLとJPQLの見通しが悪くなる |
なお、JOIN FETCHの考え方は、Hibernateの公式ドキュメントでも関連データ取得の重要な選択肢として扱われています。より詳しく確認したい場合は、Hibernate ORMの公式ドキュメントも参考になります。
EntityGraphは取得対象をアノテーションで指定する方法
また、EntityGraphも、関連Entityを最初から取得するための仕組みです。JOIN FETCHはJPQLに書きますが、EntityGraphはRepositoryメソッドにアノテーションで指定します。
@EntityGraph(attributePaths = "department")
List<Employee> findAll();
例えば、Spring Data JPAの`@EntityGraph`は、Repositoryメソッドごとに取得したい関連を指定できるため、シンプルな関連取得では読みやすい選択肢になります。公式APIにも、RepositoryメソッドでJPAのEntityGraphを設定するためのアノテーションとして説明されています。
なお、詳細はSpring Data JPAのEntityGraph公式APIで確認できます。
| 比較項目 | JOIN FETCH | EntityGraph |
|---|---|---|
| 書き方 | JPQLに書く | アノテーションで指定する |
| 向いている場面 | 条件が複雑な取得 | シンプルな関連取得 |
| 可読性 | クエリが長くなることがある | 取得対象が見えやすい |
| 柔軟性 | 高い | 中程度 |
| N+1対策 | 有効 | 有効 |
そのため、実務では、検索条件やJOIN条件を細かく制御したいならJOIN FETCH、既存のRepositoryメソッドに関連取得だけ足したいならEntityGraph、という使い分けがしやすいです。
DTO取得は一覧画面やAPIレスポンスで使いやすい
さらに、DTO取得とは、Entity全体を取得するのではなく、画面やAPIに必要な項目だけをDTOとして受け取る方法です。そのため、一覧画面や検索APIでは特に使いやすい方法です。
例えば、社員一覧で必要なのが「社員名」と「部署名」だけなら、Employee EntityとDepartment Entityを丸ごと取得する必要はありません。
public record EmployeeListDto(
String employeeName,
String departmentName
) {}
@Query("""
SELECT new com.example.EmployeeListDto(
e.name,
d.name
)
FROM Employee e
JOIN e.department d
""")
List<EmployeeListDto> findEmployeeList();
ここで大事なのは、DTO取得でもSQLは発行されるという点です。つまり、違いはSQLを発行するかどうかではありません。取得結果からEntityを作るのか、DTOを作るのかが違います。
| 取得方法 | 作られるもの | 向いている用途 |
|---|---|---|
| Entity取得 | Employee、Department | 更新処理、ドメインロジック |
| DTO取得 | EmployeeListDto | 一覧画面、検索結果、APIレスポンス |
そのため、一覧画面や検索APIではDTO取得がよく使われます。必要なカラムだけを返せるため、取得データの意図が明確になり、意図しないLazy Loadingも避けやすくなります。
MyBatisでもN+1問題は起きる
なお、N+1問題はJPAだけの問題ではありません。MyBatisでも、ループの中でSQLを呼び出す設計になっていると同じように発生します。
List<Employee> employees = employeeMapper.findAll();
for (Employee employee : employees) {
Department department =
departmentMapper.findById(employee.getDepartmentId());
}
| 技術 | N+1の発生原因 | 特徴 |
|---|---|---|
| JPA | Lazy Loadingで追加SQLが出る | 気づかないうちに起きやすい |
| MyBatis | ループ内SQLやnested select | 自分で書いた取得設計によって起きる |
実際に、MyBatisの公式ドキュメントでも、nested selectにはN+1 Selects問題があることが説明されています。MyBatisでは、JOINして1回で取得する、ID一覧をIN句でまとめて取得する、nested resultsを使うなど、SQL設計側で対策します。詳細はMyBatis Mapper XML Filesの公式ドキュメントが参考になります。
実務での使い分け
そのため、N+1問題を防ぐには、取得方法を場面ごとに選ぶことが重要です。特に一覧画面と更新処理では、選ぶべき方法が変わります。
| 用途 | よく使う方法 | 理由 |
|---|---|---|
| 一覧画面 | DTO取得 | 表示に必要な項目だけでよい |
| 検索結果 | DTO取得 | 大量データになりやすいため軽くしたい |
| APIレスポンス | DTO取得 | 返却項目を明確にできる |
| 詳細画面 | JOIN FETCH / EntityGraph | 関連データも表示することが多い |
| 更新処理 | Entity取得 | 変更検知やドメインロジックを使うため |
| MyBatisの一覧取得 | JOIN / IN句で一括取得 | ループ内SQLを避けるため |
例えば、社員情報を更新する処理ではEntity取得が向いています。なぜなら、JPAの変更検知やドメインロジックを使えるからです。一方で、社員一覧APIで社員名、部署名、ステータスだけ返すならDTO取得の方が向いています。つまり、同じ社員データでも用途によって取得方法は変わります。
N+1問題を見つけるチェックリスト
実際には、コードだけを見てもN+1問題に気づけないことがあります。そのため、SQLログや実行回数も確認します。
| 危険サイン | 起きている可能性 | 要注意度 |
|---|---|---|
| 一覧表示で関連Entityへアクセスしている | 関連取得SQLが件数分出ている | 高 |
| for文の中でRepositoryやMapperを呼んでいる | ループ内SQLになっている | 高 |
| SQLログに似たSQLが大量に出ている | N+1問題が発生している | 高 |
| JOINを書いたのに遅い | JOIN FETCHになっていない | 中 |
| 一覧APIでEntityをそのまま返している | 不要な関連データに触れている | 中 |
| データ件数が増えると急に遅くなる | N+1の影響が表面化している | 高 |
まずはSQLログを見て、「同じようなSELECTが何度も出ていないか」を確認します。そのうえで、JOIN FETCH、EntityGraph、DTO取得、一括取得のどれが適切かを判断します。
よくある質問
N+1問題はFetchType.EAGERにすれば解決しますか?
結論から言うと、基本的にはおすすめしません。EAGERにすると常に関連データを取得しやすくなるため、不要な場面でも重いSQLになることがあります。取得方法はEntity定義で固定するより、クエリやRepositoryメソッドごとに制御する方が実務では扱いやすいです。
JOIN FETCHとEntityGraphはどちらを使うべきですか?
例えば、複雑な条件をJPQLで明示したいならJOIN FETCH、シンプルに関連取得を追加したいならEntityGraphが使いやすいです。ただし、どちらも万能ではありません。OneToManyの取得やページングが絡む場合は、DTO取得や2段階取得も検討します。
DTO取得ならN+1問題は絶対に起きませんか?
一方で、DTO取得はN+1問題を避けやすい方法ですが、絶対ではありません。DTOを作ったあとに別のRepositoryやMapperをループで呼び出せば、同じようにN+1問題は起きます。重要なのは、必要なデータをどのSQLで取得するかを最初に設計することです。
MyBatisではJOINを書けば十分ですか?
多くの場合、JOINやIN句による一括取得が有効です。ただし、OneToManyのように行数が増えやすい取得では、結果の重複やページングも考える必要があります。なお、MyBatisでも、画面に必要な項目をDTOとして返す設計が実務では扱いやすいです。
まとめ:N+1問題はデータ取得設計の問題
最後に整理すると、N+1問題とは、親データを1回取得したあと、関連データ取得SQLが件数分だけ追加で発行されてしまう問題です。
具体的には、JOIN FETCH、EntityGraph、DTO取得、IN句による一括取得などがあります。ただし、本当に重要なのは書き方を暗記することではありません。むしろ、画面や処理に必要なデータを先に決めることが大切です。
N+1問題の本質
SQLの書き方だけの問題 ではなく 必要なデータを どの形で取得するかという データ取得設計の問題
一覧画面ならDTO取得、詳細画面ならJOIN FETCHやEntityGraph、更新処理ならEntity取得。このように用途ごとに取得方法を選ぶことで、N+1問題を防ぎやすくなります。
つまり、Spring BootやJPAを実務で使うなら、「Entityをどう書くか」だけでなく、「画面やAPIに必要なデータをどう取得するか」まで考えられることが重要です。
一度カジュアル面談をしませんか?
株式会社bluenaは「高還元」と「伴走支援」を両立したSES企業です。単価の81〜86%を還元する報酬体系と、専任サポーターによる隔週1on1で、エンジニアが納得できるキャリアを実現します。
Spring Boot、JPA、SQL設計など、バックエンド実務で伸ばしたいテーマがある方も、まずは現在地を聞かせてください。
カジュアル面談ですので、お気軽にお聞かせください。





