Spring Data JPAのN+1問題を起こさないために

カバー

はじめに

私のチームではJavaとSpring Bootを使用してWebアプリケーションのバックエンドAPIを構築しています。DBとのやり取りにはORMであるSpring Data JPAを使用しており、基本的には生のSQLをあまり使用せず、ORMに依存した開発を進めてきました。しかし、自動生成されるSQLでは意図通りとはいかないこともよく発生します。特に気を付けなければいけないのが「N+1問題」です。N+1問題ではデータ量が増えれば増えるほど影響が大きくなるため、早急な対処が必要になります。今回はそんなN+1問題を発生させないためにはどのように進めていけばいいか、その方法を紹介します。

N+1問題とは?

N+1問題とは、取得したい対象テーブルに加えて、関連するテーブルからの情報を取得するために追加のSQLが発行されてしまう状態を指します。
この追加SQLが大量になってしまうと当然DBへのアクセスも増え、レスポンスまでの時間が想定以上に長くなってしまいます。

N+1問題に関連するSQLクエリは、以下の2つに分類されます。

  1. N個の関連テーブルを持つ取得したいテーブルを呼び出すSQL(1個)
  2. 取得したいテーブルの関連テーブル(N個)を呼び出すSQL(N個)

つまり、関連テーブルを呼び出すSQLは1テーブルにつき1つのSQLを発行するため、関連テーブルがN個存在すれば発行されるSQLはN個になります。
そのため、全部で発行されるSQLは N + 1 になるというのがこの問題です。

N+1問題への対処

前提

ここからはN+1問題にどのように対処すればよいかを紹介します。
まず初めに、今回の説明に用いる構成を紹介しておきます。使う要素は最小限にし、会社(Company)とそこに勤める従業員(Employee)の2つを使って説明します。
Spring Data JPAではDBの情報と実装の中で使用するこれらの情報を紐づけるためにエンティティをそれぞれ作成します。

説明に必要な要素は、できるだけ最小限にしています。

Company.java

package jp.co.alpha.sample.model;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import lombok.AllArgsConstructor;
import lombok.Data;
@Entity
@Data
@AllArgsConstructor
public class Company {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
@OneToMany(mappedBy = "company")
private List<Employee> employee;
public Company() {
employee = new ArrayList<>();
}
}

要素は以下の3つです。

  1. id:会社固有のID
  2. name:会社の名称
  3. employee:会社に所属する従業員一覧

従業員(Employee)との関連付けでは、1つの会社に対して複数の社員が所属することが考えられるため、会社と従業員は「1対多」の関係になります。

Employee.java

package jp.co.alpha.sample.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.Data;
@Entity
@Data
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String name;
@ManyToOne
@JoinColumn(name = "company_id", insertable = false, updatable = false)
@JsonIgnore
private Company company;
}

要素は以下の3つです。

  1. id:従業員固有のID
  2. name:従業員の名前
  3. company:従業員の所属する会社
    • DB上ではCompanyテーブルのidをcompany_idと関連付けることで実現します。

今度は従業員側から会社(Company)との関連付けを行い、複数の従業員が1つの会社に所属することが考えられるため、従業員と会社の関係は「多対1」の関係になります。
今回の解説は上記2つのエンティティをベースにします。

仮データは以下のようなデータを予め登録しておきます。

Company

idname
1testCompanyA
2testCompanyB
3testCompanyC

Employee

idnamecompany_id
1アルファ一郎1
2アルファ二郎1
3アルファ三郎2
4アルファ四郎2

これから解説していくN+1問題への対処は、以下の3つの段階に分けて説明します。

  1. FetchType
    • エンティティの作成時に考慮すべき内容です
  2. JOIN FETCH
    • SQLを発行する際に考慮すべき内容です
  3. Jacksonのアノテーション
    • APIで値をシリアライズして返却するときに考慮すべき内容です

FetchType

まずはエンティティ作成時に考慮すべきFetchTypeについて解説します。
FetchTypeはエンティティのフェッチ(取得)を制御するための指定であり、どのように関連エンティティを取得するかを定義します。FetchTypeには2種類(EAGERLAZY)のオプションがあります。
N+1問題の具体例も交えて、まずはCompanyから見たEmployeeFetchTypeを2種類設定して、どのような違いが出るかを確認します。

FetchType=EAGER

Company.java

@OneToMany(mappedBy = "company", fetch = FetchType.EAGER)
private List<Employee> employee;

ここでは、Spring Data JPAのクエリ生成メソッドを利用してSQLを生成します。

Company company = companyRepository.findById(1).get();

次のSQLが発行されました。 (読みやすさのため、適宜改行しています)

select c1_0.id,c1_0.name,e1_0.company_id,e1_0.id,e1_0.name from company c1_0 left join employee e1_0 on c1_0.id=e1_0.company_id where c1_0.id=?

