Spring Data JPAを使用したREST API開発において、エンティティ間のリレーションシップ設計は避けて通れない重要なトピックです。本記事では、@OneToMany@ManyToOneなどのリレーションアノテーションの適切な使い方から、開発者を悩ませるN+1問題の原因と解決策まで、実践的なコード例とともに解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Data JPA 3.4.x(Spring Boot Starterに含まれる)
Hibernate 6.6.x(Spring Data JPAに含まれる)
データベース H2 Database(開発・テスト用インメモリDB)
ビルドツール Maven または Gradle
IDE VS Code または IntelliJ IDEA

事前に以下の準備を完了してください。

  • JDK 17以上のインストール
  • Spring Boot REST APIプロジェクトの基本構成(前回記事を参照)
  • Maven または Gradle によるプロジェクトビルド環境

JPAリレーションシップアノテーションの種類

JPAでは、エンティティ間の関連を表現するために4種類のリレーションシップアノテーションが提供されています。

アノテーション 関連の種類
@OneToOne 1対1 ユーザーとプロフィール
@OneToMany 1対多 部署と従業員
@ManyToOne 多対1 従業員と部署
@ManyToMany 多対多 学生と講座

本記事では、実務で最も頻繁に使用される@OneToMany@ManyToOneの双方向関連を中心に解説します。

サンプルドメインモデルの設計

本記事では、書籍管理システムを題材にします。以下のエンティティを定義します。

erDiagram
    AUTHOR ||--o{ BOOK : "writes"
    AUTHOR {
        Long id PK
        String name
        String email
    }
    BOOK {
        Long id PK
        String title
        String isbn
        Long author_id FK
    }

著者(Author)は複数の書籍(Book)を執筆でき、書籍は必ず1人の著者に紐づくという1対多の関係です。

@ManyToOneアノテーションで多対1関連を定義する

@ManyToOneは、多側のエンティティから1側のエンティティへの参照を定義します。データベースでは外部キーを持つ側がオーナーとなります。

Bookエンティティの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package com.example.bookapi.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;

@Entity
@Table(name = "books")
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String title;

    @Column(nullable = false, unique = true, length = 13)
    private String isbn;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;

    // JPAが要求する引数なしコンストラクタ
    protected Book() {
    }

    public Book(String title, String isbn, Author author) {
        this.title = title;
        this.isbn = isbn;
        this.author = author;
    }

    // Getter/Setter
    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getIsbn() {
        return isbn;
    }

    public Author getAuthor() {
        return author;
    }

    public void setAuthor(Author author) {
        this.author = author;
    }
}

@ManyToOneの主要な属性

属性 説明 デフォルト値
fetch フェッチ戦略(EAGER/LAZY) FetchType.EAGER
optional 関連が必須かどうか true
cascade カスケード操作の種類 なし

@ManyToOneのデフォルトはFetchType.EAGERですが、パフォーマンス上の理由からFetchType.LAZYを明示的に指定することを強く推奨します。

@OneToManyアノテーションで1対多関連を定義する

@OneToManyは、1側のエンティティから多側のエンティティへの参照を定義します。mappedBy属性で関連のオーナーを指定します。

Authorエンティティの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package com.example.bookapi.entity;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "authors")
public class Author {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Book> books = new ArrayList<>();

    // JPAが要求する引数なしコンストラクタ
    protected Author() {
    }

