JPA 일대다 관계에서 컬렉션 패치조인 성능 문제와 해결책

Agida·2025년 9월 16일

JPA

목록 보기
5/8
post-thumbnail

문제 상황

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();

이 쿼리는 다음과 같은 문제를 발생시킨다.

  1. 카티시안 곱(Cartesian Product) 발생: Team이 3개, 각 Team당 Member가 2명씩 있다면 총 6개의 행이 반환된다.
  2. 메모리 사용량 증가: 중복된 Team 데이터가 Member 수만큼 반복된다.
  3. 페이징 불가: 컬렉션 패치조인 시 JPA는 메모리에서 페이징을 수행한다.

실제 SQL 결과

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 수만큼 중복된다.

해결책 1: ToOne 관계는 페치조인, OneToMany는 배치 사이즈

기본 전략

  1. ManyToOne, OneToOne 관계: 페치조인 사용
  2. OneToMany, ManyToMany 관계: 배치 사이즈 최적화

배치 사이즈 설정

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(); // 배치로 한번에 로딩
}

실행되는 SQL

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번의 쿼리로 해결된다.

해결책 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, ...); -- 배치 사이즈만큼

성능 비교

컬렉션 페치조인 사용 시

  • Team 100개, 각각 Member 10명
  • 결과: 1000개 행 반환
  • 메모리: Team 데이터 10배 중복
  • 쿼리: 1번

배치 사이즈 최적화 시

  • Team 100개, 각각 Member 10명
  • 결과: Team 100개 + Member 1000개
  • 메모리: 중복 없음
  • 쿼리: 2번 (Team 1번 + Member 1번)

주의

배치 사이즈 설정 기준

  • 너무 작으면: 쿼리 횟수 증가
  • 너무 크면: 메모리 사용량 증가, DB 부하
  • 권장값: 100~1000 사이

페이징 처리

// 올바른 페이징 처리
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에서 일대다 관계 최적화는 다음 전략을 따른다.

  1. ToOne 관계: 페치조인으로 즉시 로딩
  2. OneToMany 관계: 지연로딩 + 배치 사이즈 최적화
  3. 페이징: 컬렉션 페치조인 없이 처리
  4. 배치 사이즈: 100~1000 사이 설정

이 방식으로 N+1 문제를 해결하면서도 메모리 효율성과 페이징 기능을 유지할 수 있다.

profile
백엔드

0개의 댓글