N+1 Query Problem이란
연관관계에서 발생하는 이슈로 쿼리 1번으로 N건을 가져왔는데, 관련 컬럼을 얻기 위해 쿼리를 N번 추가 수행하는 문제이다.
(쿼리결과 건수마다 참조 정보를 얻기 위해 건수만큼 또 반복해 쿼리를 수행하게 되는 문제)
예를 들어 쿼리 한 번으로 10명의 회원을 조회했는데 이 회원들의 주문을 추가로 조회하기 위해 다시 N번(10번)을 추가로 실행하는 것이 N+1 문제이다.
이는 엔티티 글로벌 페치 전략에 즉시로딩을 사용할 때 발생할 수 있다.
em.find() 메소드를 통해 엔티티를 조회할 때 연관된 엔티티를 로딩하는 전략을 즉시 로딩(FetchType.EAGE
)을 사용한다면 DB에 JOIN 쿼리를 사용해 한 번에 연관된 엔티티까지 조회한다.
⚠️ FetchType.EAGE
설정으로 인해 N+1 문제가 발생하는 것은 아니다.
FetchType.LAZY
로 설정하는 것은 연관관계 데이터를 프록시 객체로 바인딩한다는 것. 하지만 실제로 연관관계 엔티티를 프록시만으로는 사용하지 않는다.
(실제로는 연관관계 엔티티의 멤버 변수를 사용하거나 가공하는 일은 코드를 구현하는 경우가 훨씬 흔함)
FetchType
설정은 단지 N+1 발생 시점을 연관관계 데이터를 사용하는 시점으로 미룰지, 초기 데이터 로드 시점에 발생시킬지의 차이이다.
jpaRepository에 정의된 인터페이스 메소드를 실행하면 JPA는 메소드 이름을 분석해 JPQL을 생성해 실행한다.
JPQL은 SQL을 추상화한 객체지향 쿼리 언어로 특정 SQL에 종속되지 않고 엔티티 객체 와 필드 이름 을 가지고 쿼리를 날린다. 그렇기 때문에 JPQL은 findAll()
이란 메소드를 수행하였을 때 해당 엔티티를 조회하는 (위의 예시라면)select * from member
쿼리만 실행하게 되는 것이다.
(즉시로딩, 지연로딩과 같은 글로벌 페치 전략을 무시하고 JPQL만을 사용해 SQL을 생성
JPQL의 또 다른 특징은 항상 DB에 SQL을 실행해 결과를 조회함
JPQL 입장에서는 연관관계 데이터를 무시하고 해당 엔티티를 기준으로 쿼리를 조회하기 때문에 이렇게 동작한다. 그래서 연관된 엔티티 데이터가 필요한 경우, FetchType
으로 지정한 시점에 조회를 별도로 호출하게 된다.
그래서 어떻게 해결하지?
N+1 문제는 JPQL fetch join 으로 해결할 수 있다.
페치 조인은 연관된 엔티티는 프록시가 아닌 실제 엔티티를 조회하게 되므로 연관관계 객체까지 한 번의 쿼리로 가져올 수 있다.
그래서 페치 조인은 무조건 좋다?
페치 조인이 무조건 좋다고는 할 수 없다.
무분별하게 사용하면 화면에 맞춘 리포지토리 메소드가 증가할 수 있다. (특정 화면 전용에서만 사용될 수 있는 (메소드 재사용이 불가한) 리포지토리 메소드가 증가)
결국 프레젠테이션 계층(view)이 알게 모르게 데이터 접근 계층(repository)을 침범하는 것이다.
각 화면마다 즉시로딩, 지연로딩의 요구사항이 다르다면 각각 다른 메소드를 정의하고, 각 화면에서 필요한 메소드만 호출하는 방식으로 최적화할 수 있지만, 뷰와 리포지토리 간 논리적인 의존 관계가 발생해버린다.
그러므로 적절한 선에서 타협점을 찾는 것이 합리적이다.
또한, 컬렉션을 페치 조인할 경우 페이징 API를 사용할 수 없으며, 둘 이상 컬렉션을 페치할 수 없다는 단점이 있다.
컬렉션을 페치 조인하면 일대다 조인이 발생해 데이터가 예측할 수 없이 증가한다. 최악의 경우 장애로 이어질 수 있는 나름 큰 단점이 될 수 있다.
그렇다면 페치 조인도 안되겠는데 그럼 뭘 써야하지?
코드도 단순해지고, 성능 최적화도 보장되는 강력한 방법은 무엇일까?
(사실 대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결 가능하다.)
먼저 xToOne
관계를 모두 페치 조인으로 설정한다.
(xToOne
관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않음)
컬렉션은 지연 로딩으로 조회한다.
지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size
, @BatchSize
를 적용한다.
- hibernate.default_batch_fetch_size
: 글로벌 설정
- @BatchSize
: 개별 최적화 (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)
이 옵션을 사용하면 컬렉션이나, 프록시 객체를 설정한 size만큼 한꺼번에 IN 쿼리로 조회한다.
hibernate.default_batch_fetch_size
덕분에 N+1 문제에서 어느정도 해방될 수 있다.
이 방법의 장점은
결론으로는 xToOne
관계는 페치 조인을 사용해도 페이징에 영향을 주지 않으므로 페치 조인으로 쿼리 수를 줄이고, 나머지(컬렉션)은 hibernate.default_batch_fetch_size
로 최적화하자