    public Author(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // 双方向関連のヘルパーメソッド
    public void addBook(Book book) {
        books.add(book);
        book.setAuthor(this);
    }

    public void removeBook(Book book) {
        books.remove(book);
        book.setAuthor(null);
    }

    // Getter/Setter
    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public List<Book> getBooks() {
        return books;
    }
}

@OneToManyの主要な属性

属性 説明 デフォルト値
mappedBy 関連のオーナー側のフィールド名 必須
fetch フェッチ戦略(EAGER/LAZY) FetchType.LAZY
cascade カスケード操作の種類 なし
orphanRemoval 孤立したエンティティを自動削除 false

双方向関連のヘルパーメソッドが重要な理由

双方向関連では、両側の参照を同期させる必要があります。addBook()removeBook()のようなヘルパーメソッドを用意することで、データの整合性を保ちながら安全に関連を操作できます。

1
2
3
4
5
// 良い例:ヘルパーメソッドを使用
author.addBook(book);

// 悪い例:片側だけ設定すると不整合が発生する可能性
author.getBooks().add(book);  // book.author は null のまま

FetchType.LAZYとFetchType.EAGERの違いと使い分け

JPAでは、関連エンティティをいつ取得するかをFetchTypeで制御します。

フェッチ戦略の比較

戦略 動作 メリット デメリット
LAZY 関連エンティティにアクセスしたときに取得 不要なデータを読み込まない LazyInitializationExceptionのリスク
EAGER 親エンティティと同時に取得 常に関連データが利用可能 N+1問題の原因、メモリ消費増大

推奨されるデフォルト設定

1
2
3
4
5
6
7
// @ManyToOne - デフォルトはEAGERだが、LAZYを推奨
@ManyToOne(fetch = FetchType.LAZY)
private Author author;

// @OneToMany - デフォルトはLAZY(このままでOK)
@OneToMany(mappedBy = "author")
private List<Book> books;

Hibernateの公式ドキュメントでも、すべての関連をLAZYに設定し、必要な場合にのみ動的にフェッチすることが推奨されています。

N+1問題とは何か

N+1問題は、JPA/Hibernate使用時に最も頻繁に発生するパフォーマンス問題の1つです。

N+1問題の発生メカニズム

N+1問題とは、1件の親エンティティを取得するクエリ(1)に加えて、各親エンティティに対する子エンティティ取得クエリがN回発生する現象です。

sequenceDiagram
    participant App as アプリケーション
    participant DB as データベース
    
    App->>DB: 1. SELECT * FROM authors (著者一覧取得)
    DB-->>App: 著者10件を返却
    
    loop 各著者に対して
        App->>DB: 2. SELECT * FROM books WHERE author_id = 1
        App->>DB: 3. SELECT * FROM books WHERE author_id = 2
        App->>DB: ...
        App->>DB: 11. SELECT * FROM books WHERE author_id = 10
    end
    
    Note over App,DB: 合計11回のクエリ(1 + 10 = N+1)

N+1問題を再現するコード

以下のコードでN+1問題を確認できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.bookapi.service;

import com.example.bookapi.entity.Author;
import com.example.bookapi.repository.AuthorRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
public class AuthorService {

    private final AuthorRepository authorRepository;

    public AuthorService(AuthorRepository authorRepository) {
        this.authorRepository = authorRepository;
    }

