본문 바로가기

Spring

Fetch join, N+1 문제

N+1

N+1 문제란 ?

특정 엔티티를 DB에서 조회를 할 때 나가는 “1” 번의 쿼리와 연관된 객체를 함께 조회하는 “N”번의 예상하지 못한 쿼리가 나가는 문제이다. 이 때문에 DB에 과부하가 발생할 수 있다.

  • N+1 문제를 이해하기 위해서는 FetchType 에 대해 이해할 필요가 있다.
  • N+1 문제는 특정 FetchType (LAZY, EAGER)에서만 발생하는 문제가 아니다.

FetchType : LAZY and EAGER

EAGER

  • EAGER로 세팅이 되면 엔티티를 조회할 때 해당 연관관계를 바로 가져오는 것을 의미한다.
  • ManyToOne 연관관계와 OneToOne 연관관계일 때 Default FetchType이 Eager로 세팅이 됨
//Member와 Team은 다대일 관계이다.
@Entity
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
@Entity
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    private List<Member> members = new ArrayList<>();
}

멤버 조회 (EAGER 로 팀 함께 조회)

List<Member> members = em.createQuery("select m from Member m", Member.class)
                    .getResultList();
//for (Member m : members) {
//  System.out.println("m.getTeam().getName() = " + m.getTeam().getName());
//}
  • SQL은 한 번만 실행된다. (조인해서 한 번에 가져옴)
  • members에서 Team 을 조회하더라도 추가 쿼리가 실행되지 않음
select
        team0_.TEAM_ID as TEAM_ID1_1_0_,
        team0_.name as name2_1_0_,
        members1_.TEAM_ID as TEAM_ID3_0_1_,
        members1_.MEMBER_ID as MEMBER_I1_0_1_,
        members1_.MEMBER_ID as MEMBER_I1_0_2_,
        members1_.name as name2_0_2_,
        members1_.TEAM_ID as TEAM_ID3_0_2_ 
    from
        Team team0_ 
    left outer join
        Member members1_ 
            on team0_.TEAM_ID=members1_.TEAM_ID 
    where
        team0_.TEAM_ID=?

팀 조회 (EAGER 로 멤버 함께 조회)

  • SQL이 N+1 번 실행된다.
  • List<Team>을 가져오는 데에 1번
  • Member 를 조회하는데 N번 (team.size() 만큼)
List<Team> teams = em.createQuery("select t from Team t", Team.class)
                    .getResultList();
  • teams 만 조회했어도 Member를 전부 가져온다.
Hibernate: 
    /* select
        t 
    from
        Team t */ select
            team0_.TEAM_ID as TEAM_ID1_1_,
            team0_.name as name2_1_ 
        from
            Team team0_
Hibernate: 
    select
        members0_.TEAM_ID as TEAM_ID3_0_0_,
        members0_.MEMBER_ID as MEMBER_I1_0_0_,
        members0_.MEMBER_ID as MEMBER_I1_0_1_,
        members0_.name as name2_0_1_,
        members0_.TEAM_ID as TEAM_ID3_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
Hibernate: 
    select
        members0_.TEAM_ID as TEAM_ID3_0_0_,
        members0_.MEMBER_ID as MEMBER_I1_0_0_,
        members0_.MEMBER_ID as MEMBER_I1_0_1_,
        members0_.name as name2_0_1_,
        members0_.TEAM_ID as TEAM_ID3_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
  • N+1 문제 발생

LAZY

  • LAZY로 세팅이 되면 엔티티를 조회할 때 해당 연관관계를 바로 조회하지 않고 프록시 객체로 들고 있는다.
  • OneToMany 연관관계와 ManyToMany 연관관계일 때 Default FetchType이 LAZY로 세팅이 됨
//Member와 Team은 다대일 관계이다.
@Entity
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "MEMBER_ID")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
@Entity
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "TEAM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();
}

멤버 조회

//쿼리 실행
List<Member> members = em.createQuery("select m from Member m", Member.class)
                    .getResultList();
//결과
Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.MEMBER_ID as MEMBER_I1_0_,
            member0_.name as name2_0_,
            member0_.TEAM_ID as TEAM_ID3_0_ 
        from
            Member member0_
