こんにちは、かつコーチです。
これまで扱ってきたBookEntityは単独のテーブルでしたが、実際のアプリケーションでは「著者(Author)が複数の書籍(Book)を持つ」のようなテーブル間のリレーションが必ず出てきます。
今回は@OneToManyと@ManyToOneを使ったリレーションの組み方を解説します。
リレーションの基本
1対多・多対1の関係とは
@OneToManyとは「1つの親レコードが複数の子レコードを持つ」関係を表すアノテーションです。
@ManyToOneとは「複数の子レコードが1つの親レコードに属する」関係を表すアノテーションで、@OneToManyとちょうど逆の視点から同じ関係を表現します。
著者と書籍の例では、1人の著者(Author)が複数の書籍(Book)を書く一方、1冊の書籍は必ず1人の著者に属する、という関係になります。
Author側から見ると:1対多(@OneToMany)Book側から見ると:多対1(@ManyToOne)
外部キーはどちらが持つか
リレーショナルデータベースでは、外部キーは「多」側のテーブルが持ちます。
今回の例では、booksテーブルにauthor_idという外部キーカラムを持たせる形になります。
実装手順
@ManyToOne側(Book)の定義
外部キーを持つ側には@ManyToOneと@JoinColumnを設定します。
package com.example.demo.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "books")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
// getter/setterは省略
}
@JoinColumn(name = "author_id")で、booksテーブルのどのカラムを外部キーとして使うかを指定します。
@OneToMany側(Author)の定義
package com.example.demo.entity;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "authors")
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new ArrayList<>();
// getter/setterは省略
}
mappedBy = "author"は、「このリレーションの管理はBook側のauthorフィールドに任せています」という意味を表します。
mappedByを指定した側は外部キーを持たない側(参照される側)になる、と覚えておくと迷いにくいです。
fetchType:EAGERとLAZYの違い
@ManyToOneのデフォルトはFetchType.EAGER(親を取得するときに関連データも一緒に取得する)ですが、明示的にLAZY(必要になるまで取得しない)に変更するのが定石です。
@OneToManyのデフォルトはもともとLAZYですが、@ManyToOneは意識しないとEAGERのままになり、想定外のSQLが大量に発行される原因になります。
つまずきやすいポイント
双方向リレーションでの無限ループ
❌ Before:双方向にそのままtoString()やJSON変換をしてしまう
@Entity
public class Author {
@OneToMany(mappedBy = "author")
private List<Book> books;
// toString()を自動生成すると books → author → books → ... と無限ループ
}
AuthorとBookが互いを参照し合う状態でLombokの@ToStringや、ControllerでそのままJSONシリアライズを行うと、StackOverflowErrorが発生します。
筆者も検証用のAPIを叩いたときに突然サーバーが固まり、ログを追ったらStackOverflowErrorが延々と出力されていて原因調査に苦労しました。
✅ After:@JsonIgnoreや専用DTOで循環を断ち切る
@Entity
public class Author {
@OneToMany(mappedBy = "author")
@com.fasterxml.jackson.annotation.JsonIgnore
private List<Book> books;
}
@JsonIgnoreを付けて片方向だけをJSON変換の対象にするか、そもそもEntityを直接返さずDTOに詰め替えるのが根本的な解決策です。
応用・一歩先の使い方
cascadeとorphanRemovalの使いどころ
cascade = CascadeType.ALLを設定すると、親(Author)を保存・削除したときに紐づく子(Book)にも同じ操作が伝播します。
orphanRemoval = trueは、親から子を切り離した(リストから削除した)ときに、その子のレコードごと削除する設定です。
「著者を消したら書籍も消してよい」ような強い所有関係がある場合にのみ使い、単なる関連付けの場合は付けないほうが安全です。
まとめ
この記事のポイント
@ManyToOneは外部キーを持つ側、@OneToManyはmappedByで相手に管理を委ねる側@ManyToOneのデフォルトはEAGERなので、明示的にLAZYにするのが定石- 双方向リレーションはJSON変換時に無限ループしやすいので
@JsonIgnoreやDTOで対策する cascade・orphanRemovalは所有関係が強い場合にのみ設定する
次に読むべき記事
- CRUD操作の実装
- クエリメソッドの命名規則
- N+1問題とJOIN FETCHでの解決
タグ: Spring Boot, 中級者向け, データベース