JPA N+1 문제

5 분 소요

이번 포스트에서는 JPA를 사용할 떄 문제가 될 수 있는 N+1 쿼리 문제에 대하여 정리해보겠습니다.

N+1 쿼리란?

N+1 쿼리는 일대다 관계에 있는 엔터티 연관 관계에서 발생하는 이슈로 연관 관계가 있는 엔터티를 조회할 때 조회된 데이터의 개수만큼 조회 쿼리가 추가로 발생하는 것을 의미합니다.
예제 코드를 통해 확인해보겠습니다.

@Entity
@Builder
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor(access = PACKAGE)
public class Member {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;
    
    private String name;
}

@Getter
@Entity
@Builder
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor(access = PACKAGE)
public class Team {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    private String name;

    @OneToMany(fetch = EAGER)
    private List<Member> members = new ArrayList<>();
}

public interface TeamRepository extends JpaRepository<Team, Long> {

}

위 예시는 멤버 엔터티와 팀 엔터티가 일대다 관계로 맵핑되어 있음을 확인할 수 있습니다.
이 상황에서 팀 엔터티를 기준으로 데이터를 삽입한 후 조회해 보겠습니다.

@BeforeEach
void setUp() {
    List<Member> memberList = new ArrayList<>();
    for (int i = 1; i <= 10; ++i) {
        memberList.add(Member.builder()
                .name("member" + i)
                .build());
    }

    List<Team> teamList = new ArrayList<>();
    for (int i = 1; i <= 10; ++i) {
        teamList.add(Team.builder()
                .name("team" + i)
                .members(memberList)
                .build());
    }

    teams.saveAll(teamList);
    em.flush();
    em.clear();
}

@Test
void 일대다_관계에서_EAGER_조회_시_N플러스1_문제가_발생한다() {
    List<Team> all = teams.findAll();

    assertThat(all).extracting("name").containsExactly("team1", "team2", "team3", "team4", "team5", "team6", "team7", "team8", "team9", "team10");
}
Hibernate: select team0_.id as id1_1_, team0_.name as name2_1_ from team team0_
Hibernate: select members0_.team_id as team_id3_0_0_, members0_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_0_1_, members0_.name as name2_0_1_, members0_.team_id as team_id3_0_1_ from member members0_ where members0_.team_id=?

쿼리를 확인해보면 Team, Member가 Join을 통해 한번에 조회되는 것이 아니라 Team의 조회된 수만큼 추가 쿼리가 발생한 것을 볼 수 있습니다. 이렇게 된 원인이 무엇일까요??

N+1 발생 이유

  • fetchMode가 Eager인 경우, findById() (em.find())는 pk를 이용하여 DB에서 가져오기 때문에 JPA 내부에서 최적화를 하여 join을 통해 연관 테이블의 데이터를 한방 쿼리로 가져오게 됩니다.
  • 하지만 findAll()의 경우 JPA가 JPQL을 통해 엔터티를 기준으로 쿼리를 생성하는데요, JPA가 이름을 통해 생성하는 JPQL은 연관관계를 무시하고 쿼리를 만들어 냅니다.
  • 이 경우 join 키워드를 명시하지 않기 때문에 첫 쿼리로 Team 엔터티만을 가져오게 됩니다.
  • Team 엔터티를 가져온 이후 연관관계를 확인해보니 Member의 fetchType이 Eager로 되어있으니 바로 가져오기 위하여 추가 쿼리를 발생시키는 것입니다.

Member의 fetchType이 Lazy로 바꾼다면 이 문제가 해결되지 않을까요???
엄밀히 말하자면 이는 해결방법이라고 할 수 없을 것입니다.
Lazy fetch를 하게 되어도 복수의 Team엔터티에서 Member 엔터티가 필요한 시점이 오면 그때마다 계속해서 쿼리를 발생시킬 것인데요, 이는 결국 N+1의 발생 시점을 Team 엔터티의 로딩 시에 발생시킬 지 연관관계 사용 시로 미룰지에 대한 차이만 있을 뿐입니다.

