JPA에서 일대다(OneToMany) 관계를 처리할 때 컬렉션 패치조인(Collection Fetch Join)을 사용하면 성능 저하 문제가 발생한다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
컬렉션 패치조인을 사용한 JPQL 쿼리다.
// 문제가 되는 쿼리
String jpql = "select t from Team t join fetch t.members";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();
이 쿼리는 다음과 같은 문제를 발생시킨다.
SELECT t.id, t.name, m.id, m.name, m.team_id
FROM team t
INNER JOIN member m ON t.id = m.team_id;
Team이 3개, 각각 2명의 Member를 가진다면:
team_id | team_name | member_id | member_name
1 | TeamA | 1 | MemberA1
1 | TeamA | 2 | MemberA2
2 | TeamB | 3 | MemberB1
2 | TeamB | 4 | MemberB2
3 | TeamC | 5 | MemberC1
3 | TeamC | 6 | MemberC2
Team 데이터가 Member 수만큼 중복된다.
application.yml 설정
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
엔티티별 개별 설정
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@BatchSize(size = 100)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members = new ArrayList<>();
}
// 1단계: Team만 조회 (ToOne 관계가 있다면 페치조인)
String jpql = "select t from Team t";
List<Team> teams = em.createQuery(jpql, Team.class)
.setFirstResult(0)
.setMaxResults(10)
.getResultList();
// 2단계: 지연로딩 시 배치 사이즈로 최적화
for (Team team : teams) {
team.getMembers().size(); // 배치로 한번에 로딩
}
1단계 - Team 조회
SELECT t.id, t.name FROM team t LIMIT 10;
2단계 - Members 배치 조회 (배치 사이즈 100)
SELECT m.team_id, m.id, m.name
FROM member m
WHERE m.team_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
총 2번의 쿼리로 해결된다.
실제 개발에서는 더 복잡한 연관관계가 존재한다.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
// ToOne 관계 - 페치조인 대상
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
// OneToMany 관계 - 배치 사이즈 대상
@BatchSize(size = 1000)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
}
@Entity
public class OrderItem {
@Id @GeneratedValue
private Long id;
// ToOne 관계 - 페치조인 대상
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
}
// ToOne 관계만 페치조인
String jpql = "select o from Order o join fetch o.member";
List<Order> orders = em.createQuery(jpql, Order.class).getResultList();
// OneToMany는 배치 사이즈로 처리
for (Order order : orders) {
List<OrderItem> items = order.getOrderItems();
items.size(); // 배치로 로딩
// OrderItem의 Item도 배치로 로딩
for (OrderItem orderItem : items) {
orderItem.getItem().getName();
}
}
1단계 - Order와 Member 조회
SELECT o.id, o.member_id, m.id, m.name
FROM orders o
INNER JOIN member m ON o.member_id = m.id;
2단계 - OrderItem 배치 조회
SELECT oi.order_id, oi.id, oi.item_id, oi.count
FROM order_item oi
WHERE oi.order_id IN (1, 2, 3, 4, 5, ...); -- 배치 사이즈만큼
3단계 - Item 배치 조회
SELECT i.id, i.name, i.price
FROM item i
WHERE i.id IN (1, 2, 3, 4, 5, ...); -- 배치 사이즈만큼
// 올바른 페이징 처리
String jpql = "select t from Team t";
List<Team> teams = em.createQuery(jpql, Team.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
// 잘못된 페이징 (컬렉션 페치조인 시)
String jpql = "select t from Team t join fetch t.members";
// 경고: firstResult/maxResults specified with collection fetch; applying in memory!
JPA에서 일대다 관계 최적화는 다음 전략을 따른다.
이 방식으로 N+1 문제를 해결하면서도 메모리 효율성과 페이징 기능을 유지할 수 있다.