N+1 정복기 기본편

조현근·2022년 11월 16일
0
post-thumbnail

N+1이 발생하는 경우

N+1발생하는 경우를 살펴보겠습니다. 이번 포스팅에선 fetch = EAGER인 경우만 살펴보겠습니다!

@OneToMany(fetch = EAGER)

일대다 관계를 맺는 엔티티가 있고 fetch전략이 EAGER인 경우
부모 객체를 findAll()을 통해 조회하면 N+1이 발생합니다.
학습테스트를 통해 확인해보겠습니다.

Team 엔티티

@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "team_id")
    private Long id;

    @Column(name = "team_name")
    private String name;

    @OneToMany(fetch = FetchType.EAGER, cascade = {PERSIST, REMOVE})
    @JoinColumn(name = "team_id")
    private List<Member> members = new ArrayList<>();

    protected Team() {
    }

    public Team(String name, List<Member> members) {
        this.name = name;
        this.members = members;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public List<Member> getMembers() {
        return members;
    }
}

Member 엔티티

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    @Column(name = "member_name")
    private String name;

    protected Member() {
    }

    public Member(String name) {
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

학습테스트 코드

@BeforeEach
void setUp() {
    for (int i = 0; i<3; ++i) {
        Member member = new Member("member" + i);
        Team team = new Team("team1", List.of(member));
        teamRepository.save(team);
    }
    em.flush();
    em.clear();
}

@DisplayName("oneToMany(fetch = EAGER)일때 N+1 발생 확인")
@Test
void oneToManyEager_N_1() {
    System.out.println("@@@ N+1 @@@");
    List<Team> teams = teamRepository.findAll();
    System.out.println("@@@ N+1 @@@");

    assertThat(teams).hasSize(3);
    assertThat(teams.get(0).getMembers()).hasSize(1);
}

쿼리

@@@ N+1 @@@
Hibernate: 
    select
        team0_.team_id as team_id1_5_,
        team0_.team_name as team_nam2_5_ 
    from
        team team0_
Hibernate: 
    select
        members0_.team_id as team_id3_1_0_,
        members0_.member_id as member_i1_1_0_,
        members0_.member_id as member_i1_1_1_,
        members0_.member_name as member_n2_1_1_ 
    from
        member members0_ 
    where
        members0_.team_id=?
Hibernate: 
    select
        members0_.team_id as team_id3_1_0_,
        members0_.member_id as member_i1_1_0_,
        members0_.member_id as member_i1_1_1_,
        members0_.member_name as member_n2_1_1_ 
    from
        member members0_ 
    where
        members0_.team_id=?
Hibernate: 
    select
        members0_.team_id as team_id3_1_0_,
        members0_.member_id as member_i1_1_0_,
        members0_.member_id as member_i1_1_1_,
        members0_.member_name as member_n2_1_1_ 
    from
        member members0_ 
    where
        members0_.team_id=?
@@@ N+1 @@@

3개의 팀에 대한 member를 찾기위해 3번의 쿼리가 더 나간것을 알 수 있다.
만약 팀이 1000개, 10000개 혹은 10만개였으면 성능에 큰 문제가 발생했을 것이다.

1. fetch join을 사용해 해결

fetch join은 SQL에서 이야기하는 조인의 종류는 아니고 JPQL에서 성능 최적화를 위해 제공하는 기능이다. ( LEFT | INNER ) JOIN FETCH 문법을 통해 사용할 수 있다. fetch join을 사용하는 repository 메서드를 만들고 이를 사용해 해결하자.

Repository

public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query(value = "SELECT distinct t FROM Team t join fetch t.members")
    List<Team> findAllWithMember();
}

학습테스트 코드

@DisplayName("oneToMany(fetch = EAGER) fetch join으로 N+1 해결")
@Test
void oneToManyEager_N_1_Fetch_Join() {
    System.out.println("@@@ fetch join @@@");
    List<Team> teams = teamRepository.findAllWithMember();
    System.out.println("@@@ fetch join @@@");

    assertThat(teams).hasSize(3);
    assertThat(teams.get(0).getMembers()).hasSize(1);
}

쿼리

@@@ fetch join @@@
Hibernate: 
    select
        distinct team0_.team_id as team_id1_5_0_,
        members1_.member_id as member_i1_1_1_,
        team0_.team_name as team_nam2_5_0_,
        members1_.member_name as member_n2_1_1_,
        members1_.team_id as team_id3_1_0__,
        members1_.member_id as member_i1_1_0__ 
    from
        team team0_ 
    inner join
        member members1_ 
            on team0_.team_id=members1_.team_id
@@@ fetch join @@@

1+3만큼 나가던 쿼리가 단 한권으로 해결되는 것을 확인할 수 있다!!

2. @BatchSize를 사용해 해결