FetchType=LAZY

Company.java

@OneToMany(mappedBy = "company", fetch = FetchType.LAZY)
private List<Employee> employee;

次のSQLが発行されました。

select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?

2つのSQLを見比べると、FetchType=EAGERの場合は関連エンティティであるEmployeeも同時に取得されているのに対し、FetchType=LAZYの場合はCompanyのみが取得されていることが確認できます。
さらに追加でCompanyの社員1名の名前をログに表示するようにしてみます。

FetchType=EAGER

次のようなSQL発行とログ出力になりました。

select c1_0.id,c1_0.name,e1_0.company_id,e1_0.id,e1_0.name from company c1_0 left join employee e1_0 on c1_0.id=e1_0.company_id where c1_0.id=?
アルファ一郎

FetchType=LAZY

次のようなSQL発行とログ出力になりました。

Hibernate: select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?
Hibernate: select e1_0.company_id,e1_0.id,e1_0.name from employee e1_0 where e1_0.company_id=?
アルファ一郎

Employeeを確認する前はFetchType=EAGERによってのみEmployeeの情報が取得されていましたが、Employeeを参照しようとした際にFetchType=LAZYもSQLを追加で発行し、Employeeの情報を取得していることが確認できます。
N+1問題とは、今回の場合で言うとFetchType=LAZYによって発行された2つ目のEmployeeを取得するためのSQLが大量に生成されてしまうことを指します。

この比較の結果から、FetchType=EAGERは対象エンティティ取得時に関連エンティティも同じSQLで取得するのに対し、FetchType=LAZYは関連エンティティを該当エンティティの情報にアクセスした際に追加で取得するという違いが明確になります。

今回のFetchTypeの指定は、それぞれをエンティティに明記することで分かりやすくしていますが、FetchTypeには関連エンティティとの関係に基づくデフォルトがあります。これは前述の1対多や多対1などの設定に関連しています。
この設定はエンティティクラス間のリレーションを定義するもので、次の4つを使って表現できます。

  • OneToOne(1対1)
  • OneToMany(1対多)
  • ManyToOne(多対1)
  • ManyToMany(多対多)

それぞれでFetchTypeのデフォルトは次のようになります。

FetchTypeクラス間リレーション
EAGEROneToOne, ManyToOne
LAZYOneToMany, ManyToMany

エンティティの関係性に応じて設定を行いますが、今回はCompanyEmployeeの関係は「会社(Company)とそこに勤める従業員(Employee)」なため、Company側のエンティティではEmployeeに対してOneToManyとなり、Employee側のエンティティではCompanyに対してManyToOneと記述します。

先ほどのFetchTypeの表を参照すると、CompanyでのEmployeeの扱いはLAZYになります。では、先ほどの比較をもとにCompanyEmployeeFetchTypeEAGERに変更することが適切でしょうか?

その判断には、もう1つの重要な要素であるJPQL(Java Persistence Query Language)を考慮する必要があります。
JPQLはSQLに似ていますが、DBのテーブルやカラムではなく、エンティティやその属性に対して操作を行います。これにより、DBのリレーショナルモデルとJavaのオブジェクトモデルが直接結び付きます。書き方は普通のSQLとは異なり、エンティティを対象にしたクエリを記述します。
実際の運用では、生成クエリによって容易に作成できるSQL(例えば、findBy、readBy、queryBy、countBy、getBy)の他に、さまざまなメソッドが必要になります。その際にJPQLが使用されます。

JPQLはSQLとは異なると説明しましたが、基本的な書き方はほぼ同じですので、先ほどのfindById()と同様に、id=1のCompanyを取得するJPQLを作成してみます。
CompanyRepositoryで次のように記述します。

CompanyRepository.java

@Query("select a from Company a where id = :id")
Company findByCompanyId(int id);

取得されるのは、先に述べたtestCompanyAです。CompanyエンティティにあるEmployeeFetchTypeEAGERにしてこのメソッドを実行してみます。
実行後、次のSQLが発行されました。

select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?
select e1_0.company_id,e1_0.id,e1_0.name from employee e1_0 where e1_0.company_id=?

FetchTypeLAZYの時に発行されたSQLと同じです。この原因はJPQLがSQLと異なる実行モデルを持っているためです。JPQLを使用してクエリを記述する場合、Hibernate(または他のJPA実装)は、JPQLのクエリが実行された結果を取得し、その関連するエンティティ(EAGER Fetch)を読み込むために追加のSELECT文を発行します。そのため、FetchTypeEAGERに設定されている場合、関連するすべてのエンティティに対して追加のSQLが自動的に発行されます。
同じJPQLでFetchTypeLAZYに設定された場合、Employeeを参照する前に発行されるSQLは、findById()の時と同様に次の1つだけです。

