Zademy

JPAと高度な関連付け:クラスのための完全ガイド

JPA; Hibernate; Spring Data; ORM; Performance
words 単語

JPA の関連付けで何度も痛い目を見てきたので、ここに整理しておきます。@OneToOne、@OneToMany、@ManyToOne、@ManyToMany の使い分け、フェッチング戦略の最適化、プロジェクション、そしてよく踏む罠までカバーします。

I. JPAと関連付けの種類の紹介

JPA(Java Persistence API)は、ふつう Hibernate で実装され、Java のオブジェクト関係マッピング(ORM)の基盤になります。オブジェクトの永続性をリレーショナルデータベースで透過的に管理できる仕組みです。

関連付けとは?

JPA の関連付けは、エンティティ同士がどう関係しているかを定義するものです。ドメインモデル上の現実世界の関係を表します。

シンプルに整理すると、@OneToOne は「1 対 1」で、ユーザーとプロフィールのような関係です。@OneToMany は「1 対多」で、注文と注文明細に該当します。@ManyToOne はその逆で、複数の従業員がひとつの部門に属するようなケース。@ManyToMany は「多対多」で、学生とコースの関係が典型例です。

基本概念

実装に入る前に、以下の概念を押さえておかないと後で混乱します。

所有側(Owner)はデータベースで外部キー(FK)を管理する側です。逆側は mappedBy を使い、FK を持ちません。FetchType はデータがいつロードされるかを決めます(LAZY vs EAGER)。CascadeType は関連エンティティにどの操作が伝播するかを定義します。


II. 一対一の関係(@OneToOne)

エンティティ A のインスタンスが、エンティティ B のちょうど 1 つのインスタンスと結びつく場合に使います。

使用シナリオ

めったにアクセスされないフィールドを別テーブルに切り出してデータを分離するとき、メインエンティティの書き込み負荷が高くてテーブルを分けたいとき、権限の異なる機密データを別テーブルに置きたいとき、といった場面で @OneToOne が適しています。

デフォルトの動作

ここで注意してほしいのが、@OneToOne のデフォルトは FetchType.EAGER だということです。意図せず全件フェッチしてパフォーマンスを殺す原因になります。

基本的な例:単方向

@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "profile_id", referencedColumnName = "id")
    private UserProfile profile;

    // ゲッターとセッター
}

@Entity
public class UserProfile {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String bio;
    private String avatarUrl;

    // ゲッターとセッター
}

高度な例:@MapsIdを使用した双方向

@MapsId を使うのが @OneToOne 双方向のベストプラクティスだと私は考えています。両テーブルで主キーを共有するので、追加の FK カラムが不要になります。逆側でも真の遅延ロードが効くようになります。

@Entity
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;

    @OneToOne(mappedBy = "customer", cascade = CascadeType.ALL,
              fetch = FetchType.LAZY, orphanRemoval = true)
    private CustomerDetails details;

    // ヘルパーメソッド
    public void setDetails(CustomerDetails details) {
        this.details = details;
        if (details != null) {
            details.setCustomer(this);
        }
    }
}

@Entity
public class CustomerDetails {
    @Id
    private Long id;

    private String address;
    private String phoneNumber;

    @OneToOne
    @MapsId
    @JoinColumn(name = "id")
    private Customer customer;

    // ゲッターとセッター
}

CustomerDetails の id は Customer の id と同じになります。@MapsId が「customer 関連が主キーのソースである」ことを示すので、逆側で真の LAZY ロードが可能になります。

一般的なエラー

N+1 問題は @OneToOne でも頻発します。

// 悪い:各ユーザーに対して追加のクエリ
List<User> users = userRepository.findAll();
users.forEach(user -> System.out.println(user.getProfile().getBio()));

JOIN FETCH でまとめて取ってくれば解決します。

@Query("SELECT u FROM User u LEFT JOIN FETCH u.profile")
List<User> findAllWithProfiles();

III. 一対多と多対一の関係(@OneToMany / @ManyToOne)

これが一番よく使う関連付けです。常にペアで機能します。@ManyToOne が所有側(FK を持つ)、@OneToMany が逆側(mappedBy を使う)です。

