プロジェクトを進めながらクエリを確認していた際、不思議な点を発見したことがあります。 明らかにAとBという互いに異なるエンティティの関連関係のローディング戦略をLazyにしたのに、なぜAオブジェクトを照会するときにBオブジェクトも一緒にローディングするクエリが発行されるのでしょうか?
~~ToOne関係のデフォルトのローディング戦略#
JPAを使用する場合、~~ToOne関係のローディング戦略のデフォルト値はFetchType.EAGERになっています。しかしこのような戦略はN+1問題のように意図しないクエリが発行される場合があるため、FetchType.LAZYに設定して使用することが推奨されます。 しかし明らかにFetchType.LAZYに設定したにもかかわらず、意図しないクエリが発生しました。一体なぜそうなるのでしょうか?この問題はJPAが遅延ローディング(LazyLoading)のためにプロキシオブジェクトを使用することによって発生します。
事前知識#
DBとオブジェクトの関連関係パラダイムの不一致#
本格的な分析に先立って、DBテーブルとJavaオブジェクトの関連関係パラダイムの不一致問題を整理しておきましょう。
DBテーブルは主テーブルであれ対象テーブルであれ、どちらか一方に外部キーがあれば双方向から照会できます。このような関係を双方向関係と言います。 以下の2つのSQL文はどちらも実行できます。
select * from species s join mission m on s.species_id = m.species_id
select * from mission m join species s on m.species_id = s.species_idしかしオブジェクトは参照を使って他のオブジェクトと関連関係を持ち、参照にアクセスして関連するオブジェクトを照会します。Speciesオブジェクトにmissionオブジェクトの参照が存在すればmissionオブジェクトにアクセスできますが、Missionオブジェクトにspeciesオブジェクトに該当する参照が存在しなければ、Missionオブジェクトはspeciesオブジェクトにアクセスできません。このような単方向関係で両方のオブジェクトに互いの参照を設定して一つのオブジェクトで外部キーを管理するなら、2つのオブジェクトはデータベースと同様に双方向関係で表現できます。(実際には単方向関係が2つです。)
JPAのようなORM技術は、このようなテーブルとオブジェクト間の関連関係パラダイムの不一致などの様々な問題を解決するために生まれた技術です。
関連関係のオーナー、@JoinColumn、mappedBy#
JPAで双方向関係を設定する際は、「誰がFK(外部キー)を管理するか?」を必ず指定する必要があります。これを関連関係のオーナーと言い、常にFKを持つ側がオーナーになります。
以下は理解を助けるために、一対多(1:N)関係のチーム(Team)と学生(Student)を例として説明します。
**@JoinColumn**はオーナー側につけ、「私がこのFK列を管理する」という宣言です。オーナー側で値を設定してはじめてDBに反映されます。
@ManyToOne
@JoinColumn(name = "team_id") // STUDENT テーブルの team_id 列が FK
private Team team;mappedByはオーナーでない側につけ、「相手方のフィールドがこの関係を管理する」という意味です。ここで値を設定してもDBには反映されません。mappedByに指定する値は相手エンティティのフィールド名です。
@OneToMany(mappedBy = "team") // Student クラスの 'team' フィールドがオーナー
private List<Student> students = new ArrayList<>();一方が@JoinColumnであれば反対側は必ずmappedByです。両側ともに@JoinColumnを使用するとFKが2つ生成されるので注意してください。
サンプル構成#
説明のために確認するサンプルDBテーブルは以下のとおりです。 MISSIONテーブルでSPECIESの主キー値を外部キーとして持っています。

