연관관계 매핑 — @OneToMany는 왜 이렇게 복잡할까
주문과 주문 상품, 사용자와 게시글처럼 엔티티 사이에 관계가 있을 때, JPA에서는 이 관계를 어떻게 표현할까요? 그리고 @OneToMany가 왜 그렇게 많은 함정을 가지고 있을까요?
개념 정의
연관관계 매핑 은 엔티티 간의 참조 관계를 JPA 어노테이션으로 정의하는 것입니다. 객체의 참조와 테이블의 외래 키를 매핑하여, 객체 그래프 탐색을 통해 연관 데이터에 접근할 수 있게 합니다.
왜 필요한가
연관관계 매핑 없이는 외래 키를 직접 다뤄야 합니다.
// 연관관계 매핑 없이
public class Order {
private Long id;
private Long userId; // 외래 키를 직접 보유
public User getUser() {
// userId로 직접 조회해야 함
return userRepository.findById(userId);
}
}
연관관계 매핑이 있으면 객체처럼 자연스럽게 탐색합니다.
// 연관관계 매핑 사용
public class Order {
@ManyToOne
private User user; // 객체 참조
// order.getUser().getName() — 자연스러운 탐색
}
내부 동작
연관관계 종류
| 관계 | 예시 | 어노테이션 |
|---|---|---|
| 다대일 | 주문 → 사용자 | @ManyToOne |
| 일대다 | 사용자 → 주문들 | @OneToMany |
| 일대일 | 사용자 → 프로필 | @OneToOne |
| 다대다 | 학생 ↔ 수업 | @ManyToMany |
연관관계의 주인
양방향 연관관계에서 외래 키를 관리하는 쪽 이 주인입니다.
[DB 테이블]
orders 테이블: id, user_id(FK), amount
users 테이블: id, name
→ 외래 키(user_id)는 orders에 있음
→ 연관관계의 주인은 Order 엔티티 (@ManyToOne)
→ User의 @OneToMany에 mappedBy 지정
DB에서 외래 키는 항상 N 쪽(다) 테이블에 있기 때문에, @ManyToOne이 있는 엔티티가 연관관계의 주인이 됩니다. 따라서 주인이 아닌 쪽(@OneToMany)에서 값을 변경해도 외래 키에는 반영되지 않습니다.
@ManyToOne — 가장 기본이고 중요한 매핑
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY) // 권장: LAZY
@JoinColumn(name = "user_id") // 외래 키 컬럼명
private User user; // 연관관계의 주인
private int amount;
}
@ManyToOne의 기본 fetch 전략은 EAGER이지만, 실무에서는 반드시 LAZY로 변경해야 합니다. EAGER는 항상 JOIN 쿼리를 실행해서 불필요한 데이터를 로드합니다.
@OneToMany — 양방향의 반대쪽
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "user") // 주인이 아님, Order.user 필드에 매핑됨
private List<Order> orders = new ArrayList<>();
}
mappedBy = "user"는 "Order 엔티티의 user 필드가 외래 키를 관리한다"는 의미입니다.
코드 예제
양방향 편의 메서드
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
// 양방향 편의 메서드
public void setUser(User user) {
// 기존 관계 제거
if (this.user != null) {
this.user.getOrders().remove(this);
}
this.user = user;
if (user != null) {
user.getOrders().add(this);
}
}
}
편의 메서드가 없으면 이런 문제가 생깁니다.
User user = new User("홍길동");
Order order = new Order(10000);
// 주인 쪽만 설정
order.setUser(user);
em.persist(order);
// 같은 트랜잭션 내에서 user.getOrders()를 조회하면?
System.out.println(user.getOrders().size()); // 0! (1차 캐시에서 가져오므로)
cascade — 영속성 전이
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
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);
}
}
// cascade 사용 시 — Order만 persist하면 OrderItem도 함께 저장
Order order = new Order();
order.addItem(new OrderItem("상품A", 1000));
order.addItem(new OrderItem("상품B", 2000));
em.persist(order); // INSERT order + INSERT orderItem × 2
CascadeType 종류
CascadeType.PERSIST // 저장 시 함께 저장
CascadeType.MERGE // 병합 시 함께 병합
CascadeType.REMOVE // 삭제 시 함께 삭제
CascadeType.REFRESH // 새로고침 시 함께 새로고침
CascadeType.DETACH // 분리 시 함께 분리
CascadeType.ALL // 위 모두 (주의 필요)
cascade는 소유자가 명확할 때만 사용합니다. OrderItem은 Order 없이 존재할 수 없으므로 cascade가 적합합니다. 하지만 User에 대한 cascade.REMOVE는 위험합니다 — 사용자 삭제 시 모든 주문이 삭제될 수 있습니다.
orphanRemoval — 고아 객체 제거
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
Order order = em.find(Order.class, 1L);
order.getItems().remove(0); // 컬렉션에서 제거
// orphanRemoval = true이면
// → DELETE FROM order_item WHERE id = ? (DB에서도 삭제)
// orphanRemoval = false이면
// → UPDATE order_item SET order_id = NULL (외래 키만 null)
@OneToOne
@Entity
public class User {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private UserProfile profile;
}
@Entity
public class UserProfile {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String bio;
private String avatarUrl;
@OneToOne(mappedBy = "profile")
private User user;
}
주의: @OneToOne의 주인이 아닌 쪽(mappedBy)에서는 LAZY 로딩이 동작하지 않습니다. JPA가 null인지 프록시인지 판단하려면 반대쪽 테이블을 조회해야 하기 때문입니다.
@ManyToMany — 왜 실무에서 피하는가
// 비추천: @ManyToMany 직접 사용
@Entity
public class Student {
@ManyToMany
@JoinTable(name = "enrollment",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))
private List<Course> courses = new ArrayList<>();
}
문제: 중간 테이블에 추가 컬럼(수강 날짜, 성적 등)을 넣을 수 없습니다.
// 추천: 중간 엔티티를 직접 만들기
@Entity
public class Enrollment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "student_id")
private Student student;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
private Course course;
private LocalDate enrolledDate; // 추가 필드 가능
private String grade;
}
무한 참조 방지 (JSON 직렬화)
// 양방향 관계에서 JSON 직렬화 시 무한 루프 발생
// 해결: DTO를 사용하거나 @JsonIgnore/@JsonManagedReference
// 가장 좋은 방법: 엔티티를 직접 반환하지 않고 DTO 사용
public record OrderResponse(Long id, int amount, String userName) {
public static OrderResponse from(Order order) {
return new OrderResponse(order.getId(), order.getAmount(),
order.getUser().getName());
}
}
주의할 점
CascadeType.ALL은 의도치 않은 대량 삭제를 일으킬 수 있다
CascadeType.ALL은 REMOVE를 포함하므로, 부모 엔티티를 삭제하면 연관된 자식이 ** 모두 삭제 **됩니다. 자식이 다른 엔티티에서도 참조되고 있다면 데이터 정합성이 깨질 수 있습니다. cascade는 ** 소유자가 명확한 관계 **(Order → OrderItem)에서만 사용해야 합니다.
@OneToOne의 mappedBy 쪽은 LAZY가 동작하지 않는다
@OneToOne 관계에서 주인이 아닌 쪽(mappedBy가 있는 쪽)에서는 **LAZY 로딩이 동작하지 않습니다 **. JPA가 프록시를 생성하려면 null인지 아닌지를 알아야 하는데, 이를 확인하려면 반대쪽 테이블을 조회해야 하기 때문입니다.
양방향 관계에서 편의 메서드 없이 한쪽만 설정하면 1차 캐시 불일치가 발생한다
양방향 연관관계에서 주인 쪽만 설정하면 DB에는 반영되지만, ** 같은 트랜잭션의 1차 캐시에서는 반대쪽 참조가 비어있습니다 **. 양방향 편의 메서드로 양쪽을 동기화해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| 연관관계 주인 | 외래 키가 있는 쪽 (@ManyToOne) |
| fetch 전략 | @ManyToOne은 반드시 LAZY로 설정 |
| 편의 메서드 | 양방향 관계에서 양쪽 참조를 동기화 |
| cascade | 소유자가 명확한 관계에서만 사용 |
| @ManyToMany | 중간 엔티티로 풀어서 사용 |
| API 응답 | 엔티티 대신 DTO로 변환하여 반환 |