JPA 1:N 페이징 처리 불가에 대해서

JPA 에 대해서 어느 정도 알고 있다고 가정하고 정리한 글입니다.

 

JPA 에서  1:N(OneToMany) 연관관계에서 페이징이 불가능한 이유는 JPA 가 SQL 을 생성하는 방식과 관련이 있습니다.

 

JPA 의 OneToMany 기본 매핑

@Entity
public class Member {

  @Id @GeneratedValue
  private Long id;
  private String name;
    
  @OneToMany(mappedBy = "member")
  private List<Order> orders = new ArrayList<>();
}

 

위의 코드는 Member -> Order 는 1:N 관계입니다.

 

JPA 가 SQL 을 어떻게 날리는가?

Member 를 조회할 때 orders 정보도 필요하여 orders Collection 을 fetch join 으로 함께 가져온다고 가정해 봅시다.

select m from Member m join fetch m.orders

 

이렇게 하면 JPA 는 SQL 을 다음처럼 번역을 하게 됩니다.

SELECT m.id, m.name, o.id, o.name
FROM member m JOIN orders o
ON m.id = o.member_id

 

여기서 문제점은 "데이터 뻥튀기" 가 발생한다는 점입니다.

예를 들어 Member 가 1명이 있고, Order 가 3개라면 결과는 아래와 같습니다.

m.id m.name o.id o.name
1 홍길동 10 주문1
1 홍길동 11 주문2
1 홍길동 12 주문3

 

즉, Member 하나가 Order 개수만큼 중복으로 나오게 됩니다.

JPA 는 이걸 다시 컬렉션에 묶어서 member 객체에 넣어줍니다.

 

그런데 여기서 페이징을 추가한다면?

여기에 setFirstResult(0), setMaxResults(10) 같은 페이징 조건을 건다면, SQL 레벨에서는 "뻥튀기 된 결과" 를 기준으로 페이징이 적용이 됩니다.

즉, Member 기준으로 페이징을 하는 게 아니라, Member * Order 결과를 기준으로 페이징이 이루어져 데이터가 꼬이거나 원하는 Member 를 다 못 가져오는 문제가 발생하게 됩니다.

결국 이러한 문제 때문에 JPA 는 1:N 관계에서는 Fetch Join + 페이징을 금지하고 있습니다.

 

Spring 에서도 아래와 같은 오류를 반환해 줍니다.

"cannot use fetch join with collection in paging"

 

해결방법

여러 해결방법이 있지만 일반적인 해결방법은 일대다 컬렉션이 있을 경우 바로 패치조인을 사용하지 않는 것입니다.

우선 아래와 같이 Member 만 페이징 조회를 합니다.

em.createQuery("select m from Member m", Member.class)
  .setFirstResult(0)
  .setMaxResults(10)
  .getResultList();

 

이후 for 문을 통해서 orders 정보를 가져오면 됩니다.

하지만 위와 같은 해결방법으로 페이징 문제는 해결했지만 N+1 이라는 문제점이 남아 있습니다.

해결방법은 맨 아래의 예시 코드가 있습니다.
다대일(ManyToOne) 또는 일대일(OneToOne) 관계는 패치조인 + 페이징이 가능합니다.

 

추가적인 N+1 문제

JPA 를 조금 공부해 본 개발자라면 N+1 문제를 맞닥뜨린 경험이 있으실 것입니다.

@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();

 

위와 같은 코드가 있을 경우 OneToMany 는 default 값이 Lazy Loading 입니다.

따라서 createQuery 로 따로 fetch join 을 하지 않는 이상 proxy 로 orders 가 저장되어 있다가 orders 가 필요한 상황에서 for 문을 돌려 orders.getName() 을 호출 할 경우, 그 때 Order Table 을 조회하는 query 문이 발생하게 됩니다. 이때 Member(1) 에 Order(100) 개가 저장된 경우 100 번의 Query 문이 실행되는 큰 문제가 발생하게 됩니다.

 

N+1 문제를 막으려면 @BatchSize 또는 hibernate.default_batch_fetch_size 를 사용해서 orders 를 IN 쿼리로 한번에 불러오시면 됩니다.