select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?

これまでにFetchTypeについて挙動が明らかになったので、FetchTypeに対応する関連エンティティの取得について整理します。

LAZY

  • 生成クエリ:関連エンティティはアクセス時
  • JPQL:関連エンティティはアクセス時
  • N+1問題が起きる:関連エンティティにアクセスした際に発生

EAGER

  • 生成クエリ:関連エンティティも1つのSQLで取得
  • JPQL:関連エンティティも必ず追加SQLで取得
  • N+1問題が起きる:生成クエリは問題なし。JPQLでは必ず発生

実際の運用を行う中でJPQLを多く実装することを考えると、FetchTypeEAGERを指定することはリスクがあると言えます。LAZYの場合、関連エンティティにアクセスしない限り不要なSQLは発行されず、N+1問題も発生しません。
しかし、FetchTypeLAZYを指定しても、関連エンティティを参照すると必ず追加のSQLが発行されてしまいます。この問題を解決するのがJOIN FETCHです。

JOIN FETCH

JOIN FETCHは関連するエンティティをJOINして一括で取得することで、発行するクエリの数を減らすための方法です。
JOINの形式には次の4つがあります。

  • LEFT JOIN FETCH
    • 関連エンティティが存在しない場合でも対象のエンティティは取得される
  • LEFT OUTER JOIN FETCH
    • LEFT JOIN FETCH と同様
  • INNER JOIN FETCH
    • 関連エンティティが存在しない場合は、対象のエンティティは取得されない
  • JOIN FETCH
    • INNER JOIN FETCH と同様

Companyを取得するJPQLの中で、EmployeeをJOINで取得するようにしてみます。

CompanyRepository.java

@Query("select c from Company c " +
"left join fetch c.employee e " +
"where c.id = :id")
Company findByCompanyId(int id);

FetchTypeがデフォルトのLAZYの状態でfindByCompanyId()メソッドを実行すると、発行されたSQLは次のようになります。

select c1_0.id,e1_0.company_id,e1_0.id,e1_0.name,c1_0.name from company c1_0 left join employee e1_0 on c1_0.id=e1_0.company_id where c1_0.id=?

1つのSQLで関連するエンティティも同時に取得できました。
JOINの形式を比較するため、Employeecompany_idが設定されていない「testCompanyC」を指定して、LEFT JOIN FETCHINNER JOIN FETCHを試してみます。
説明の通り、LEFT JOIN FETCHではCompanyは取得でき、employeeのサイズが0になりました。一方、INNER JOIN FETCHで記述した場合、Companyの取得もできないことが確認できました。

これらの結果から、JPQLで任意のクエリを実行してエンティティを取得する際の整理は次のようになります。

FetchType=EAGERの場合

何もしなければ関連するエンティティを取得するSQLが必ず発行されることから、JOIN FETCHは必須です。

FetchType=LAZYの場合

使用頻度を考慮して、JOIN FETCHを使用するかどうかを判断する必要があります。参照がない場合は、JOIN FETCHがなくても問題ありません。


ここまででFetchTypeLAZYに設定し、必要な関連エンティティについてJOIN FETCHを活用した結果、N+1問題の原因となる無駄なSQLが発行されないことが確認できました。これにより、効率的なデータ取得が実現できます。

しかし、最後にもう一つ注意点があります。それはJOIN FETCHの章に書いた「関連付けアノテーションがLAZYの場合は使用頻度を考慮してJOIN FETCHするかを判断する必要がある」という部分です。これはFetchTypeLAZYの場合、関連エンティティの参照がない場合には追加のSQLが発行されないことを意味するため、無理にJOIN FETCHを使用する必要はないということです。ただし、関連エンティティがどのようなタイミングで参照されるかには注意が必要です。

Jacksonのアノテーション

私たちが使用するAPIでは、今回のCompanyのようなエンティティを返却値として使用しているものが多数あります。
どのような挙動になるかを確認するために、実際にCompanyを返却するAPIを作成してみます。

CompanyController.java

@GetMapping({ "/company", "/company/" })
public Company getCompany() {
return companyService.getCompany();
}

このようにして、Companyを返却するAPIをControllerに実装しました。

getCompanyメソッドの中では、JPQLを使ってCompanyを取得するメソッドを呼び出し、その結果を返しています。
Companyの関連エンティティであるEmployeeOneToManyの関係にあるため、デフォルトでFetchTypeLAZYです。今回はEmployeeは不要なため、JPQLの中でもJOIN FETCHを使用しないことにします。

getCompanyメソッドの実行により、次のSQLが発行されました。

select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?

Companyだけを返却すればよいため、Employeeに関する余計なSQLは発行されておらず、想定通りの結果です。
しかし、CompanyController.javaの最後のreturnでフロントエンドに返却する際に、次のSQLが発行されていました。