//멤버의 팀 이름 접근 시 프록시에서 접근
for (Member m : members) {
    System.out.println("m.getTeam().getName() = " + m.getTeam().getName());
}
//결과
Hibernate: 
    select
        team0_.TEAM_ID as TEAM_ID1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
m.getTeam().getName() = TeamA
m.getTeam().getName() = TeamA
Hibernate: 
    select
        team0_.TEAM_ID as TEAM_ID1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
m.getTeam().getName() = TeamB

팀 조회

//쿼리 실행
List<Team> teams = em.createQuery("select t from Team t", Team.class)
                    .getResultList();

//팀의 멤버 이름 접근 시 프록시에서 접근
for (Team t: teams) {
    System.out.println("t.getMembers() = " + t.getMembers().get(0).getName());
}

N + 1 ?

  • 위의 예제를 보면 Team을 조회하는 Member를 조회하든 연관관계를 Lazy로 설정하게 되면 List를 가져오는 한 번의 쿼리와 그 List의 size인 N번의 쿼리가 발생하게 된다. 이를 N + 1 문제라 한다.

N + 1 문제의 해결

  • 크게 두 가지 방법으로 해결할 수 있다.

Fetch Join

em.createQuery("select m from Member m join fetch m.team t", Member.class)
                    .getResultList();
em.createQuery("select t from Team t join fetch t.members m", Team.class)
                    .getResultList();
  • 위의 쿼리와 같이 join fetch를 사용하여 멤버를 조회할 때 팀을 함께 가져오거나, 팀을 조회할 때 멤버를 함께 가져와서 로딩하도록 하는 방법이다. 이를 페치 조인이라 한다.
  • 여기서 ManyToOne 관계에서의 fetch join에서는 문제가 되지 않지만 OneToMany 관계에서 발생하게될 문제가 있다.
    • ToOne 관계에서는 페치조인을 하더라도 데이터베이스에서 조회할 때 레코드의 개수가 일정하게 유지된다.
    • 그러나 ToMany 관계에서는 조회하는 레코드의 사이즈가 늘어나게된다. 위의 예제에서 팀을 조회할때 멤버를 페치조인하게 되면 실제로 조회되는 팀이 하나인데 멤버의 수만큼의 레코드가 조회된다. (팀을 조회하는것임에도!!) 이는 데이터베이스 관점에서 보면 당연하다.
    • 이게 왜 문제일까?
      • 페이징을 할 때 문제가 된다. ToMany 형태에서는 페치조인을 하면서 페이징을 할 수 없다.

@BatchSize, spring.jpa.properties.hibernate.default_batch_fetch_size

  • 위의 N+1 방법을 해결하는 또 다른 방법이 있다.
    • 페치 조인은 N+1 번의 쿼리를 1번의 쿼리로 최적화하는 기법이다.
    • batch_size 를 설정하는 방법을 사용하면 1+1의 쿼리로 최적화할 수 있고 페이징까지 처리할 수 있게된다.

사용 방법

  • application.yml 파일에 spring.jpa.properties.hibernate.default_batch_fetch_size: 100~1000 으로 설정 (전체 프로젝트에 적용)
  • 특정 연관관계에 @BatchSize(500)으로 개별 설정

동작

  • 위와 같이 간단한 설정만으로 N+1 문제를 해결할 수 있다.
//쿼리 실행
List<Team> teams = em.createQuery("select t from Team t", Team.class)
                    .getResultList();

//팀의 멤버 이름 접근 시 프록시에서 접근
for (Team t: teams) {
    System.out.println("t.getMembers() = " + t.getMembers().get(0).getName());
}
  • 위의 코드에서 첫 번째 쿼리는 예상대로 팀만을 조회한다.
  • 두 번째로 멤버를 접근하려 할 때 해당 팀에 연관된 멤버들의 id를 기준으로 memberId in (id1 … idN) 의 형태로 조회하게 된다. (N은 위에서 설정한 값이다.)
  • 이러한 방식을 사용하면 team을 조회할 때 페이징 처리를 할 수 있기 때문에 페치조인에서 발생한 문제를 해결할 수 있다.