query 문이 많이 실행되지 않는 경우 fetch join 을 사용해도 됩니다.
예를 들어, 한 회원의 주문이 3~4개 밖에 없는 경우...

 

실전예제

부모(Member) 만 페이징 조회 -> 지연로딩 + BatchSize 로 자식(Orders) 조회

 

@Entity
public class Member {

  @Id @GeneratedValue
  private Long id;
    
  private String name;
    
  @OneToMany(mappedBy = "member")
  @BatchSize(size = 100)
  private List<Order> orders = new ArrayList<>();
}

@Entity
public class Order {

  @Id @GeneratedValue
  private Long id;
    
  private String productName;
    
  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "member_id")
  private Member member;
}

 

@BatchSize(size = 100) 은 최대 100개의 memberId 를 모아서 IN 쿼리로 한 번에 orders 를 가져오도록 JPA 에게 지시하는 것입니다. 만약 글로벌로 설정하고 싶다면 application.yml 에 아래처럼 설정해 주시면 됩니다.

spring:
  jpa:
    properties:
      hibernate.default_batch_fetch_size: 100

 

이렇게 설정을 하시고 서비스계층에서 EntityManger 를 사용하시면 됩니다.

@Service
@Transsactional(readOnly = true)
public class MemberService {

  @PersistenceContext
  private EntityManger em;
  
  public void printMemberWithOrders(int page, int size) {
    List<Member> members = em.createQuery("select m from Member m", Member.class)
      .setFirstResult(page * size)
      .setMaxResults(size)
      .getResultList();
    
    for(Member member : members) {
      System.out.println("회원 : " + member.getName());
      
      // 여기서 orders 접근 -> 지연로딩 (1+N -> 1+1)
      for(Order order : member.getOrders()) {
        System.out.println("주문 : " + order.getProductName());
      }
    }
      
  }
}

 

실행 쿼리 흐름

1단계

부모(Member) 페이징을 조회합니다.

SELECT m.id, m.name
FROM member m
LIMIT ? OFFSET ?;

 

2단계

자식(Order) 조회 (BatchSize 적용 -> IN 절)

SELECT o.id, o.product_name, o.member_id
FROM orders o
WHERE o.member_id IN(1,2,3);

 

원래라면 Member 마다 개별 쿼리(WHERE member_id=?) 가 3번 나갔을 건데,

@BatchSize(size = 100) 덕분에 한방쿼리로 합쳐지게 됩니다.

여기서 중요한 점은 IN 쿼리로 가져오는 것을 size 로 설정한 값에 따라 자동으로 설정해 준다는 것입니다.

 

아래는 개별 쿼리가 3번 나갔을 경우의 예시입니다.

SELECT * FROM orders WHERE member_id = 1;
SELECT * FROM orders WHERE member_id = 2;
SELECT * FROM orders WHERE member_id = 3;

 

꿀팁

1. XxxToOne 관계는 모두 fetch join 해도 된다.

ManyToOne, OneToOne 같은 관계는 fetch join 으로 항상 묶어서 가져와도 안전합니다.

왜냐하면 N:1 조인을 하면 결과가 "뻥튀기" 되지 않기 때문입니다.

 

2. 조회되는 데이터가 적을 때는 fetch join 해도 된다.

예를 들어 관리자 페이지에서 회원 + 주문 2~3건만 확인되는 경우라면,

fetch join 을 해도 성능상 큰 문제가 발생하지 않습니다.

 

3. 단, N 쪽에 수많은 데이터가 있다면 성능 최적화 고려 필요

fetch join 은 DB 에서 모든 조인 결과를 한번에 가져오기 때문에 N 에 수많은 데이터가 있다면 고려해 볼 필요가 있습니다.

 

'SQL' 카테고리의 다른 글

오라클 SQL - ORDER BY  (1) 2024.12.17
오라클 IN 연산자  (1) 2024.12.17
오라클 Column 추가, 삭제, 변경  (1) 2024.12.16
오라클 테이블 만드는 방법( CREATE, PK, INDEX, COMMENT )  (0) 2024.12.16
SQL 기본키, 외래키  (1) 2024.12.09