select e1_0.company_id,e1_0.id,e1_0.name from employee e1_0 where e1_0.company_id=?
select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?

それぞれのSQLが次の処理を行っていることが分かります。

  1. Employeeの取得
  2. Companyの取得

Spring BootのRest APIでは、戻り値として設定したクラスのインスタンスを、搭載されているJacksonライブラリを用いてシリアライズ(JSON化)し、レスポンスを返します。
今回の状況では、このシリアライズのタイミングで関連エンティティを参照し、追加のSQLが発行されてしまっていることが分かります。2つ目のCompanyEmployeeから見た場合、ManyToOneの関係にあります。そのため、FetchTypeEAGERに設定されている場合、対象のEmployeeを取得する際に一緒にSQLが発行されてしまうことも分かります。
この状況では、FetchTypeLAZYに設定しても、最終的に関連エンティティを参照することになり、意図しないSQLが発行されてしまいます。

これはJacksonの@JsonIgnoreを使用して対処できます。
@JsonIgnoreは次のようにエンティティ内のプロパティに追記することで、指定したプロパティをシリアライズ、デシリアライズの対象から外すことができます。

Company.java

@OneToMany(mappedBy = "company")
@JsonIgnore
private List<Employee> employee;

再度同じAPIを実行してみると、SQLは次のようになります。

select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?

今度はCompanyを取得するSQLのみが発行されていることが確認できました。さらに、このJacksonの設定には次のようなものもあります。

@JsonIgnorePropertiesは、@JsonIgnoreの設定を一括で管理することができます。

@JsonIncludeは、対象外とするプロパティの条件を指定することができます。

  • Include.NON_ABSENT
    • nullまたはOptional.empty()等の場合に対象外とする
  • Include.NON_NULL
    • nullの場合に対象外とする
  • Include.NON_EMPTY
    • 空文字の場合に対象外とする
  • Include.NON_DEFAULT
    • デフォルト値の場合に対象外とする

最初に述べた@JsonIgnoreでは必ず対象から外れますが、これらの設定では中身に応じて対象とするかどうかを判断できます。 次のようにInclude.NON_EMPTYを利用すれば、配列にEmployeeが存在する場合には返却されます。一方で、Employeeが存在しない「testCompanyC」のような場合には、追加のSQLが発行されるのを防ぐことができます。 以下はCompanyクラスでの実装例です。

Company.java

@OneToMany(mappedBy = "company")
@JsonInclude(Include.NON_EMPTY)
private List<Employee> employee;

ネイティブクエリ

最後に、Spring Data JPAではJPAのエンティティマネージャを介して直接SQLを使用してDBを操作できるネイティブクエリ(Native Query)があります。
使用方法としては、@Queryアノテーションを使い、クエリにnativeQuery = trueオプションを指定します。

以下はCompanyRepositoryにおけるネイティブクエリの実装例です。

CompanyRepository.java

@Query(value = "select * from company where id = :id", nativeQuery = true)
Company findCompanyByNativeQuery(int id);

FetchTypeの指定ができない場合などは、このネイティブクエリと@JsonIgnoreを組み合わせることで、不要なSQLの発行を防ぐことができます。
ネイティブクエリとJPQLには次のような違いがあります。

特徴ネイティブクエリJPQL
構文SQLそのものエンティティと属性を用いたクエリ
DB依存DBに依存
(DB固有の構文が使える)
DB非依存
実行時パフォーマンス最適化されたSQLが
直接実行されることが可能
JPA仕様に従った実行
データマッピングカラム名とエンティティの
フィールド名が一致する必要がある
エンティティとして
自動的にマッピングされる
使用する際の柔軟性DB固有の機能や
関数が使用可能
JPA API及びエンティティ構造
に基づく柔軟性

上記のように、ネイティブクエリを使用することのメリットもあるため、開発の状況に応じて使い方を適切に選ぶことが重要です。

おわりに

Spring Data JPAを使用する際にN+1問題を発生させないための方法を紹介しました。
エンティティに対応した生成クエリやJPQLは内容がシンプルであり、適切に活用することで開発者の負担を軽減し、より可読性の高いコードを作成できます。しかし、これには今回のN+1問題のように気を付けるべき点も存在するため、正しく理解して対応することが重要です。また、何か問題が発生した際には、1つ1つのSQLがなぜ発行されるのかを確認し、起きている事象を解析することが問題解決に非常に重要であると、記事を書いていて改めて感じました。
この記事が皆さんの開発に役立てば幸いです。

参考にさせていただいたサイト


TOP
アルファロゴ 株式会社アルファシステムズは、ITサービス事業を展開しています。このブログでは、技術的な取り組みを紹介しています。X(旧Twitter)で更新通知をしています。