글을 시작하기 이전에 간단하게 정리하면
1:1 연관관계 : Fetch join
Collection 연관관계 : default_batch_fetch_size
N개의 컬렉션을 fetch join을 하면 MultipleBagFetchException이 발생한다.
특정 컬럼을 조회할 경우에 join을 하고 Projection을 Dto로 매핑을 한다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "MEMBER", uniqueConstraints = {
@UniqueConstraint(name = "MEMBER_EMAIL", columnNames = {"email"}),
})
public class Member extends BaseEntity{
@OneToMany(mappedBy = "member",fetch = FetchType.EAGER)
private List<Request> requests = new ArrayList<>();
}
------------------------------------------------------------------------------------
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class Request {
@ManyToOne
@JoinColumn(name = "member_id")
private Member member;
}
@BeforeEach
void setUp(){
List<Request> requestArrayList = new ArrayList<>();
IntStream.range(0, 10)
.mapToObj(i -> new Request("title" + i))
.forEach(requestArrayList::add);
requestRepository.saveAll(requestArrayList);
List<Member> memberArrayList = new ArrayList<>();
IntStream.range(0, 10)
.mapToObj(i -> Member.builder()
.name("member" + i)
.requests(requestArrayList)
.build())
.forEach(memberArrayList::add);
memberRepository.saveAll(memberArrayList);
entityManager.clear();
}
@Test
@Transactional
public void fix() throws Exception {
System.out.println("===============================================================");
List<Member> memberList = memberRepository.findAll();
System.out.println("===============================================================");
assertThat(memberList.size()).isNull();
}
===============================================================
- 회원 조회
Hibernate: select m1_0.id,m1_0.name from member_table m1_0
- Request 조회
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
===============================================================
===============================================================
Hibernate: select m1_0.id,m1_0.name from member_table m1_0
===============================================================
@Test
@Transactional
public void fix() throws Exception {
System.out.println("===============================================================");
memberRepository.findAll().stream().flatMap(member -> member.getRequests().stream()
.map(Request::getTitle))
.collect(Collectors.toList());
System.out.println("===============================================================");
assertThat(memberRepository.findAll().size()).isNull();
}
===============================================================
Hibernate: select m1_0.id,m1_0.name from member_table m1_0
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id=?
===============================================================
정리
Eager를 사용하든 Lazy를 사용하든 결국 동일하게 발생한다. Lazy를 사용하면 단지 프록시 객체로 가져오기 때문에 N+1이 데이터 사용하는 시점으로 미루는 것이지 해결하는 것은 아니다.
N+1 발생하는 이유
jpaRepository에 정의한 인터페이스 메서드를 실행하면 JPA는 메서드 이름을 분석해서 JPQL을 생성하여 실행하게 된다. JPQL은 SQL을 추상화한 객체지향 쿼리 언어로서 특정 SQL에 종속되지 않고 엔티티 객체와 필드 이름을 가지고 쿼리를 한다.
보통 JPA를 처음 사용하면 N+1 문제를 해결하는 방식을 Fetch Join을 사용해서 해결한다. 하지만 만능은 아니다. 일단 간단한 코드를 보고 장단점에 대해서 설명을 하겠다.
일단 기존의 코드를 기반으로 문제를 해결하겠다. 위에 Lazy를 처리하여도 N+1 문제가 발생을 하였다. 이걸 Lazy Loading, Fetch Join을 통하여 해결하면 다음과 같다.
@Query("select distinct m from Member m join fetch m.requests")
List<Member>findAllRelatedRequest();
@Test
@Transactional
public void fix() throws Exception {
System.out.println("===============================================================");
memberRepository.findAllRelatedRequest().stream().flatMap(member -> member.getRequests().stream()
.map(Request::getTitle))
.collect(Collectors.toList());
System.out.println("===============================================================");
assertThat(memberRepository.findAll().size()).isNull();
}
===============================================================
Hibernate: select distinct m1_0.id,m1_0.name,r1_0.member_id,r1_0.id,r1_0.title
from member_table m1_0
join request r1_0 on m1_0.id=r1_0.member_id
===============================================================
N+1 문제를 fetch join으로 해결할 수 없다. 일단 fetch join에 대해서 설명하자면 우리가 알고 있는 join과 차이가 있다.
fetch join은 orm에서 사용하며 디비 스키마를 엔티티로 자동 변환 > 영속성 컨텍스트에 영속화를 해준다.
이러한 특징 덕분에 fetch join을 해서 가져온 연관 관계가 있는 1차 캐시에 저장이 되고 다시 조회를 하여도 쿼리를 수행하지 않는다.
하지만 일반 join쿼리는 단순히 sql에서 데이터를 조회하는 개념이기 때문에 영속성 컨텍스트와 관련이 없다. 이것이 패러다임의 차이이며 fetch join은 이를 줄여주는 역활을 한다.
SQL Distinct / JPQL Distinct 차이
- SQL에서 Distinct절은 DB에서 수행되며 JOIN 발생한 데이터 형태에서 각 ROW를 비교하여 다른 경우만 남긴다. 하지만 JPA의 Distinct는 엔티티객체에 대해서 Distinct를 수행을 합니다.
처음에 컬렉션을 2개를 FETCH JOIN을 하면 이 오류가 발생한다.
즉 하나만 FETCH JOIN을 해야합니다.
테스트를 위해서 엔티티를 하나 추가를 시키고 오류를 살펴보겠다.
@OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
private List<Request> requests = new ArrayList<>();
@OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
private List<Notice> notices = new ArrayList<>();
@Query("select distinct m from Member m join fetch m.requests join fetch m.notices")
List<Member>findAllRelatedRequest();
org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.example.localcache.domain.Member.notices, com.example.localcache.domain.Member.requests]
at org.springframework.orm.jpa.EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible(EntityManagerFactoryUtils.java:371)
at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:246)
2022-01-16 12:37:18.309 WARN 39536 --- [ main] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
batchsize는 몇으로 적용을 해야되나요??
일반적으로 100~1000으로 설정을 합니다. 하지만 dbms에 따라서 where in절은 1000까지 제한하는 경우가 있기 때문에 1000이상은 설정하지 않는다. 그렇다고 너무 크게하면 was에서 메모리에 로딩하디에 오버헤드가 발생하기 때문에 서비스에 맞게 적절하게 설정을 해야합니다.
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
show_sql: true
default_batch_fetch_size: 100
@BatchSize(size = 10)
@OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
private List<Request> requests = new ArrayList<>();
===============================================================
Hibernate: select m1_0.id,m1_0.name from member_table m1_0
Hibernate: select r1_0.member_id,r1_0.id,r1_0.title from request r1_0 where r1_0.member_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
===============================================================
Hibernate: select m1_0.id,m1_0.name from member_table m1_0
결론
1. 컬렉션 n개 조회 : batch size 해결
2. 쿼리 개수 : fetch join이 1개의 쿼리로 해결 > batch size는 in절 + 추가적인 쿼리 ( 최적화)
3. 데이터 전송 : fetch join은 join을 하고 중복 데이터를 많이 가져옴 batch size가 유리
EntityGraph는 어노테이션 방식으로 편하게 N+1 문제를 해결할 수 있다. 하지만 여기서 trade off가 발생한다. 사실은 Lazy Loading을 Eager Loading으로 부분적으로 전환하는 기능입니다.
여러 1:N 연관관계를 한번에 Join해 올 수 있습니다. FetchJoin의 경우 1개의 Collection까지만 같이 Join하여 조회할 수 있습니다.
@EntityGraph(attributePaths = {"requests"})
@Query("select o from Member o")
List<Member>findAllEntityGraph();
select new 패키지 경로.Dto(원하는 필드)
from Member m
join m.request r
where m.id=r.id
글을 시작하기 이전에 간단하게 정리하면
1:1 연관관계 : Fetch join
Collection 연관관계 : default_batch_fetch_size
N개의 컬렉션을 fetch join을 하면 MultipleBagFetchException이 발생한다.
특정 컬럼을 조회할 경우에 join을 하고 Projection을 Dto로 매핑을 한다.
https://junhyunny.github.io/spring-boot/jpa/jpa-fetch-join-paging-problem/