연관관계 로딩 전략에서 즉시 로딩과 지연 로딩 전략이 있다.
지연 로딩은 엔티티를 조회할 때 연관된 엔티티를 사용하기 전까지는 실제 엔티티를 조회하지 않고, 실제 엔티티를 사용할 때 비로소 초기화 함으로써 불필요한 데이터의 조회를 나중으로 미루는 전략이다.
즉시 로딩은 엔티티를 조회할 때 연관된 엔티티를 함께 조회(JOIN)하는 전략이다.
그렇다면 N+1 문제는 무엇일까?
결론부터 말하자면 N+1은 로딩 전략을 즉시로딩 전략을 사용할 때 발생한다. 또한 즉시로딩 전략을 사용한다고 모두 발생하는건 아니다.
그렇다면 조회할 엔티티를 즉시로딩 전략으로 세팅하고 테스트를 해보자.
JPQL 미사용
Member member = em.find(Member.class, 1L);
위 코드를 실행하면 다음과 같다.
select
member0_.id as id1_0_0_,
member0_.created_date as created_2_0_0_,
member0_.updated_date as updated_3_0_0_,
member0_.age as age4_0_0_,
member0_.team_id as team_id6_0_0_,
member0_.username as username5_0_0_,
team1_.team_id as team_id1_1_1_,
team1_.name as name2_1_1_
from
member member0_
left outer join
team team1_
on member0_.team_id=team1_.team_id
where
member0_.id=?
실행된 SQL을 보면 즉시로딩으로 설정한 Team 엔티티를 JOIN 쿼리로 함께 조회한다. 여기서는 즉시로딩이 필요한 엔티티를 함께 조회하고 싶을때 매우 좋아보인다.
하지만 우리가 많이 사용하는 JPQL을 사용할 때 문제는 발생한다.
다음 예시를 보자.
JPQL 사용
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
위 코드를 실행하면 다음과 같다.
select
member0_.id as id1_0_,
member0_.created_date as created_2_0_,
member0_.updated_date as updated_3_0_,
member0_.age as age4_0_,
member0_.team_id as team_id6_0_,
member0_.username as username5_0_
from
member member0_
------------------------------------------------------------------------------------
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id=?
------------------------------------------------------------------------------------
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id=?
실행된 SQL을 보면 연관된 엔티티(Team)에 대해서 여러 번 쿼리가 날라간 것을 볼 수 있다. 결론을 말하면 JPA가 JPQL을 분석해서 SQL을 생성할 때는 로딩 전략을 참고하지 않고 오직 JPQL 자체만 사용하게 된다. 즉, JPA가 JPQL을 분석해서 SQL을 생성할 때 글로벌 Fetch 전략을 참고하지 않고 오직 JPQL 자체만을 사용하기 때문이다.
따라서 JPQL을 사용하면 즉시로딩이든 지연로딩이든 구분하지 않고 JPQL 쿼리 자체에 충실하게 SQL을 만든다.
위 예제에서는 조회하는 Member에 대해서 연관된 Team이 2개 밖에 없어서 2번 쿼리가 나갔지만 하나 추가될 때마다 하나씩 쿼리가 더 생성될 것이다.
이처럼 처음 조회한 데이터 수만큼 다시 SQL을 사용해서 조회하는 것을 N+1 문제라고 한다.
그렇다면 이를 어떻게 해결할 수 있을까?
로딩 전략을 즉시로딩으로 설정하면 생기는 문제점을 위에서 알아보았다. 해당 문제점은 Fetch JOIN
으로 해결할 수 있다.
우선 기본적으로 모든 연관관계 로딩 전략을 지연로딩(LAZY LOADING)
전략으로 바꾸는 것이 최선이다. 지연로딩으로 설정하면 N+1 문제가 생길일이 없고 또한 실제 연관된 엔티티를 사용하기 전까지는 쿼리가 날라가지 않기 때문이다.
그런다음 연관된 엔티티나 컬렉션이 함께 필요한 경우 Fetch JOIN으로 한번에 조회하면 N+1 문제가 해결된다.
다음과 같이 join 명령어 마지막에 fetch
키워드를 넣어주면 된다.
List<Member> members = em
.createQuery("select m from Member m " +
"join fetch m.team t", Member.class)
.getResultList();
위 코드를 실행하면 다음과 같다.
select
member0_.id as id1_0_0_,
team1_.team_id as team_id1_1_1_,
member0_.created_date as created_2_0_0_,
member0_.updated_date as updated_3_0_0_,
member0_.age as age4_0_0_,
member0_.team_id as team_id6_0_0_,
member0_.username as username5_0_0_,
team1_.name as name2_1_1_
from
member member0_
inner join
team team1_
on member0_.team_id=team1_.team_id
위 쿼리를 보면 JOIN을 사용해서 Fetch JOIN 대상까지 함께 조회하는 것을 볼 수 있다. 따라서 기본적으로 지연로딩을 연관된 엔티티에 설정해두고 함께 조회할 필요가 있을 경우 Fetch JOIN으로 해결하면 N+1 문제가 발생할 일이 없다.
필요에 따라서 즉시 로딩을 써도 무관하기도 하고, 또한 1:1 연관관계에서 연관관계의 주인 반대편 엔티티는 무조건적으로 즉시로딩을 강제한다.
Fetch JOIN은 현업에서는 물론 나 또한 매우 많이 사용하고 있다. 이 방식은 N+1 문제를 해결하면서 화면에 필요한 데이터를 미리 로딩하는 매우 현실적인 방안이라고 할 수 있다.
두 번째 방법은 하이버네이트가 제공하는 BatchSize 어노테이션을 사용하는 것이다. 이 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN 절을 사용해서 조회한다.
단, 조건이 두 가지 존재한다.
- LAZY 로딩 전략을 사용하는 연관된 엔티티의 클래스 레벨에 @BatchSize 애노테이션을 적용했을 때
- LAZY 로딩 전략을 사용하는 연관된 엔티티들 Collection의 필드에 @BatchSize 애노테이션을 적용했을 때
이 두가지 이외에 @BatchSize를 적용하면 예상한대로 작동하지 않을 수도 있으니 주의하도록 하자.
다음과 같이 즉시 로딩이 걸린 Member 엔티티가 있다고 가정해보자.
@BatchSize(size = 2)
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
private List<Member> members = new ArrayList<>();
즉시 로딩으로 설정하면 Member 엔티티와 연관된 Team 엔티티가 3개 존재하므로 조회 시점에 3건의 데이터를 모두 조회해야 한다. 따라서 다음과 같이 SQL이 두 번 실행된다.
select
members0_.team_id as team_id6_0_1_,
members0_.id as id1_0_1_,
members0_.id as id1_0_0_,
members0_.created_date as created_2_0_0_,
members0_.updated_date as updated_3_0_0_,
members0_.age as age4_0_0_,
members0_.team_id as team_id6_0_0_,
members0_.username as username5_0_0_
from
member members0_
where
members0_.team_id in (
?, ?
)
select
members0_.team_id as team_id6_0_1_,
members0_.id as id1_0_1_,
members0_.id as id1_0_0_,
members0_.created_date as created_2_0_0_,
members0_.updated_date as updated_3_0_0_,
members0_.age as age4_0_0_,
members0_.team_id as team_id6_0_0_,
members0_.username as username5_0_0_
from
member members0_
where
members0_.team_id=?
반면에 지연로딩으로 설정하면 지연로딩된 엔티티를 최초 사용하는 시점에 다음 SQL을 실행해서 2건의 데이터를 미리 로딩해둔다.
select
members0_.team_id as team_id6_0_1_,
members0_.id as id1_0_1_,
members0_.id as id1_0_0_,
members0_.created_date as created_2_0_0_,
members0_.updated_date as updated_3_0_0_,
members0_.age as age4_0_0_,
members0_.team_id as team_id6_0_0_,
members0_.username as username5_0_0_
from
member members0_
where
members0_.team_id in (
?, ?
)
그리고 3번 째 데이터를 사용하면 다음 SQL을 추가로 실행한다.
select
members0_.team_id as team_id6_0_1_,
members0_.id as id1_0_1_,
members0_.id as id1_0_0_,
members0_.created_date as created_2_0_0_,
members0_.updated_date as updated_3_0_0_,
members0_.age as age4_0_0_,
members0_.team_id as team_id6_0_0_,
members0_.username as username5_0_0_
from
member members0_
where
members0_.team_id=?
또한 N:1 관계에서 지연로딩으로 설정하고, 1의 엔티티의 클래스 레벨에 @BatchSize를 적용했을 때를 보자.
public class Member {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
public Team team;
}
@Entity
@BatchSize(size = 2)
public class Team{ }
만약 조회하려는 Member 엔티티와 관련된 Team 엔티티들이 4개가 존재하면 다음과 같이 연관된 엔티티에 대한 쿼리가 두번 나가는 것을 확인할 수 있다.
select
member0_.id as id1_0_,
member0_.created_date as created_2_0_,
member0_.updated_date as updated_3_0_,
member0_.age as age4_0_,
member0_.team_id as team_id6_0_,
member0_.username as username5_0_
from
member member0_
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id in (
?, ?
)
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id in (
?, ?
)
세 번째 방법은 하이버네이트가 제공하는 Fetch 어노테이션에 FetchMode를 SUBSELECT로 사용하면 연관된 데이터를 조회할 때 서브쿼리를 사용해서 N+1 문제를 해결하는 방법이다.
이 전략은 1:N의 관계에 있는 엔티티에 대해서만 수행이 가능하다.
다음과 같이 즉시로딩이 걸린 Member 엔티티가 있다고 가정해보자.
@Fetch(FetchMode.SUBSELECT)
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
private List<Member> members = new ArrayList<>();
이렇게 설정하고 Team 엔티티를 조회하면 다음과 같이 서브쿼리와 함께 쿼리가 날라가서 같이 조회하게 된다.
select
members0_.team_id as team_id6_0_1_,
members0_.id as id1_0_1_,
members0_.id as id1_0_0_,
members0_.created_date as created_2_0_0_,
members0_.updated_date as updated_3_0_0_,
members0_.age as age4_0_0_,
members0_.team_id as team_id6_0_0_,
members0_.username as username5_0_0_
from
member members0_
where
members0_.team_id in (
select
team0_.team_id
from
team team0_
)
위 쿼리는 즉시로딩으로 설정하면 조회 시점에 날라가게 되고, 지연 로딩으로 설정하면 연관된 엔티티 사용 시점에 쿼리가 날라간다.