ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • N+1 문제란? 그리고 해결방법 (feat. fetch join)
    개발/JPA & Hibernate 2021. 7. 18. 15:19

    N+1 문제란?

    객체 조회(1회)의 결과로 n개의 결과가 나온다고 했을 때, 조회된 객체안에 또 다른 객체가 있을 수 있다.

    그렇다면 객체안의 객체를 조회하기 위해 또 다른 쿼리가 발생하는 경우가 있는데, 그러면 처음 조회된 n개의 결과만큼 새로운 쿼리가 발생한다.

    즉, 첫 조회 1회 + 첫 조회 결과 n개 만큼의 결과가 증가하는 현상을 놓고 n+1 문제라고 한다.

    JPA에서 N+1 문제 해결

    Fetch Join 사용

    가장 일반적인 방법이다. Fetch Join에 관한 설명은 이 글 마지막에 정리했다. 애초에 사용될 데이터라면 지연로딩을 하지않고, 첫번째 쿼리때 Fetch Join 해서 가져오는 것이 좋다.

    Fetch Join에서도 설명하겠지만, Join이 아닌 Fetch Join의 차이는 Projection이다. Join은 지정한 필드만 Projection 하기 때문에, 해당 객체의 참조가 일어났을 때 다시 쿼리가 나간다.

     

    JPQL로 Fetch Join할 때 발생 할 수 있는 문제점

    1:N (컬렉션) 관계에서 발생할 수 있는 문제가 있다.

    JPQL쿼리를 보내면 SQL의 결과는 다음과 같이 1(Team)의 중복이 생길 수 밖에 없다.

     

    t.id t.name m.id m.name
    1 ManUtd 1 kim
    1 ManUtd 2 Lee
    1 ManUtd 3 Park

     

    레코드가 이렇게 3개가 나오면 자바 객체로 매핑할때, JPA는 사용자가 원하는 Team 객체가 1개인지 3개인지 알 수 없다. 그래서 3개를 리턴해버린다. 그러므로 {팀 ID 1, [멤버 ID1, ID2, ID3]} 인 객체만 3개가 된다.

    보통은 1개의 팀 객체만 원할것이다. 이 경우 distinct 를 사용하면 중복을 제거할 수 있다.

    • 1차적으로 SQL 쿼리에서 select distinct가 나가고
    • 2차적으로 애플리케이션에서 중복을 제거한다.
    • JOQL 예시 : SELECT DISTINCT t FROM Team t JOIN FETCH t

     

    BatchSize 조절

    batch size를 지정하면, N+1 에서 N에 해당하는 부분을 SQL IN절을 사용해서 조회한다.

    만약 N=3이고, BatchSize를 3으로 지정하면 딱 batch size에 맞는다. 그러면 한번의 쿼리만 추가된다. (1+1)

    Hibernate:
    /* select t from Team t */ 
    select
        team0_.id as id1_4_,
        team0_.league_id as league_i3_4_,
        team0_.name as name2_4_
    from
        Team team0_
    // 조회 결과 : 3개의 Team id가 식별되었다.
    
    Hibernate:
    /* load one-to-many hellojpa.Team.members */ 
    select
        members0_.team_id as team_id4_1_1_,
        members0_.id as id1_1_1_,
        members0_.id as id1_1_0_,
        members0_.name as name2_1_0_,
        members0_.product_id as product_3_1_0_,
        members0_.team_id as team_id4_1_0_
    from
        Member members0_
    where
        members0_.team_id in (
            ?, ?, ? // 식별된 3개의 Team id가 들어갈 것이다.
        )

     

    만약 N=3, BatchSize = 2이면 2번의 쿼리가 추가로 발생할 것이다.

    Hibernate: 
        /* load one-to-many hellojpa.Team.members */ select
            members0_.team_id as team_id4_1_1_,
            members0_.id as id1_1_1_,
            members0_.id as id1_1_0_,
            members0_.name as name2_1_0_,
            members0_.product_id as product_3_1_0_,
            members0_.team_id as team_id4_1_0_ 
        from
            Member members0_ 
        where
            members0_.team_id in (
                ?, ? // 2개
            )
    Hibernate: 
        /* load one-to-many hellojpa.Team.members */ select
            members0_.team_id as team_id4_1_1_,
            members0_.id as id1_1_1_,
            members0_.id as id1_1_0_,
            members0_.name as name2_1_0_,
            members0_.product_id as product_3_1_0_,
            members0_.team_id as team_id4_1_0_ 
        from
            Member members0_ 
        where
            members0_.team_id=?  // 1개는 in절이 아닌, 바로 where절로 검색.(이게 중요한건 아님)

     

    글로벌하게 적용하려면 hibernate.default_batch_fetch_size 옵션을 사용하면 되고,

    독립적으로 적용하려면 필드 위에 @BatchSize 애노테이션에 사이즈를 지정하면 된다.

    // Team Entity
    @BatchSize(size = 5)
    @OneToMany(mappedBy = "team", cascade = CascadeType.ALL)
    private List<Member> members = new ArrayList<>();

     

    글로벌 패치 전략이 Eager인 경우

    fetch join을 하는건 글로벌 전략이 eager 일때처럼 연관관계를 고려해서 join하는 것이다. 그럼 애초에 Eager로 글로벌 패치 전략을 사용하면 되지 않을까?

    • Eager가 글로벌이 되면 사용하지 않는 데이터도 매번 불러오게 된다.
    • 이 경우에도 N+1 문제가 발생할 수 있다.

    find() 메소드로 조회할 땐 괜찮다. 즉시로딩인 경우 아래처럼 JOIN을 통해서 연관관계를 가져오기 때문이다.

    select
        team0_.id as id1_4_0_,
        team0_.league_id as league_i3_4_0_,
        team0_.name as name2_4_0_,
        league1_.id as id1_0_1_,
        league1_.name as name2_0_1_
    from
        Team team0_
            left outer join
                League league1_
                    on team0_.league_id=league1_.id
    where
        team0_.id=?

     

    문제는 Eager + JPQL을 사용할 때 이다.

    JPA가 JPQL로 SQL을 생성할 때는 글로벌 패치전략을 고려하지 않고, JPQL만을 고려해서 쿼리를 만든다.

    /* select t from Team t */ 
    select
        team0_.id as id1_4_,
        team0_.league_id as league_i3_4_,
        team0_.name as name2_4_ 
    from
        Team team0_

    위 쿼리의 결과로 Team 객체가 만들어질것이다. 그런데 Team이 가지고 있는 League 객체의 패치 전략이 EAGER이다. EAGER 즉, 즉시로딩을 만족하려면 바로 League 객체를 가져오는 쿼리가 필요해진다.

     

    select
        league0_.id as id1_0_0_,
        league0_.name as name2_0_0_ 
    from
        League league0_ 
    where
        league0_.id=?

    이 League 객체를 가져오는 쿼리는 조회된 Team 객체의 수만큼 실행된다.

    Eager를 생각하면 JOIN을 떠올려서 N+1 이 안생길거라 생각할 수 있지만, Eager + JPQL은 JOIN이 아닌, 로딩+로딩이 발생할 여지가 있어서 이 경우 N+1 문제 발생.

    해결 방법

    마찬가지로 fetch join이다. Eager + JPQL을 써야한다면 Fetch Join으로 해결할 수 있다.


     

    Fetch Join

    즉시로딩(eager)와 효과가 비슷하다. azy 속성을 적용했더라도 프록시가 아닌 실제 엔티티를 가져오게 하는 성능최적화 용도다.

    • 다대일, 일대일에서는 추가데이터가 발생하지 않는다.
    • 일대다에서는 추가데이터가 발생한다.

    일대다상황 중복

    "select t from Team t join fetch t.members where [t.name](http://t.name/) = 'seoul'"

    team 1개와 member 3개가 있다. team에 member를 join하면

    • team1-member1, team1-member2, team1-member3 3개의 레코드가 조회된다
    • team1은 하나인데, 3개의 결과가 나왔다. → 결과가 증가해서 team 객체가 3개가 나오게 된다. (만약 member를 주체로본다면, 3개의 member당 1개의 팀을 갖게되니까 결과가 증가한게 아니다. 객체로 매핑시킨다면 member는 3개가 나오는게 정상이다.)
    • distinct로 중복을 제거할 수 있다. SQL문에서도 distinct가 발생하고, application에서도 중복제거를 한다.

    특징

    • 엔티티를 조회하므로 준영속상태에서도 객체 그래프 탐색 가능 (⇒ 쿼리 안날려도 됌) (그러나 1 뎁스 탐색 후, 그 안에 또다른 객체를 조회하려면 쿼리를 발생시켜야 함.)
    • fetch join 대상은 별칭을 부여할 수 없다. (hibernate에서 지원하지만 jpa표준은 아니고, 문제가 있을수도)
    • 둘 이상의 컬렉션을 fetch 불가
    • 컬렉션 fetch join 하면 페이징 api 사용 불가 DB에서 페이징하지 않고, 메모리에 모두 불러온다음 애플리케이션에서 페이징을 하므로 메모리문제.

    결론

    글로벌 로딩 전략은 Lazy(지연로딩)을 사용하고, 성능 최적화를 위해 한번에 로딩해야할 경우만 fetch join 사용

    Fetch Join vs Join

    Fetch Join은 조회 대상과 관계있는 객체를 한번에 조회한다. 연관관계까지 projection한다.

    • 관계있는 객체가 프록시가 아닌 실제 엔티티로 매핑된다.

    일반 join은 지정한 대상만 조회한다. 그래서 쿼리를 보면 지정한 대상만 projection한다.

    • 관계있는 객체가 프록시 컬렉션 래퍼로 매핑된다.
    • 그래서 조회결과에 있는 객체를 참조하면 추가적인 쿼리가 발생한다.
    • 이 차이를 이해하지 못하면 심각한 문제에 빠질 수 있다. (n+1)

    추가로 참고하면 좋을 글

    N+1 을 해결하는 여러가지 방법 https://jojoldu.tistory.com/165

    다중 fetch가 필요한 경우. (batch size 조절) https://jojoldu.tistory.com/457

    댓글

Designed by Tistory.