N+1 해결 방법

Fetch Join

페치 조인은 N+1 문제를 해결하기 위한 방법 중 하나로 sql 조인을 활용하여 연관된 엔터티를 쿼리 한번으로 모두 가져오는 방법입니다.
사용하기 위한 방법은 아래와 같습니다.

public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query("SELECT t FROM Team t LEFT JOIN FETCH t.members")
    List<Team> findAllFetchJoin();
}

@Test
void 패치_조인을_하면_N플러스1_문제가_발생하지_않는다() {
    List<Team> allFetchJoin = teams.findAllFetchJoin();

    assertThat(allFetchJoin).extracting("name").containsExactly("team1", "team2", "team3", "team4", "team5", "team6", "team7", "team8", "team9", "team10");
}
Hibernate: select team0_.id as id1_1_0_, members1_.id as id1_0_1_, team0_.name as name2_1_0_, members1_.name as name2_0_1_, members1_.team_id as team_id3_0_1_, members1_.team_id as team_id3_0_0__, members1_.id as id1_0_0__ from team team0_ left outer join member members1_ on team0_.id=members1_.team_id

테스트를 돌려보니 명시한대로 left join을 통해 데이터를 잘 가져온 것을 알 수 있습니다.
만약 JPQL에 패치조인이 아닌 일반 조인을 사용했다면 어땟을까요?? 이것도 확인해보겠습니다. :D

@Query("SELECT t FROM Team t LEFT JOIN t.members")
List<Team> findAllLeftJoin();
Hibernate: select team0_.id as id1_1_, team0_.name as name2_1_ from team team0_ left outer join member members1_ on team0_.id=members1_.team_id
Hibernate: select members0_.team_id as team_id3_0_0_, members0_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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_.id as id1_0_0_, members0_.id as id1_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 문제가 발생했네요..
첫 쿼리에서 left join으로 조회쿼리를 만들었지만 Member 엔터티는 가져오지 않았기 때문에 추가 쿼리가 발생한 것으로 보입니다.

하지만 이 패치 조인도 결국 만능은 아닌데요,, 다음과 같은 문제가 있을 수 있습니다. 패치 조인 사용 시 주의할 점

Entity Graph

연관 관계 맵핑 시 글로벌 패치 전략을 설정해 놓으면 이는 정적으로 런타임 시에 수정될 수 없습니다.
엔터티 그래프는 이러한 점을 보완하기 위해 사용하는 기법인데요, JPQL의 N+1 문제를 해결하기 위해서도 사용할 수 있습니다.
attributePaths에 바로 가져올 필드를 설정하면 이를 패치 타입 Eager로 가져오게 되는데요, outer join을 통해 한번에 연관관계 데이터까지 가져옵니다.

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

@Test
void 엔터티_그래프를_사용하면_N플러스1이_발생하지_않는다() {
    List<Team> allEntityGraph = teams.findAllEntityGraph();
    assertThat(allEntityGraph).extracting("name").containsExactly("team1", "team2", "team3", "team4", "team5", "team6", "team7", "team8", "team9", "team10");
}
Hibernate: select team0_.id as id1_1_0_, members1_.id as id1_0_1_, team0_.name as name2_1_0_, members1_.name as name2_0_1_, members1_.team_id as team_id3_0_1_, members1_.team_id as team_id3_0_0__, members1_.id as id1_0_0__ from team team0_ left outer join member members1_ on team0_.id=members1_.team_id

여기까지 JPA N+1 문제가 무엇인지, 어떻게 피할 수 있는지에 대해 알아보았습니다.
JPA를 제대로 학습하지 않고 사용하게 되면 쉽게 만날 수 있는 문제이기도 하니, 이번 포스트가 많은 분들께 도움이 되길 바라겠습니다.

태그:

카테고리:

업데이트:

댓글남기기