    @Transactional(readOnly = true)
    public void demonstrateN1Problem() {
        // 1回目のクエリ:著者一覧を取得
        List<Author> authors = authorRepository.findAll();
        
        // 著者ごとにN回のクエリが発生
        for (Author author : authors) {
            // books にアクセスするたびに SELECT が実行される
            System.out.println(author.getName() + " の著書数: " + author.getBooks().size());
        }
    }
}

発生するSQLの確認

application.propertiesに以下を設定すると、実行されるSQLを確認できます。

1
2
3
4
5
6
# SQLログを有効化
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# バインドパラメータも表示(より詳細な確認用)
logging.level.org.hibernate.orm.jdbc.bind=trace

実行結果のログ例:

1
2
3
4
5
6
7
8
-- 1回目:著者一覧取得
SELECT a.id, a.name, a.email FROM authors a

-- 2回目以降:各著者の書籍を個別に取得(N+1の原因)
SELECT b.id, b.title, b.isbn, b.author_id FROM books b WHERE b.author_id = 1
SELECT b.id, b.title, b.isbn, b.author_id FROM books b WHERE b.author_id = 2
SELECT b.id, b.title, b.isbn, b.author_id FROM books b WHERE b.author_id = 3
-- ...N回繰り返し

JOIN FETCHでN+1問題を解決する

JPQL(Java Persistence Query Language)のJOIN FETCHを使用すると、関連エンティティを1回のクエリで取得できます。

AuthorRepositoryにカスタムクエリを追加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.example.bookapi.repository;

import com.example.bookapi.entity.Author;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;

@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {

    // JOIN FETCHで書籍を同時に取得
    @Query("SELECT DISTINCT a FROM Author a LEFT JOIN FETCH a.books")
    List<Author> findAllWithBooks();

    // 特定の著者とその書籍を取得
    @Query("SELECT a FROM Author a LEFT JOIN FETCH a.books WHERE a.id = :id")
    Author findByIdWithBooks(Long id);
}

実行されるSQL

1
2
3
4
5
SELECT DISTINCT 
    a.id, a.name, a.email,
    b.id, b.title, b.isbn, b.author_id
FROM authors a
LEFT OUTER JOIN books b ON a.id = b.author_id

1回のクエリで著者と書籍をすべて取得でき、N+1問題が解決します。

DISTINCTが必要な理由

LEFT JOIN FETCHを使用すると、関連する書籍の数だけ著者の行が重複します。DISTINCTを指定することで、Hibernate内部で重複を排除します。

@EntityGraphでN+1問題を解決する

@EntityGraphは、Spring Data JPAが提供するアノテーションで、フェッチ戦略を宣言的に定義できます。

@EntityGraphの基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.bookapi.repository;

import com.example.bookapi.entity.Author;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;

@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {

    // attributePathsでフェッチする関連を指定
    @EntityGraph(attributePaths = {"books"})
    List<Author> findAll();

    // 特定のメソッドにも適用可能
    @EntityGraph(attributePaths = {"books"})
    Optional<Author> findById(Long id);

    // 条件付きクエリにも使用可能
    @EntityGraph(attributePaths = {"books"})
    List<Author> findByNameContaining(String name);
}

@NamedEntityGraphを使用する方法

エンティティ側で名前付きグラフを定義することもできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package com.example.bookapi.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.NamedEntityGraph;
import jakarta.persistence.NamedAttributeNode;
// 他のimportは省略

@Entity
@Table(name = "authors")
@NamedEntityGraph(
    name = "Author.withBooks",
    attributeNodes = @NamedAttributeNode("books")
)
public class Author {
    // フィールドとメソッドは同じ
}
1
2
3
4
5
6
@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @EntityGraph(value = "Author.withBooks", type = EntityGraph.EntityGraphType.FETCH)
    List<Author> findAll();
}

EntityGraphTypeの種類

タイプ 動作
FETCH 指定した属性はEAGER、その他はLAZY
LOAD 指定した属性はEAGER、その他はマッピング定義に従う

JOIN FETCHと@EntityGraphの比較と使い分け

両方の手法にはそれぞれ特徴があります。

観点 JOIN FETCH @EntityGraph
記述場所 JPQLクエリ内 アノテーション
柔軟性 複雑な条件に対応可能 シンプルな関連フェッチ向け
可読性 クエリが長くなる 宣言的でわかりやすい
再利用性 クエリごとに記述 名前付きグラフで再利用可能
動的変更 可能 実行時変更は困難

使い分けの指針

  • シンプルな関連フェッチには@EntityGraphを使用
  • 複雑なフィルタ条件や動的なクエリにはJOIN FETCHを使用
  • 複数の箇所で同じフェッチパターンを使う場合は@NamedEntityGraphを定義

パフォーマンス計測による効果の検証

N+1問題の解決効果を数値で確認します。

テストデータの準備

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.bookapi;

import com.example.bookapi.entity.Author;
import com.example.bookapi.entity.Book;
import com.example.bookapi.repository.AuthorRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class DataInitializer implements CommandLineRunner {

    private final AuthorRepository authorRepository;

    public DataInitializer(AuthorRepository authorRepository) {
        this.authorRepository = authorRepository;
    }

    @Override
    @Transactional
    public void run(String... args) {
        // 100人の著者、各著者に10冊の書籍を登録
        for (int i = 1; i <= 100; i++) {
            Author author = new Author("Author " + i, "author" + i + "@example.com");
            for (int j = 1; j <= 10; j++) {
                Book book = new Book(
                    "Book " + j + " by Author " + i,
                    String.format("978-%04d-%04d", i, j),
                    author
                );
                author.addBook(book);
            }
            authorRepository.save(author);
        }
    }
}

