요청이 1개의 쿼리로 처리 되길 기대했는데 N개의 추가 쿼리가 발생하는 현상
1개의 쿼리를 실행하려고 했는데 N개의 쿼리가 추가로 실행되는 것이기 때문에
1+N
이라고 이해하면 더 직관적이다
N+1 문제는 많이 상황에서 발생한다.
@ManyToOne, @OneToOne, @ManyToMany, @OneToMany, 양방향, 단방향 ...
이번 게시물에서는 @OneToMany
에서 N+1 문제가 발생하는 상황에 대해서 알아보려고 한다.
@OneToMany(mappedBy="크루", fetch=FetchType.LAZY)
List<할 일> 목록
크루_Repository.findAll()
로 크루 목록들을 조회해본다고 가정하자
이를 쿼리문으로 바꾸면 select * from crew
가 된다.
🖋️ proxy 객체란?
- Hibernate가 제공하는 가짜 객체
- 실제 데이터를 즉시 로딩하는 것이 아니라, 필요할 때 데이터베이스에서 가져오는 방식
// 크루 엔티티 조회 (할 일 목록은 지연 로딩) Crew crew = entityManager.find(Crew.class, 1L); // 이 시점에서는 '할 일 목록'을 조회하지 않음 System.out.println("크루 이름: " + crew.getName()); // 할 일 목록을 조회하는 순간 쿼리 실행됨 (지연 로딩 발생) System.out.println("할 일 개수: " + crew.get목록().size());
- 지연 로딩된 프록시 객체는 트랜잭션이 끝난 후 접근하면 오류 발생
Crew crew = crewRepository.findById(1L).get(); // 할 일 목록은 아직 프록시 상태 em.close(); // 영속성 컨텍스트 종료 // 여기서 할 일 목록을 조회하면 LazyInitializationException 발생 System.out.println(crew.get목록().size());
이때까지는 쿼리가 하나만 나가는데, 크루당 할 일이 많아보여서 크루 한 명당 몇개의 할 일을 하는지 알아보고 싶어짐.
for (크루 크루: 크루들) {
크루1.목록(proxy).getSize()
}
첫번째 크루를 확인해보니까 proxy라서 jpa가 1차 캐시 저장소에서 크루1 목록이 있는지 확인해본다. 여기서 해당되는 데이터가 없기 때문에 select * from 할일 where 크루_id = 1
이라는 쿼리를 날려서 데이터를 가져오게 된다. 이게 모든 크루에서 반복된다.
실제로 실행되는 쿼리를 보면 위와 같다.
2개의 데이터만 조회한다면 큰 문제가 없겠지만, 100만 데이터를 조회해야한다면 성능에 큰 부하가 생길 것이다.
해결 방법에는 fetch join
를 활용하거나 @EntityGraph
를 사용해서 해결하는 방법이 있다. fetch join
을 활용해서 해결하는 방법에 대해 알아보자.
@Query("select c from 크루 c left join fetch c.목록")
List<크루> findAllJPQLFetch();
JPQL이란?
Java Persistence Query Language
엔티티를 대상으로 쿼리 작성
이 해결방법을 쿼리문으로 알아보면
select 크루.*, 할일.* from 크루 join fetch 할일
이 된다.
위 쿼리문을 실행시키면 아까와 달리 실제 객체를 가지고 오게 된다.
아까처럼 크루들의 할 일을 조회해보면
for (크루 크루: 크루들) {
크루1.목록(진짜).getSize();
}
처럼 진짜 객체들을 꺼내게 된다.
즉, 최초에 관련된 데이터를 한꺼번에 가져와서 객체화 해줬기 때문에
DB를 거치지 않고 데이터를 꺼내서 반환한다.
-> 1개의 쿼리로 문제 해결!
해당 부분을 쿼리로 살펴보면 아래와 같다.
이전에는 크루만 조회했는데 크루와 할 일 목록 모두 전부 조회하게 된다. 추가 쿼리 없이 객체에서 데이터 꺼내쓰기!
No!! 즉시(Eager) 로딩을 써도 N+1 문제가 발생한다.
JPQL이 즉시 로딩 쿼리를 만들 때를 한번 가정해보자.
크루 entity에서 findAll sql 만들어본다고 가정한다면,
처음 쿼리를 만들 때 크루에 연관관계가 있는 엔티티는 신경 안 쓰고 조회 대상이 되는 Entity 기준으로만 쿼리를 만든다.
위와 같이 크루만! 가져오게 된다. (연관관계 있는 엔티티만 관심 있음)
그 후, 어? 연관된 엔티티가 있네. 글로벌 패치 전략이 뭐였지?
라고 이제서야 관심 갖게 된다.
@OneToMany(mappedBy = "크루", fetch = FetchType.EAGER)
이 실행되면서 바로 조회해서 갖고 오게 되고
이때 즉시! N번의 추가 쿼리가 발생하게 된다..
즉, 위와 같은 쿼리문이 발생된다.
결론적으로,
즉시로딩을 최대한 사용하지 말고, 지연로딩 + fetch join 조합을 쓰면 된다.
대표적인 fetch join 문제 상황으로는
OneToMany 관계에서 페이징 처리할 때이다.
firstResult/maxResults specified with collection fetch; applying in memory!
위 오류는 ManyToOne을 사용하거나 @BatchSize()를 활용해서 해결하면 된다.
🗯️ 다른 상황에서 n+1문제가 일어날 땐 어떻게 해야하는지, @EntityGraph
사용하면 어떻게 n+1 문제를 해결할 수 있는지, 마지막에 메모리 부하가 일어났을 때는 어떻게 해결하면 좋을 지 추가로 공부하면 좋을 듯하다!