쿼리 하나로 끝날 줄 알았는데 N개의 추가 쿼리가 나가는 현상. 팀 10개를 조회했더니 팀 조회 쿼리 1개에 각 팀의 회원을 가져오는 쿼리 10개가 붙어 11개가 나가는 식이다. 코드만 봐서는 보이지 않아서 위험하다.
지연 로딩이 부르는 추가 쿼리
리포지토리로 조회할 때 실행하는 첫 쿼리에서 하위 엔티티까지 한 번에 가져오지 않고, 그것을 실제로 쓸 때 다시 조회하기 때문이다.
여기에 하나가 더 겹친다. JPQL은 글로벌 fetch 전략을 무시하고 JPQL만 가지고 SQL을 만든다. 즉시 로딩으로 설정해두어도 JPQL로 조회하면 일단 그 엔티티만 가져오고 연관 엔티티를 따로 조회한다.
즉시 로딩(EAGER)은 특정 엔티티를 조회할 때 연관된 것을 전부 같이 가져오고, 지연 로딩(LAZY)은 필요한 시점에 가져온다. 둘 다 N+1이 나고 시점만 다르다. EAGER는 JPQL로 조회한 직후 JPA가 fetch 전략을 보고 곧바로 연관 엔티티를 추가 조회한다. LAZY는 그 자리에서는 조회하지 않다가 하위 엔티티를 건드리는 순간 추가 조회한다.
즉시 로딩은 쓰지도 않을 데이터까지 무조건 가져오므로 기본은 지연 로딩으로 두고, 필요한 곳에서 한 번에 가져오는 것이 정석이다.
fetch join과 그 대안
fetch join은 JPQL로 데이터를 가져올 때 처음부터 연관된 데이터까지 조인해서 가져온다. SQL의 조인을 JPQL에서 명시하는 것이라 가장 직접적인 해법이지만 쓸 수 없는 자리가 있다.
@EntityGraph는 attributePaths에 같이 가져올 필드 이름을 적어두면 그 필드가 지연 로딩 대신 즉시 조회된다. 애노테이션으로 선언한다는 점만 다르고 효과는 fetch join과 비슷하다.
Batch Size는 성격이 다르다. N+1을 없애는 것이 아니라 N을 1로 줄인다.
-- Batch Size 없이: N번
select * from user where team_id = ?
-- Batch Size 적용: 1번
select * from user where team_id in (?, ?, ?, ...)100번 일어날 N+1을 한 번의 추가 조회로 바꾼다. 한계값은 1000 정도이고, fetch join을 쓸 수 없는 상황에서 대안이 된다.