JPAエンティティは以下のように定義しました。 Missionエンティティで@JoinColumnを利用してspecies_id値を外部キーとして設定します。そして互いに双方向参照を持っています。
@Entity
public class Species {
@Id
@Column(name = "species_id")
private Long id;
// FetchType.LAZYを適用する
// MissionエンティティのspeciesフィールドをmappedByして関連関係のオーナーとして適用する
@OneToOne(fetch = FetchType.LAZY, mappedBy = "species")
private Mission mission;
private String speciesName;
}@Entity
public class Mission {
@Id
@Column(name = "mission_id")
private Long id;
@OneToOne(fetch = FetchType.LAZY) // FetchType.LAZYを適用
@JoinColumn(name = "species_id")
private Species species;
private String missionName;
}
両方のオブジェクトは互いに参照するフィールドを持っており、テーブルは一対一の関係を持っています。Missionオブジェクトが関連関係のオーナーとして外部キーを管理し、MISSIONテーブルとマッピングされています。
オブジェクトはオブジェクトグラフ(互いに異なるオブジェクト間の参照を通じて接続されたチェーンが作るネットワーク)で関連するオブジェクトを探索できます。しかしオブジェクトがデータベースに保存されているため、関連するオブジェクトを自由に探索することは難しいです。メモリ上のオブジェクトなら参照を辿って自由に移動できますが、DBに保存されたデータは実際に照会クエリを実行するまで取得できないからです。つまり、mission.getSpecies()を呼び出す瞬間にDBにSELECTクエリが発行されなければSpeciesデータを取得する方法がありません。
プロキシと遅延ローディング#
JPAはこの問題を解決するために、関連関係にあるオブジェクトたちを一度にDBから照会して互いに探索が可能になるよう最適化します。しかしこのような方法は予期しないクエリが発生する可能性があり、時には照会にコストがかかる場合があります。JPAはこの問題を解決するためにプロキシという技術を使用します。そして関連するオブジェクトを最初からデータベースから照会するのではなく、実際に使用するタイミングでデータベースから照会できます。このような方式を遅延ローディング(Lazy Loading)と言います。遅延ローディング機能を使用するには実際のエンティティオブジェクトの代わりにDB照会を遅延できるダミーオブジェクトが必要で、これをプロキシオブジェクトと言います。プロキシオブジェクトはDBへのアクセスを委任されており、実際に使用されるときにDBを照会して実際のエンティティオブジェクトを生成します。
問題の確認#
照会されるオブジェクトが実際のエンティティかプロキシかテストするために、以下のようなコードを作成して実行してみました。 まず、関連関係のオーナーであるMissionエンティティをDBから照会し、Speciesエンティティは参照(reference)を通じて照会してみました。
Long missionId = 1001L;
Long speciesId = 9001L;
Mission newMission = new Mission(missionId,"ミッション1"); // id, missionName
Species newSpecies = new Species(speciesId, "種1"); // id, speciesName
newMission.setSpecies(newSpecies);
speciesRepository.save(newSpecies); // speciesを先に保存する必要がある。MissionがSpeciesのspecies_id(FK)を参照するため、SpeciesがDBに先に存在しなければ外部キー制約違反が発生する
missionRepository.save(newMission);
entityManager.clear(); // 1次キャッシュをクリアして以降の照会時にDBから実際にSELECTクエリが発生するよう強制する。クリアしなければキャッシュからすぐに返されてプロキシ生成の有無を確認できない
Mission mission = missionRepository.findById(missionId).get();
String missionClassName = mission.getClass().getName();
String speciesClassName = mission.getSpecies().getClass().getName();
System.out.println("missionClassName = " + missionClassName);
System.out.println("speciesClassName = " + speciesClassName);
Speciesエンティティはプロキシオブジェクトが生成されたことが確認できます。 今度は逆に、関連関係のオーナーでないSpeciesエンティティをDBから照会し、Missionエンティティを参照を通じて照会してみましょう。
Species species = speciesRepository.findById(species.getId()).get();
String speciesClassName = species.getClass().getName();
String missionClassName = species.getMission().getClass().getName();
System.out.println("speciesClassName = " + speciesClassName);
System.out.println("missionClassName = " + missionClassName);
2つの実行コードは互いに異なるエンティティをDBから照会するという点を除いて構造が完全に同じです。各エンティティはOneToOneの関連関係をマッピングする際にFetchType.LAZYを適用しましたが、Speciesエンティティをフォームから照会するときにMissionエンティティの照会クエリも一緒に発生し、Missionオブジェクトはプロキシではなく実際のエンティティが生成されました。
原因分析:プロキシの限界#
プロキシを使用して遅延ローディングを適用するとき、外部キーを直接管理しないオブジェクトの一対一関係は遅延ローディングに設定しても即時ローディングします。 この問題はプロキシの限界のために発生する問題です。
実際のエンティティオブジェクトのダミーオブジェクトプロキシを生成するときは、実際のエンティティに関する情報を持っていなければなりません。ダミーオブジェクトが実際のオブジェクトのふりをするためにプロキシを使用しますが、実際のオブジェクトが存在しなければダミーオブジェクトの存在意義はなくなります。したがってエンティティをプロキシで照会するときに実際のエンティティの識別子値(PK)をパラメーターとして渡し、プロキシオブジェクトは**この識別子値(PK)**を情報として持っています。
実際に
Mission mission = missionRepository.findById(missionId).get()コードを利用して照会したMissionエンティティをデバッガーで確認すると、上でspeciesId値(PK)として定義した9001LをSpeciesプロキシオブジェクトが持っていることを確認できます。この情報を利用して実際に使用されるときにSpeciesエンティティを初期化します。