BatchSize는 collection이나 lazy entity를 batch로 가져오게 해준다.
WHERE절이 같은 여러 개의 SELECT 쿼리들을 하나의 IN 쿼리로 만들어준다.

1. Entity의 클레스 레벨에 붙이는 경우엔 프록시로 호출하는 경우만 동작한다.
Member엔티티 클래스 레벨에 @BatchSize를 붙여보았습니다.

@BatchSize 적용한 Member엔티티

@BatchSize(size = 100)
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    @Column(name = "member_name")
    private String name;

    protected Member() {
    }

    public Member(String name) {
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

쿼리

@@@ N+1 @@@
Hibernate: 
    select
        team0_.team_id as team_id1_5_,
        team0_.team_name as team_nam2_5_ 
    from
        team team0_
Hibernate: 
    select
        members0_.team_id as team_id3_1_0_,
        members0_.member_id as member_i1_1_0_,
        members0_.member_id as member_i1_1_1_,
        members0_.member_name as member_n2_1_1_ 
    from
        member members0_ 
    where
        members0_.team_id=?
Hibernate: 
    select
        members0_.team_id as team_id3_1_0_,
        members0_.member_id as member_i1_1_0_,
        members0_.member_id as member_i1_1_1_,
        members0_.member_name as member_n2_1_1_ 
    from
        member members0_ 
    where
        members0_.team_id=?
Hibernate: 
    select
        members0_.team_id as team_id3_1_0_,
        members0_.member_id as member_i1_1_0_,
        members0_.member_id as member_i1_1_1_,
        members0_.member_name as member_n2_1_1_ 
    from
        member members0_ 
    where
        members0_.team_id=?
@@@ N+1 @@@

N+1이 해결되지 않았습니다. 클래스 레벨에서 @BatchSize는 프록시로 호출될때 적용되는데, fetch = EAGER인 경우 프록시 객체가 아니기 때문에 적용되지 않음을 알 수 있습니다.

2. 필드에 붙이는 경우엔 N+1 해결
Team 엔티티에서 컬렉션으로 가지고 있는 Member 엔티티에 필드레벨에서 @BatchSize를 붙이면 N+1이 해결되는 것을 알 수 있습니다.

@BatchSize 적용한 Team엔티티

@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "team_id")
    private Long id;

    @Column(name = "team_name")
    private String name;

    @BatchSize(size = 100)
    @OneToMany(fetch = FetchType.EAGER, cascade = {PERSIST, REMOVE})
    @JoinColumn(name = "team_id")
    private List<Member> members = new ArrayList<>();

    protected Team() {
    }

    public Team(String name, List<Member> members) {
        this.name = name;
        this.members = members;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public List<Member> getMembers() {
        return members;
    }
}

쿼리

@@@ N+1 @@@
Hibernate: 
    select
        team0_.team_id as team_id1_5_,
        team0_.team_name as team_nam2_5_ 
    from
        team team0_
Hibernate: 
    select
        members0_.team_id as team_id3_1_1_,
        members0_.member_id as member_i1_1_1_,
        members0_.member_id as member_i1_1_0_,
        members0_.member_name as member_n2_1_0_ 
    from
        member members0_ 
    where
        members0_.team_id in (
            ?, ?, ?
        )
@@@ N+1 @@@

3. @EntityGraph를 사용해 해결

@EntityGraph라는 것을 사용해 해결할 수도 있다. repository에 @EntityGraph를 붙여주고 사용하면 된다. 코드로 확인해보자

Repository

@EntityGraph(attributePaths = "members")
@Query(value = "SELECT t FROM Team t")
List<Team> findAllEntityGraph();

쿼리

@@@ N+1 @@@
Hibernate: 
    select
        distinct team0_.team_id as team_id1_5_0_,
        members1_.member_id as member_i1_1_1_,
        team0_.team_name as team_nam2_5_0_,
        members1_.member_name as member_n2_1_1_,
        members1_.team_id as team_id3_1_0__,
        members1_.member_id as member_i1_1_0__ 
    from
        team team0_ 
    inner join
        member members1_ 
            on team0_.team_id=members1_.team_id
@@@ N+1 @@@

정리

@OneToMany(fetch = EAGER)에서 N+1이 발생하는 경우와 이를 해결할 수 있는 3가지 방법(fetch join, @BatchSize, @EntityGraph)에 대해 간략히 살펴보았다. 다음 정리에선 fetch = LAZY에 대해서, 그리고 카테시안 곱을 방지하는 방법에 대해 알아보겠다.
아래 블로그를 한 번 읽어보고 포스팅할 예정이다!!
https://jojoldu.tistory.com/165
https://jojoldu.tistory.com/457

참고
자바 ORM 표준 JPA 프로그래밍
https://velog.io/@mohai2618/JPA-N1-%EB%AC%B8%EC%A0%9C-%EC%B5%9C%EC%A0%81%ED%99%94

profile
안녕하세요!

0개의 댓글