N+1問題 Spring Boot JPAの原因と対策

Spring Boot JPAのN+1問題とJOIN FETCH、EntityGraph、DTO取得の違い

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 FETCHEntityGraph
書き方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の発生原因特徴
JPALazy 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に必要なデータをどう取得するか」まで考えられることが重要です。

IaC INP PM PMO PMP UX Webディレクター インフラエンジニア キャリアチェンジ フロントエンドエンジニア