もう一度オブジェクトとテーブルの関係を確認しましょう。

Missionエンティティは実際に使用するときにDBからMISSIONテーブルを照会します。
MISSIONテーブルには外部キーを管理する列が存在するため、テーブルの照会だけでSPECIESテーブルと関連関係があることがわかります。これによってspecies_id値を取得したり、関連するデータがないこと(null)がわかります。
(なお、@OneToOneのoptional属性は関連するエンティティの存在を強制するかどうかを指定します。デフォルト値はtrueで、関連するエンティティがなくてもよく、FK列にnullが許可されます。optional = falseに設定すると関連するエンティティが必ず存在しなければならず、FK列がNOT NULLとして処理されます。)
しかしSpeciesエンティティは実際に使用されるときにSPECIESテーブルを照会することで、MISSIONテーブルと関連関係があるかどうかわかりません。FK列のように他のテーブルとの関連関係の存在を確認する列がないため、nullかどうかさえわかりません。 したがってMISSIONテーブルとの関連関係を確認するためにMISSIONテーブルにSELECTクエリを送らなければなりません。
JPAはspecies_idと関係を持っているMISSIONテーブルのデータを探します。SELECTクエリでデータの存在確認だけを行うのは非効率なため、JPAはSELECTクエリとともにデータも取得してプロキシの代わりに実際のエンティティを生成します。こうして結果的にFetchType.LAZYが無視されてFetchType.EAGERが適用されます。

そしてSELECTクエリを発行して関連するデータがなければ以下のようにnullになります。

@OneToManyではなぜこの問題が発生しないのか?#
一(1)対多(N)の場合は常に**多(N)**側が外部キーを持ちます。この場合、一(1)に該当するオブジェクトは関連関係のオーナーではなく、マッピングされたテーブルを照会したとき、前述の場合と同様に関連関係の有無を確認する列が存在しません。つまりこの場合も多(N)に該当するテーブルにSELECTクエリを送らなければならないのでは?と考えるかもしれません。
一(1)に該当するエンティティの多(N)に該当する関連関係フィールドはコレクション型です。遅延ローディングを適用するときにエンティティにコレクションがあれば、コレクションを追跡・管理する目的でHibernateはコレクションラッパーというものを提供します。これがコレクションに対するプロキシの役割を果たします。したがってSELECTクエリを使用して存在確認をする必要なく、遅延ローディングが正常に動作します。
解決方法#
実のところ解決方法はあまりありません。そのまま即時ローディングになることを受け入れて使用するか、不必要に双方向関連関係を設定する必要がないのであれば以下のように単方向関連関係に設定してもよいです。 またはプロキシの代わりにバイトコードを操作するライブラリを使用する方法も存在するようです。
そして実際に単方向マッピングだけでもテーブルとオブジェクトの関連関係マッピングは完了しています。双方向マッピングは単方向マッピングと比較して関連関係のオーナーも決めなければならず、2つの単方向関連関係を双方向にするためのロジックもしっかり管理しなければならないほど複雑です。双方向の利点は関連関係のオーナーでない方向から関連関係のオーナー方向へのオブジェクトグラフ探索機能が追加されただけです。
したがって逆方向への探索機能が必要でなければ、単方向を優先して使用することを推奨します。

[参考資料]
JPA @OneToOne 一対一関連関係まとめとLazyLoading問題
JPA @OneToOneではFetchType.LAZYが効かないことがある?