ベストプラクティス:双方向

@Entity
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private LocalDateTime orderDate;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL,
               orphanRemoval = true, fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();

    // ヘルパーメソッド(重要!)
    public void addItem(OrderItem item) {
        items.add(item);
        item.setOrder(this);
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.setOrder(null);
    }
}

@Entity
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String productName;
    private Integer quantity;
    private BigDecimal price;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id", nullable = false)
    private Order order;

    // ゲッターとセッター
}

ここで私が絶対に守っているルールがいくつかあります。所有側は常に @ManyToOne(FK を持つ側)にする。@OneToMany 側では必ず mappedBy を使う。両側を同期するヘルパーメソッドを必ず実装する。orphanRemoval = true で孤立エンティティを自動削除する。そして基本は FetchType.LAZY にする。

一般的なエラー

双方向の同期を忘れると、片方のメモリ状態が壊れてバグの温床になります。

// 悪い
Order order = new Order();
OrderItem item = new OrderItem();
order.getItems().add(item); // itemにorderを設定していない!

ヘルパーメソッドを使えば両側が同時に更新されます。

// 良い
order.addItem(item); // 両側を同期

IV. 多対多の関係(@ManyToMany)

両側が複数のインスタンスを持てる場合に使います。

基本的な例:単方向

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToMany
    @JoinTable(
        name = "student_course",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private Set<Course> courses = new HashSet<>();
}

@Entity
public class Course {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
}

推奨:追加属性を持つ結合エンティティ

結合テーブルに登録日や成績といった追加データが必要な場合、@ManyToMany を 2 つの @ManyToOne に分解することを強く推奨します。これをやらないと後で必ず後悔します。

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "student", cascade = CascadeType.ALL,
               orphanRemoval = true)
    private Set<Enrollment> enrollments = new HashSet<>();

    public void enrollInCourse(Course course, LocalDate enrollmentDate) {
        Enrollment enrollment = new Enrollment(this, course, enrollmentDate);
        enrollments.add(enrollment);
        course.getEnrollments().add(enrollment);
    }
}

@Entity
public class Course {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @OneToMany(mappedBy = "course", cascade = CascadeType.ALL,
               orphanRemoval = true)
    private Set<Enrollment> enrollments = new HashSet<>();
}

@Entity
public class Enrollment {
    @EmbeddedId
    private EnrollmentId id;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("studentId")
    private Student student;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("courseId")
    private Course course;

    private LocalDate enrollmentDate;
    private String grade;

    public Enrollment() {}

    public Enrollment(Student student, Course course, LocalDate enrollmentDate) {
        this.student = student;
        this.course = course;
        this.enrollmentDate = enrollmentDate;
        this.id = new EnrollmentId(student.getId(), course.getId());
    }
}

@Embeddable
public class EnrollmentId implements Serializable {
    private Long studentId;
    private Long courseId;

    // コンストラクタ、equals、hashCode
}

V. フェッチング戦略とN+1問題

N+1問題とは?

JPA で最も頻繁に遭遇するパフォーマンス問題が N+1 です。1 回のクエリでリストを取得した後、各要素の関連にアクセスするたびに追加クエリが飛びます。

// 悪い:1 + N クエリ
List<Order> orders = orderRepository.findAll(); // 1クエリ
orders.forEach(order -> {
    System.out.println(order.getItems().size()); // 各注文に対してNクエリ
});

解決策1:JOIN FETCH

@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items")
List<Order> findAllWithItems();

解決策2:EntityGraph

