JPQL에서 동작한 쿼리를 통해서 members에 데이터가 바인딩 됩니다. JPA가 글로벌 페치 전략을 받아들이지만 지연 로딩이기 때문에 추가적인 SQL을 발생시키지 않습니다. 하지만 위에서 본 예제처럼 레이지 로딩으로 추가적인 작업을 진행하게 되면 결국 N+1 문제가 발생하게 됩니다.
@BatchSize(size = 5)// Batch size를 지정한다 @OneToMany(mappedBy = "member", fetch = FetchType.EAGER)// 즉시 로딩으로 설정 var orders: List<Order> = emptyList() privateset }
@BatchSize(size = 5) 에노 테이션을 통해서 설정한 size 만큼 데이터를 미리 로딩 합니다. 즉 연관된 엔티티를 조회할 때 size 만큼 where in 쿼리를 통해서 조회하게 되고 size를 넘어가게 되면 추가로 where in 쿼리를 진행합니다. 하지만 글로벌 페치 전략을 변경해야 하며, 정해진 Batch size 만큼 조회되는 단점도 있습니다.
interfaceMemberRepository : JpaRepository<Member, Long> { @Query( "select m from Member m left join fetch m.orders" ) funfindAllWithFetch(): List<Member> }
1 2 3 4 5 6 7 8 9
@Test internalfun `페치 조인 사용`() { val members = memberRepository.findAllWithFetch()
// 조회한 모든 회원에 대해서 조회하는 경우에도 N+1 문제가 발생하지 않음 for (member in members) { println("order size: ${member.orders.size}") } }
가장 많이 사용하는 방법인 fetch을 통해서 조인 쿼리를 진행하는 것입니다. fetch 키워드를 사용하게 되면 연관된 엔티티나 컬렉션을 한 번에 같이 조회할 수 있습니다. 즉 페치 조인을 사용하게 되면 연관된 엔티티는 프록시가 아닌 실제 엔티티를 조회하게 되므로 연관관계 객체까지 한 번의 쿼리로 가져올 수 있습니다.
위 로그를 보면 SQL을 통해서 한 번에 데이터를 가져옵니다. Order Size: 10을 보면 N+1이 발생하지 않고 있습니다. 그렇다면 fetch 키워드를 제거하면 어떻게 될까요?
@Query( "select m from Member m join m.orders" ) funfindAllWithFetch(): List<Member> }
1 2 3 4 5 6 7 8 9
@Test internalfun `페치 조인 키워드 제거`() { val members = memberRepository.findAllWithFetch() // 페치 타입 Lazy 경우
// 페치 조인하지 않은 상태에서는 N+1 문제 발생 for (member in members) { println("order size: ${member.orders.size}") } }
출력되는 SQL을 보면 조인을 통해서 연관관계 컬렉션까지 함께 조회되는 것으로 생각할 수 있습니다. 하지만 JPQL은 결과를 반환할 때 연관관계까지 고려하지 않고 select 절에 지정한 엔티티만 조회하게 됩니다. 따라서 컬렉션은 초기화하지 않은 컬렉션 레퍼를 반환하게 되고 컬렉션이 없기 때문에 Lazy 로딩이 발생하게 되고 결과적으로 N+1 문제가 발생하게 됩니다.
interfaceMemberRepository : JpaRepository<Member, Long> { @Query( value = "select m from Member m left join fetch m.orders", countQuery = "select count(m) from Member m" ) funfindAllWithFetchPaging(pageable: Pageable): Page<Member> }
1 2 3 4 5 6 7 8 9 10 11
@Test internalfun `컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다`() { val page = PageRequest.of(0, 10) val members = memberRepository.findAllWithFetchPaging(page)
// 조회한 모든 회원에 대해서 조회하는 경우에도 N+1 문제가 발생하지 않음 for (member in members) { println("order size: ${member.orders.size}") } }
이전에 Paging 처리 Fetch Join 적용 시 limit 동작하지 않는 이슈에서도 다룬 적 있습니다. 해당 쿼리에서는 limit offset 관련된 쿼리문이 없습니다. 하이버네이트에서 컬렉션을 페치 조인하고 페이지 API를 사용하면 메모리에서 페이징 처리를 진행합니다. 즉 데이터베이스에서는 FULL Scan 한 이후 모든 데이터를 메모리에 올린 이후 limit에 맞게 데이터를 만들게 됩니다. 우선 데이터베이스에 Full Sacn 하는 것도 문제지만 그것을 메모리에 올리기 때문에 메모리를 심하게 잡아먹게 됩니다. 컬렉션이 아닌 단일 값 연관 필드의 경우에는 페치 조인을 사용해도 페이징 API를 사용할 수 있습니다.
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)// 새로운 컬렉션 쿠폰 추가 var coupons: List<Coupon> = emptyList() privateset }
1 2 3 4 5
@Query( value = "select m from Member m left join fetch m.orders left join fetch m.coupons", countQuery = "select count(m) from Member m" ) funfindAllWithFetchPaging2(pageable: Pageable): Page<Member>
1 2 3 4 5 6 7 8 9 10
@Test internalfun `둘 이상 컬렉션을 페치할 수 없다`() { val page = PageRequest.of(0, 10) val members = memberRepository.findAllWithFetchPaging2(page)
// 조회한 모든 회원에 대해서 조회하는 경우에도 N+1 문제가 발생하지 않음 for (member in members) { println("order size: ${member.orders.size}") } }
컬렉션의 카테시안 곱이 만들어지므로 하이버네이트는 주의해야 합니다. 하이버네이트는 annot simultaneously fetch multiple bag 예외가 발생하게 됩니다. 가장 쉬운 해결 방법으로는 자료형을 List -> Set으로 변경하는 것입니다.