Skip to content

Latest commit

 

History

History
322 lines (259 loc) · 10.8 KB

08.md

File metadata and controls

322 lines (259 loc) · 10.8 KB

JPA - N+1 Problem

연관관계 로딩 전략에서 즉시 로딩과 지연 로딩 전략이 있다.

지연 로딩은 엔티티를 조회할 때 연관된 엔티티를 사용하기 전까지는 실제 엔티티를 조회하지 않고, 실제 엔티티를 사용할 때 비로소 초기화 함으로써 불필요한 데이터의 조회를 나중으로 미루는 전략이다.

즉시 로딩은 엔티티를 조회할 때 연관된 엔티티를 함께 조회(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 문제라고 한다.

그렇다면 이를 어떻게 해결할 수 있을까?

JPQL Fetch JOIN

로딩 전략을 즉시로딩으로 설정하면 생기는 문제점을 위에서 알아보았다. 해당 문제점은 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 문제를 해결하면서 화면에 필요한 데이터를 미리 로딩하는 매우 현실적인 방안이라고 할 수 있다.

Hibernate @BatchSize

두 번째 방법은 하이버네이트가 제공하는 BatchSize 어노테이션을 사용하는 것이다. 이 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN 절을 사용해서 조회한다.

단, 조건이 두 가지 존재한다.

  1. LAZY 로딩 전략을 사용하는 연관된 엔티티의 클래스 레벨에 @BatchSize 애노테이션을 적용했을 때
  2. 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 (
        ?, ?
    )

Hibernate @Fetch(FetchMode.SUBSELECT)

세 번째 방법은 하이버네이트가 제공하는 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_
    )

위 쿼리는 즉시로딩으로 설정하면 조회 시점에 날라가게 되고, 지연 로딩으로 설정하면 연관된 엔티티 사용 시점에 쿼리가 날라간다.