@EntityGraph(attributePaths = {"items"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithItemsUsingEntityGraph();

解決策3:バッチフェッチング

@Entity
public class Order {
    @OneToMany(mappedBy = "order")
    @BatchSize(size = 10)
    private List<OrderItem> items;
}

VI. プロジェクションとDTO

インターフェースベースのプロジェクション

public interface OrderSummary {
    Long getId();
    LocalDateTime getOrderDate();
    BigDecimal getTotalAmount();
}

@Query("SELECT o.id as id, o.orderDate as orderDate, " +
       "SUM(i.price * i.quantity) as totalAmount " +
       "FROM Order o LEFT JOIN o.items i " +
       "GROUP BY o.id, o.orderDate")
List<OrderSummary> findOrderSummaries();

クラスベースのプロジェクション(DTO)

public class OrderDTO {
    private Long id;
    private LocalDateTime orderDate;
    private BigDecimal totalAmount;

    public OrderDTO(Long id, LocalDateTime orderDate, BigDecimal totalAmount) {
        this.id = id;
        this.orderDate = orderDate;
        this.totalAmount = totalAmount;
    }
    // ゲッター
}

@Query("SELECT new com.example.dto.OrderDTO(o.id, o.orderDate, " +
       "SUM(i.price * i.quantity)) " +
       "FROM Order o LEFT JOIN o.items i " +
       "GROUP BY o.id, o.orderDate")
List<OrderDTO> findOrderDTOs();

VII. カスケード操作

PERSIST は親を永続化すると子も永続化します。MERGE は親のマージが子に伝播します。REMOVE は親の削除で子も消えます。REFRESH はデータベースから再ロード、DETACH は永続化コンテキストからの切り離し、そして ALL はこれらすべてをカスケードします。

ベストプラクティス

@Entity
public class Order {
    @OneToMany(mappedBy = "order",
               cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();
}

CascadeType.ALL は強い親子関係がある場合だけ使います。orphanRemoval = true でコレクションから外れたエンティティを自動削除できます。CascadeType.REMOVE は意図しない削除を招きやすいので慎重に。


VIII. パフォーマンスのベストプラクティス

1. 常にLAZYフェッチングを使用

@ManyToOne(fetch = FetchType.LAZY) // 常にLAZY
@JoinColumn(name = "order_id")
private Order order;

2. JOIN FETCHで選択的にロード

@Query("SELECT o FROM Order o " +
       "LEFT JOIN FETCH o.items " +
       "WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);

3. 読み取り専用クエリに@Queryを使用

@Query("SELECT o FROM Order o WHERE o.status = :status")
@QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true"))
List<Order> findByStatus(@Param("status") String status);

4. 大量データにページネーションを使用

Page<Order> findByStatus(String status, Pageable pageable);

IX. 一般的なエラーと解決策

エラー3:LazyInitializationException

原因はセッション外で遅延関連付けにアクセスすることです。

// 悪い
@Transactional
public Order getOrder(Long id) {
    return orderRepository.findById(id).orElseThrow();
}

// コントローラーで
Order order = orderService.getOrder(1L);
order.getItems().size(); // LazyInitializationException!

トランザクション内で初期化するか、JOIN FETCH で取得しておきます。

// オプション1:トランザクション内でロード
@Transactional
public Order getOrderWithItems(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    order.getItems().size(); // トランザクション内で初期化
    return order;
}

// オプション2:JOIN FETCHを使用
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);

エラー4:MultipleBagFetchException

同じクエリで複数のコレクションをフェッチしようとると発生します。

// 悪い
@Query("SELECT o FROM Order o " +
       "LEFT JOIN FETCH o.items " +
       "LEFT JOIN FETCH o.payments")
List<Order> findAllWithItemsAndPayments(); // MultipleBagFetchException!

List を Set に変えるか、クエリを分けます。

// オプション1:Setを使用
@OneToMany(mappedBy = "order")
private Set<OrderItem> items = new HashSet<>();

// オプション2:複数のクエリを使用
@Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.items")
List<Order> findAllWithItems();

@Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.payments WHERE o IN :orders")
List<Order> findWithPayments(@Param("orders") List<Order> orders);

X. 結論と推奨事項

現場で私が大事だと思っていること:デフォルトは LAZY で、必要なときだけ JOIN FETCH か EntityGraph で取得する。双方向の同期はヘルパーメソッドで確実に行う。@OneToOne には @MapsId を検討する。追加属性が必要な @ManyToMany は結合エンティティに分解する。プロジェクションや DTO で必要なデータだけを取得する。カスケードは慎重に。大量データにはページネーションを。

推奨読書

  • Hibernate公式ドキュメント
  • Vlad MihalceaのHibernate本
  • Spring Data JPAリファレンス