パフォーマンス比較の実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example.bookapi.service;

import com.example.bookapi.entity.Author;
import com.example.bookapi.repository.AuthorRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
public class PerformanceTestService {

    private static final Logger log = LoggerFactory.getLogger(PerformanceTestService.class);
    private final AuthorRepository authorRepository;

    public PerformanceTestService(AuthorRepository authorRepository) {
        this.authorRepository = authorRepository;
    }

    @Transactional(readOnly = true)
    public void comparePerformance() {
        // N+1問題が発生するパターン
        long startN1 = System.currentTimeMillis();
        List<Author> authorsN1 = authorRepository.findAll();
        int totalBooksN1 = 0;
        for (Author author : authorsN1) {
            totalBooksN1 += author.getBooks().size();
        }
        long endN1 = System.currentTimeMillis();
        log.info("N+1パターン: {}ms, 書籍総数: {}", endN1 - startN1, totalBooksN1);

        // JOIN FETCHで最適化したパターン
        long startOptimized = System.currentTimeMillis();
        List<Author> authorsOptimized = authorRepository.findAllWithBooks();
        int totalBooksOptimized = 0;
        for (Author author : authorsOptimized) {
            totalBooksOptimized += author.getBooks().size();
        }
        long endOptimized = System.currentTimeMillis();
        log.info("最適化パターン: {}ms, 書籍総数: {}", endOptimized - startOptimized, totalBooksOptimized);
    }
}

期待される結果

パターン クエリ数 実行時間(目安)
N+1問題あり 101回 500-1000ms
JOIN FETCH使用 1回 50-100ms

実際の数値は環境により異なりますが、JOIN FETCHを使用することで10倍以上の高速化が期待できます。

複数コレクションをフェッチする際の注意点

複数の@OneToMany関連を同時にJOIN FETCHすると、カルテシアン積(Cartesian Product)が発生し、パフォーマンスが劇的に悪化します。

問題のあるコード例

1
2
3
// 複数コレクションの同時FETCH - 避けるべきパターン
@Query("SELECT a FROM Author a LEFT JOIN FETCH a.books LEFT JOIN FETCH a.awards")
List<Author> findAllWithBooksAndAwards();  // カルテシアン積が発生!

解決策:バッチサイズの設定

1
2
3
4
5
6
7
8
@Entity
@Table(name = "authors")
public class Author {

    @OneToMany(mappedBy = "author")
    @BatchSize(size = 100)  // IN句で一括取得
    private List<Book> books;
}

またはapplication.propertiesでグローバルに設定:

1
spring.jpa.properties.hibernate.default_batch_fetch_size=100

ManyToMany関連の設計パターン

多対多関連では、中間エンティティを明示的に作成することを推奨します。

推奨されるアプローチ:中間エンティティの作成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
@Table(name = "book_categories")
public class BookCategory {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "book_id", nullable = false)
    private Book book;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id", nullable = false)
    private Category category;

    @Column(name = "assigned_at")
    private LocalDateTime assignedAt;

    // コンストラクタ、Getter/Setter省略
}

中間エンティティを使用することで、関連に追加の属性(例:登録日時)を持たせたり、N+1問題への対処がしやすくなります。

まとめ

本記事では、Spring Boot REST APIにおけるJPAリレーションシップ設計とN+1問題の解決方法について解説しました。

重要なポイント

  1. すべての関連をFetchType.LAZYに設定する - EAGERはN+1問題の原因となるため避ける
  2. 双方向関連ではヘルパーメソッドを実装する - データの整合性を保つため必須
  3. N+1問題はJOIN FETCHまたは@EntityGraphで解決する - 用途に応じて使い分け
  4. 複数コレクションの同時フェッチは避ける - バッチサイズ設定を活用
  5. SQLログを有効化して実際のクエリを確認する - 問題の早期発見に有効

適切なリレーション設計とフェッチ戦略の選択により、REST APIのパフォーマンスを大幅に向上させることができます。

参考リンク