【Spring Boot】@OneToMany・@ManyToOneでリレーションを組む

Java

こんにちは、かつコーチです。

これまで扱ってきた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 → ... と無限ループ
}

AuthorBookが互いを参照し合う状態で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は外部キーを持つ側、@OneToManymappedByで相手に管理を委ねる側
  • @ManyToOneのデフォルトはEAGERなので、明示的にLAZYにするのが定石
  • 双方向リレーションはJSON変換時に無限ループしやすいので@JsonIgnoreやDTOで対策する
  • cascadeorphanRemovalは所有関係が強い場合にのみ設定する

次に読むべき記事

  • CRUD操作の実装
  • クエリメソッドの命名規則
  • N+1問題とJOIN FETCHでの解決

タグ: Spring Boot, 中級者向け, データベース

タイトルとURLをコピーしました