Spring BootでJPAを使ってデータベースを操作する際、テーブル間のリレーションシップをエンティティクラスでどう表現すればいいか悩んでいませんか?
この記事では、@OneToMany、@ManyToOne、@ManyToManyアノテーションを使った関連マッピングの基本から、双方向・単方向の使い分け、cascade設定、FetchTypeの選択基準まで段階的に解説します。N+1問題や循環参照といった実務でよく遭遇する落とし穴への対策も紹介します。
JPAの関連マッピングとは
JPA(Java Persistence API)の関連マッピングとは、データベースのテーブル間リレーションシップをJavaのエンティティクラスで表現する仕組みです。リレーショナルデータベースでは外部キーで表現する関係を、JPAではオブジェクト指向的に扱えます。
主要な関連の種類:
- 1対多(One-to-Many): 1つのエンティティが複数の関連エンティティを持つ(例: 1人のユーザーが複数の投稿を持つ)
- 多対1(Many-to-One): 複数のエンティティが1つの関連エンティティを参照する(例: 複数の投稿が1人のユーザーに属する)
- 多対多(Many-to-Many): 複数のエンティティが互いに複数の関連を持つ(例: 学生と講座の関係)
- 1対1(One-to-One): 1つのエンティティが1つの関連エンティティを持つ(例: ユーザーとプロフィール)
対応するアノテーションは@OneToMany、@ManyToOne、@ManyToMany、@OneToOneです。この記事では特によく使われる最初の3つを中心に解説します。
@ManyToOneと@OneToManyの基本 - 単方向関連
最もよく使われる1対多・多対1の関連から見ていきましょう。
@ManyToOne とは - 多側に外部キーを持つアノテーション
@ManyToOne は、複数のエンティティが1つの関連エンティティを参照する関係を表すアノテーションです。テーブル設計上は、@ManyToOne を付けた側のテーブルに外部キーカラムが作られます。
- 配置場所: 必ず「多」側のエンティティのフィールドに付ける
- デフォルトの FetchType:
EAGER(明示的にLAZYを指定するのが推奨) - 外部キーのカラム名:
@JoinColumn(name = "...")で指定(省略時はフィールド名_id) - nullable 制御:
@JoinColumn(nullable = false)で必須参照を表現できる
例として、複数の投稿(Post)が1人のユーザー(User)に属するケースはこう書きます。
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
}
この実装では、PostからUserへの参照は可能ですが、UserからPostへのアクセスはできません(単方向)。一方向のナビゲーションで十分な場合や、エンティティ間の結合度を低く保ちたい場合は、この単方向で始めるのがシンプルです。
@OneToMany とは - 1側からコレクションでアクセスするアノテーション
@OneToMany は、1つのエンティティが複数の関連エンティティをコレクションとして保持する関係を表します。実務では @ManyToOne と組み合わせた双方向関連で使われることがほとんどです。
- 配置場所: 「1」側のエンティティのコレクションフィールドに付ける
- デフォルトの FetchType:
LAZY - 双方向で使う場合は
mappedByを必ず指定(後述) - コレクション型は
ListかSetが一般的で、重複を避けたい場合はSetを使う
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Post> posts = new ArrayList<>();
@OneToMany 単方向で @JoinColumn も mappedBy も指定しないと、中間テーブルが自動生成されてしまうため注意が必要です。通常は@ManyToOne単方向または双方向の関連を使う方が適切です。
@JoinColumn の基本属性 - name/referencedColumnName/nullable/unique
@JoinColumn は外部キーカラムの詳細を制御するアノテーションです。よく使う属性は以下の通りです。
name: 外部キーカラムの物理名(省略時はフィールド名_主キー名)referencedColumnName: 参照先テーブルのカラム名(省略時は主キー)nullable: 外部キーが NULL を許容するか(必須参照ならfalse)unique: 外部キーに UNIQUE 制約を付けるか(@OneToOne相当の挙動)insertable/updatable: INSERT/UPDATE 文に含めるか(複合キーや読み取り専用関連で使う)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(
name = "user_id",
referencedColumnName = "id",
nullable = false,
updatable = false
)
private User user;
@JoinColumn を省略しても動作はしますが、明示的に書いておくとスキーマ意図が明確になります。
双方向関連とmappedBy属性
実務では、両方向からアクセスできる双方向関連がよく使われます。
mappedBy とは - 関連の所有者を示す属性
mappedBy は双方向関連において、どちらのエンティティが関連の「所有者(owner)」かを JPA に伝える属性です。外部キーを物理的に持つ側(@ManyToOne側)が所有者であり、mappedBy は所有者でない側(逆側、inverse side)に指定します。
- 指定する側:
@OneToMany側(双方向の場合) - 指定する値: 所有者側エンティティ内の、自分を参照しているフィールド名
- 未指定の影響: JPA が2つの独立した関連と認識し、意図しない中間テーブルが生成される
- 所有者側でしか外部キーの更新が反映されないため、ヘルパーメソッドで両側を同期させる
双方向@OneToMany/@ManyToOneの実装
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
@OneToMany(mappedBy = "user")
private List<Post> posts = new ArrayList<>();
// ヘルパーメソッド
public void addPost(Post post) {
posts.add(post);
post.setUser(this);
}
}
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@ManyToOne
@JoinColumn(name = "user_id")
private User user;
}
mappedBy = "user" の "user" は、所有者である Post クラスの user フィールドを指しています。Post 側の user をセットしない限り外部キーは更新されないため、addPost のようなヘルパーメソッドで両側の整合性を同時に保つのが定石です。
@ManyToManyによる多対多の関連マッピング
多対多の関連は、データベースでは中間テーブルを使って表現されますが、JPAでは@ManyToManyで簡潔に記述できます。学生(Student)と講座(Course)の例で見てみましょう。
@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<>();
}
// 双方向にする場合のCourse側
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToMany(mappedBy = "courses") // 双方向の場合
private Set<Student> students = new HashSet<>();
}
@JoinTableで中間テーブルの名前とカラム名をカスタマイズでき、joinColumnsは自分側、inverseJoinColumnsは相手側の外部キーを指定します。多対多では重複を避けるためListよりSetを使うことが多いです。
多対多関連の実務的な注意点
中間テーブルに追加の属性(登録日時、ステータスなど)を持たせたい場合、@ManyToManyでは対応できません。その場合は、中間テーブルを独立したエンティティとして作成し、2つの@ManyToOne関連に分解します。逆に言えば、属性が不要なら@ManyToManyで十分です。
@Entity
public class Enrollment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
private Student student;
@ManyToOne
private Course course;
private LocalDateTime enrolledAt; // 追加属性
private String status; // 追加属性
}
CascadeType - 関連エンティティへの操作の伝播
CascadeTypeは、親エンティティへの操作を関連エンティティにも伝播させるかを制御します。
CascadeTypeの種類
- PERSIST: 親を保存(persist)すると、関連エンティティも保存される
- MERGE: 親をマージすると、関連エンティティもマージされる
- REMOVE: 親を削除すると、関連エンティティも削除される
- REFRESH: 親をリフレッシュすると、関連エンティティもリフレッシュされる
- DETACH: 親をデタッチすると、関連エンティティもデタッチされる
- ALL: 上記すべての操作を伝播させる
Cascadeの動作例
@OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST)
private List<Post> posts = new ArrayList<>();
// 使用例
User user = new User("太郎", "[email protected]");
Post post = new Post("タイトル", "本文");
user.addPost(post);
entityManager.persist(user); // userとpostの両方が保存される
cascade設定のベストプラクティス
CascadeType.ALLは便利に見えますが、REMOVEが含まれるため、親を削除したときに意図せず子まで削除されるリスクがあります。PERSISTとMERGEなど、必要な操作だけを明示的に指定しましょう。
// 推奨される設定例
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Post> posts = new ArrayList<>();
orphanRemoval属性との違い
@OneToMany(mappedBy = "user", orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
// orphanRemoval = trueの場合、リストから削除するだけで子エンティティが削除される
user.removePost(post);
userRepository.save(user); // postがDBからも削除される
orphanRemoval = trueは、親エンティティとの関連が切れた子エンティティ(孤児)を自動削除します。CascadeType.REMOVEとは異なり、親を削除しなくてもリストから外すだけで子が削除されます。
FetchType - データ取得戦略の選択
FetchTypeは、関連エンティティをいつ取得するかを制御します。
// LAZY: 実際にアクセスされるまで取得されない
@ManyToOne(fetch = FetchType.LAZY)
private User user;
// EAGER: 親エンティティと同時に取得される
@ManyToOne(fetch = FetchType.EAGER)
private User user;
LAZYは関連エンティティへのアクセス時に初めてSQLが発行されるため、必要なデータだけを取得できます。EAGERは親エンティティ取得時に関連も同時に取得するため、不要なデータまで取得してしまう可能性があり、N+1問題の原因にもなります。
デフォルトはアノテーションごとに異なります。
@ManyToOne,@OneToOne: EAGER(デフォルト)@OneToMany,@ManyToMany: LAZY(デフォルト)
基本的にはFetchType.LAZYを明示的に指定し、必要な箇所だけ後述の@EntityGraphやJOIN FETCHで取得戦略を制御する方針が安全です。
なおFetchType.LAZYを使う場合、セッション外で関連エンティティにアクセスするとLazyInitializationExceptionが発生します。対策としては、(1)トランザクション内でアクセスする、(2)@EntityGraphやJOIN FETCHで明示的に取得する、(3)DTOを使ってセッション内で必要なデータを取得する、などの方法があります。
N+1問題とその対策
N+1問題は、JPAでよく遭遇するパフォーマンス問題です。
N+1問題とは
List<Post> posts = postRepository.findAll();
for (Post post : posts) {
System.out.println(post.getUser().getName()); // 各postごとにSQLが発行される!
}
このコードでは、全postを取得するSQLが1回実行され、さらに各postのuserを取得するSQLがN回(postの数だけ)実行されます。合計でN+1回のSQLが発行され、パフォーマンスが劣化します。
@EntityGraphを使った解決方法
最も手軽なのは、Repositoryメソッドに@EntityGraphアノテーションを付けて、関連エンティティを1つのクエリで取得する方法です。
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(attributePaths = "user")
List<Post> findAll();
@EntityGraph(attributePaths = {"user", "comments"})
List<Post> findByTitleContaining(String title);
}
attributePathsに取得したい関連のフィールド名を指定します。複数の関連を同時に指定でき、内部的にはLEFT OUTER JOINが使われます。
JPQLのJOIN FETCHを使った解決方法
複雑な条件や複数の関連を扱う場合は、JPQL(Java Persistence Query Language)でJOIN FETCHを使うと柔軟に制御できます。
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT p FROM Post p JOIN FETCH p.user")
List<Post> findAllWithUser();
@Query("SELECT DISTINCT p FROM Post p " +
"LEFT JOIN FETCH p.user " +
"LEFT JOIN FETCH p.comments")
List<Post> findAllWithUserAndComments();
}
複数のコレクションをフェッチする場合はCartesian積による重複行を排除するためDISTINCTが必要です。LEFT JOIN FETCHを使うと、関連がnullの場合も親エンティティを取得できます。
なお、N+1を解消してもDB接続待ちで詰まることがあります。コネクションプール側の設定はSpring BootのHikariCPコネクションプールを正しく設定・チューニングする方法で解説しています。
双方向関連における循環参照の問題と対策
REST APIでエンティティをJSONに変換する際、双方向関連があると循環参照エラーが発生します。
循環参照エラーの原因
// UserエンティティがPostのリストを持ち
// PostエンティティがUserを持つ双方向関連の場合
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow();
}
// JSON変換時:
// User -> Posts -> User -> Posts -> ... (無限ループ)
循環参照は最終的に例外として表面化することが多いため、REST API 全体での例外ハンドリングを統一しておくと原因特定が楽になります。実装パターンは Spring BootのREST APIで例外処理を実装する方法 を参照してください。
@JsonIgnoreによる解決方法
片側の関連に@JsonIgnoreを付けてJSON変換から除外する方法や、@JsonManagedReferenceと@JsonBackReferenceのペアを使う方法があります。
@Entity
public class User {
@OneToMany(mappedBy = "user")
@JsonManagedReference // 親側
private List<Post> posts = new ArrayList<>();
}
@Entity
public class Post {
@ManyToOne
@JoinColumn(name = "user_id")
@JsonBackReference // 子側(シリアライズ時に無視される)
private User user;
}
DTOパターンによる根本的な解決(推奨)
最も推奨される方法は、エンティティを直接返さず、DTO(Data Transfer Object)を使うことです。
public class UserResponse {
private Long id;
private String name;
private List<PostSummary> posts;
}
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
return convertToDto(user); // エンティティをDTOに変換
}
DTOパターンなら循環参照が発生せず、APIレスポンスの形式を自由にコントロールでき、エンティティの内部構造をクライアントに公開しないためセキュリティ上のリスクも低減できます。DTO を返す Controller では、入力側にも @Valid を組み合わせるとリクエスト/レスポンス両方の整合性が取れます。使い方は Spring Boot @Validアノテーションでバリデーションをシンプルに実装する方法 で解説しています。
実践例:UserとPostの完全な実装
これまでの知識を統合した実用的なサンプルコードです。
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@OneToMany(mappedBy = "user", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Post> posts = new ArrayList<>();
public void addPost(Post post) {
posts.add(post);
post.setUser(this);
}
}
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
}
// Repository
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT p FROM Post p JOIN FETCH p.user")
List<Post> findAllWithUser(); // N+1問題を回避
}
// Service
@Service
@Transactional
public class BlogService {
public User createUserWithPost(String name, String email, String postTitle) {
User user = new User(name, email);
Post post = new Post(postTitle);
user.addPost(post);
return userRepository.save(user); // cascadeでpostも保存される
}
}
まとめとベストプラクティス
最後に、実務で使える指針をまとめます。
- FetchTypeは基本的にLAZYを明示指定する
- cascadeは必要最小限に(
ALLやREMOVEは慎重に) - 双方向関連ではmappedByを必ず設定する(所有者側のフィールド名を指定)
- N+1問題には
@EntityGraphやJOIN FETCHで対処する - REST APIではエンティティを直接返さずDTOを使う
最初はシンプルな設計から始めて、パフォーマンス問題が実際に発生してから対処する方が、過度な最適化による複雑化を避けられます。この記事で紹介したパターンを実際にコードで試しながら、理解を深めていってください。
関連記事
- Spring BootのREST APIで例外処理を実装する方法 - JPAで発生する例外を含めた統一的な例外処理パターン
- 依存性注入(DI) - Repository/Serviceの構成理解に
- Spring BootのHikariCPコネクションプールを正しく設定・チューニングする方法 - JPAと組み合わせるDB接続設定
- Spring BootでMockMvcを使ったControllerの単体テストを書く方法 - Repository層をモックしたテストの書き方
- Spring BootのApplicationEventでモジュール間を疎結合にする方法 - エンティティ変更イベントの伝播設計