Back to list
JPA/Hibernate マッピングの解説:一対多と多対一の関係
JPA Mapping with Hibernate- One-to-Many and Many-to-One Relationship
Translated: 2026/3/14 11:16:36
Japanese Translation
前節では一対一の関係について議論しました。本節では、一対多および多対一の関係について考察します。
JPA において、一対多と多対一は一枚のコインの両面です。部門には多くの雇用者レコードが存在し、各雇用者は単一の部門に所属します。データベースでは、外キー(department_id)は雇用者テーブルに存在し、雇用者が所有側となります。
用語の意味
| 用語 | 意味 |
| --- | --- |
| 所有側 (Owning side) | データベースに外キーカラムを保持するエンティティ——常に @ManyToOne 側 |
| 逆側 (Inverse side) | mappedBy を持ち、FK を制御せず関係へのナビゲーションのみを行うエンティティ |
| mappedBy | 逆側(所有ではない側)に使用される |
| CascadeType | 親から子へどの操作(PERSIST, MERGE, REMOVE など)が派生するか |
| orphanRemoval | 親コレクションから子を削除した場合、自動的にその行を削除する機能 |
| FetchType.LAZY | 子データはアクセス時オンデマンドでロードされる——コレクションのデフォルト |
| FetchType.EAGER | 子データは親と常にロードされる——単一関連付けのデフォルト |
推奨アプローチは、両方のエンティティが互いの関係を認識する双方向のマッピングです。
```java
@Entity
public class Department {
@Id
@GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "department",
cascade = CascadeType.ALL,
orphanRemoval = true)
private Set<Employee> employees = new HashSet<>();
// 両方の側を同期させるために、常にヘルパーメソッドを使用
public void addEmployee(Employee emp) {
employees.add(emp);
emp.setDepartment(this);
}
public void removeEmployee(Employee emp) {
employees.remove(emp);
emp.setDepartment(null);
}
}
@Entity
public class Employee {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
// デフォルトを上書きする!
@JoinColumn(name = "department_id")
private Department department;
}
```
なぜ mappedBy を用いるのか?
これは、部門が FK カラムを所有しない、つまり逆側であることを JPA に告げます。これがない場合、Hibernate はジョイントテーブルを生成しますが、それはほとんど望ましくありません。
```java
// クリーンに見えますが、追加の SQL を生成します
@OneToMany
@JoinColumn(name = "department_id")
// mappedBy を使用しない
private List<Employee> employees;
```
ここでは、Department が Employee を認識しており、Employee は Department を参照していません。Hibernate が子 INSERT 中に FK を書けないため(FK カラムを所有していないため)、その後追加の UPDATE ステートメントを発行します。これは不必要な SQL を生成し、パフォーマンスに悪影響を与えます。双方向のマッピングを好むべきです。
非双方向 @OneToMany の問題
- 追加の UPDATE クエリを生成する
- パフォーマンス低下
- ジョイントテーブルを生成する可能性がある
- 関係の一貫性を維持するのが困難
- クエリ機能に制限がある
したがって、ベストプラクティスは、所有側を @ManyToOne で、逆側を @OneToMany(mappedBy=...) とすることです。
@ManyToOne は EAGER にデフォルト設定されており、これは隠れたパフォーマンスの罠です。明示的にこれを上書きする必要があります。
```java
@ManyToOne(fetch = FetchType.LAZY) // EAGER のデフォルトを上書き
@JoinColumn(name = "department_id")
private Department department;
```
@OneToMany はデフォルトで LAZY であり、これは正しい——そのまま放置してください。
List ではなく Set を用いる理由
同じエンティティ上の 2 つの @OneToMany コレクションを JOIN FETCH する場合、List を使用すると、結果行数が乗算されるカルタesian 積が発生します。Set はそれらを重複除去します。
```java
@OneToMany(mappedBy = "department")
private Set<Employee> employees = new HashSet<>();
```
これは、データベース ID に基づくビジネスキーとして正しく等価性ハッシュ関数を実装する必要があります。
双方向の関係において、JPA は DB に書き込むために所有側を使用します。もし逆向き側のみを更新した場合(Department.employees)、FK は保存されません。常にヘルパーメソッドを使用してください:
```java
// 良い
department.addEmployee(employee);
// 悪い——逆向き側のみを更新し、FK は保存されない
department.getEmployees().add(employee);
```
親所有の子に関する orphanRemoval
親が子ライフサイクルを完全に所有する場合、orphanRemoval を有効にしてください:
```java
@OneToMany(mappedBy = "department",
cascade = CascadeType.ALL,
orphanRemoval = true)
private Set<Employee> employees = new HashSet<>();
```
コレクションから子を削除した際に、それが自動的に削除されます。
Original Content
In the previous section, we discussed the One-to-One Relationship Now, let’s look at the one-to-many and many-to-one relationships In JPA, One-to-Many and Many-to-One are two sides of the same coin. A Department can have many Employee records, and each Employee belongs to one Department. In the database, the foreign key (department_id) lives on the employees table — making Employee the owning side. Term Meaning Owning side The entity that holds the foreign key column in the database — always the @ManyToOne side Inverse side The entity with mappedBy — does not control the FK, just navigates the relationship mappedBy mappedBy is used on the inverse (non-owning) side. (Inverse side) CascadeType Which operations (PERSIST, MERGE, REMOVE etc.) propagate from parent to child orphanRemoval Automatically deletes a child row when it is removed from the parent collection FetchType.LAZY Child data is loaded on demand when accessed — default for collections FetchType.EAGER Child data is always loaded with the parent — default for single associations The recommended approach is a bidirectional mapping, where both entities know about each other. @Entity public class Department { @Id @GeneratedValue private Long id; private String name; @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true) private Set employees = new HashSet<>(); // Always use helper methods to keep both sides in sync public void addEmployee(Employee emp) { employees.add(emp); emp.setDepartment(this); } public void removeEmployee(Employee emp) { employees.remove(emp); emp.setDepartment(null); } } @Entity public class Employee { @Id @GeneratedValue private Long id; private String name; @ManyToOne(fetch = FetchType.LAZY) // Always override the default! @JoinColumn(name = "department_id") private Department department; } Why mappedBy? It tells JPA that Department is the inverse side — it does not own the FK column. Without it, Hibernate creates a join table, which is almost never what you want. // Looks clean but generates extra SQL @OneToMany @JoinColumn(name = "department_id") // no mappedBy private List employees; Here: Department knows about Employee Employee does not reference Department When Hibernate cannot write the FK during the child INSERT (because it doesn't own the column), it issues extra UPDATE statements afterward. This produces unnecessary SQL and hurts performance. Prefer bidirectional mappings. Problems with unidirectional @OneToMany: Generates extra UPDATE queries Poor performance May create join tables Harder to maintain relationship consistency Limited query capability Therefore: Best practice is to use @ManyToOne as the owning side and @OneToMany(mappedBy) as the inverse side. @ManyToOne defaults to EAGER — a hidden performance trap. Always override it explicitly. @ManyToOne(fetch = FetchType.LAZY) // override the EAGER default @JoinColumn(name = "department_id") private Department department; @OneToMany is lazy by default, which is correct — leave it as-is. Set Instead of List When you JOIN FETCH two @OneToMany collections on the same entity, using List causes a Cartesian product that multiplies result rows. A Set deduplicates them. @OneToMany(mappedBy = "department") private Set employees = new HashSet<>(); This requires a proper equals and hashCode implementation based on a business key, not the database ID. In a bidirectional relationship, JPA uses the owning side to write to the DB. If you only update the inverse side (Department.employees), the FK won't be persisted. Always use helper methods: // Good department.addEmployee(employee); // Bad — only updates the inverse side department.getEmployees().add(employee); orphanRemoval for Parent-Owned Children When the parent fully owns the lifecycle of its children, enable orphanRemoval: @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true) private Set employees = new HashSet<>(); Removing a child from the collection now automatically deletes it from the database on the next flush. @ManyToOne cascade = CascadeType.ALL belongs on the parent (@OneToMany) side. Adding it to @ManyToOne can cause catastrophic side effects — like deleting a Department when you delete one Employee. // WRONG @ManyToOne(cascade = CascadeType.ALL) // could delete the department! private Department department; // CORRECT — no cascade on the child side @ManyToOne(fetch = FetchType.LAZY) private Department department; Cascade Type Effect PERSIST When parent is saved for the first time, unsaved children are also saved MERGE When parent is merged (updated), children are also merged REMOVE When parent is deleted, all children are deleted — use with care REFRESH When parent is refreshed from DB, children are also refreshed DETACH When parent is detached from context, children are also detached ALL Shorthand for all of the above — convenient but can be dangerous equals and hashCode Use a natural business key (e.g., email for an employee), not the database ID. The ID is null before the entity is persisted, so ID-based equality breaks collections like HashSet. @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Employee)) return false; Employee other = (Employee) o; return email != null && email.equals(other.email); } @Override public int hashCode() { return getClass().hashCode(); } The most common JPA performance issue. Loading a list of departments, then accessing each one's employees, fires one query per department. // Triggers 1 + N queries List depts = repo.findAll(); depts.forEach(d -> d.getEmployees().size()); // N extra queries! Fix with JOIN FETCH: @Query("SELECT DISTINCT d FROM Department d LEFT JOIN FETCH d.employees") List findAllWithEmployees(); Fix with @EntityGraph (Spring Data): @EntityGraph(attributePaths = {"employees"}) List findAll(); Fix with @BatchSize (Hibernate): @OneToMany(mappedBy = "department") @BatchSize(size = 25) private Set employees; @BatchSize loads children in batches rather than one query each — a good low-friction option when you don't want to change your queries. LazyInitializationException Accessing a lazy collection outside of an active Hibernate session (e.g., after the transaction has closed) throws this exception. // Fails if the session is closed department.getEmployees().size(); // LazyInitializationException! Fix: Always access lazy relationships within a @Transactional context, or eagerly fetch what you need in the query. Setting the child's reference without updating the parent's collection (or vice versa) leaves the in-memory object graph inconsistent. // Only sets one side — department.getEmployees() won't contain emp in memory emp.setDepartment(department); Fix: Use the helper addEmployee / removeEmployee methods shown above. CascadeType.REMOVE + orphanRemoval Redundancy Both cause child deletion when the parent is removed. Using both is redundant and signals misunderstanding. Use orphanRemoval = true when the parent fully owns the child lifecycle — it covers the removal case and also handles disassociation from the collection. Attribute Recommended Notes @OneToMany fetch LAZY (default) Don't override unless necessary @ManyToOne fetch LAZY (explicit) Default is EAGER — always override mappedBy On the @OneToMany side Marks the inverse (non-owning) side cascade CascadeType.ALL on parent only Never put cascade on @ManyToOne orphanRemoval true for owned children Handles both removal and disassociation Collection type Set Safer than List with multiple joins N+1 prevention JOIN FETCH or @EntityGraph @BatchSize is a low-friction alternative equals/hashCode Based on business key Never based on database ID The FK lives on the many side — that entity is the owner. Use mappedBy on the @OneToMany to declare the inverse side. Always set @ManyToOne(fetch = FetchType.LAZY) — the default EAGER is a trap. Write bidirectional helper methods to keep both sides in sync. Use Set over List to avoid Cartesian products. Watch for the N+1 problem whenever you iterate over collections. Never cascade from child to parent. Code : @Entity @Getter @Setter public class Department { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true) private Set employees = new HashSet<>(); // Always use helper methods to keep both sides in sync public void addEmployee(Employee emp) { employees.add(emp); emp.setDepartment(this); } public void removeEmployee(Employee emp) { employees.remove(emp); emp.setDepartment(null); } } @Entity @Getter @Setter public class Employee { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String email; public Employee(String name, String email) { this.name = name; this.email = email; } @ManyToOne(fetch = FetchType.LAZY) // Always override the default! @JoinColumn(name = "department_id") private Department department; @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Employee)) return false; Employee other = (Employee) o; return email != null && email.equals(other.email); } @Override public int hashCode() { return getClass().hashCode(); } } public interface DepartmentRepository extends JpaRepository { } public interface EmployeeRepository extends JpaRepository { } @Component public class DataLoader implements ApplicationRunner { private final DepartmentRepository departmentRepo; private final EmployeeRepository employeeRepo; public DataLoader(DepartmentRepository departmentRepo, EmployeeRepository employeeRepo) { this.departmentRepo = departmentRepo; this.employeeRepo = employeeRepo; } @Override @Transactional public void run(ApplicationArguments args) { /* * STEP 1: Create a Department entity * No DB query yet because the entity is only created in memory. */ Department engineering = new Department(); engineering.setName("Engineering"); /* * STEP 2: Create Employee entities * These are also only in memory at this point. */ Employee alice = new Employee("Alice", "alice@example.com"); Employee bob = new Employee("Bob", "bob@example.com"); /* * STEP 3: Link employees to department * The helper method usually: * 1. Adds employee to department.employees list * 2. Sets employee.department = this * * This ensures both sides of the bidirectional relationship stay consistent. */ engineering.addEmployee(alice); engineering.addEmployee(bob); /* * STEP 4: Persist department * * Because the Department entity likely has: * * @OneToMany(mappedBy="department", cascade = CascadeType.ALL, orphanRemoval = true) * * Hibernate will cascade the persist operation to Employee entities. * * Expected SQL queries: * * INSERT INTO department (name) * INSERT INTO employee (name, email, department_id) * INSERT INTO employee (name, email, department_id) * * Total queries = 3 */ departmentRepo.save(engineering); /* * STEP 5: Read all departments * * Expected SQL: * SELECT * FROM department * * If employees collection is LAZY (recommended), * employees are NOT fetched until accessed. */ departmentRepo.findAll().forEach(d -> System.out.println("Dept: " + d.getName()) ); /* * STEP 6: Find department by ID * * Expected SQL: * SELECT * FROM department WHERE id = ? */ Optional dept = departmentRepo.findById(engineering.getId()); dept.ifPresent(d -> System.out.println("Found: " + d.getName())); System.out.println("Department loaded"); // employees should load only here dept.get().getEmployees().forEach(e -> System.out.println(e.getName())); /* * STEP 7: Update employee * * Changing Alice's name marks the entity as dirty. * * Expected SQL on flush/commit: * UPDATE employee SET name='Alice Smith' WHERE id=? */ alice.setName("Alice Smith"); employeeRepo.save(alice); /* * STEP 8: Remove employee from department * * Helper method typically: * 1. Removes Bob from department.employees list * 2. Sets bob.department = null * * Because orphanRemoval = true: * Hibernate will DELETE the employee record automatically. * * Expected SQL: * DELETE FROM employee WHERE id = ? */ engineering.removeEmployee(bob); /* * Saving department ensures Hibernate detects the orphan removal. */ departmentRepo.save(engineering); } }