N+1 문제를 위한 Fetch Join 테스트하기

kshired·2021년 11월 6일
1
post-thumbnail

우리는 스프링을 사용할 때 보통 N+1 문제에 대해서 많이 들어봤을 것이다.

그래서 보통 N+1 문제를 만났을 때 어떻게 해결하냐? 물어보면, 아 그거? fetch join으로 해결하면 됩니다! 라고 답하지만..

여기서 이 때 fetch join을 실행했을 때 실제 쿼리는 어떻게 나가고, 그 결과는 어떻게 나오나요?

라고 물어보면 대부분 이 fetch join을 실행했을 때의 결과를 제대로 알지 못하는 사람이 다수일 것이다.

사실 나도 애매하게 알고만 있었고, 제대로 확인을 안해보았었다. 그래서 오늘은 그 궁금증을 해결해보자.

N+1문제가 뭔데요?

  • 보통 N+1문제는 1:N, 즉 One To Many의 관계를 갖는 엔티티간의 조회를 할 때 발생하는 문제이다.
  • N개의 엔티티를 모두 가져오기위해 1개의 쿼리가 발생하고, 그 엔티티가 같이 조회되어 예상치 못한 N개의
    쿼리가 추가로 나가게 된다.

실제 사례를 보자.

일단 사례를 구성하기 위해, 엔티티 두 개를 만들겠다.

엔티티는 Member와 Team이 있으며, 각각의 Member는 하나의 Team을 가지며 하나의 Team은 여러 Member를 가질 수 있다.

그것을 코드로 표현하면 아래와 같다.

package com.example.demo.domain;

import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@NoArgsConstructor
public class Member {

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

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

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name="TEAM_ID")
    private Team team;

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Team getTeam() {
        return team;
    }

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

    public void setTeam(Team team){
        this.team = team;
        team.getMember().add(this);
    }
}


package com.example.demo.domain;

import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@NoArgsConstructor
public class Team {
    @Id
    @GeneratedValue
    @Column(name="TEAM_ID")
    private Long id;

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

    @OneToMany(mappedBy = "team")
    private List<Member> member = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public List<Member> getMember() {
        return member;
    }

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

먼저 fetch type이 eager일 때 어떤 쿼리가 나가는가?

  • 상황 : 현재 DB에는 10개의 팀이 있고, 각 팀에는 10명의 멤버가 저장되어 있다.
  • 즉, 총 100명의 멤버가 있으며 각 멤버는 10개의 팀 중 하나에 속해 있다.

테스트를 위한 코드를 작성하자.

@Test
@Transactional
public void getMembers(){
	// Spring data jpa 사용
	List<Member> all = memberRepository.findAll();
}

위 코드를 실행시키면 어떻게 될까?

먼저 모든 멤버를 조회하는 쿼리 1개가 발생하였고,

팀 10개를 모두 조회하는 쿼리 10개가 추가적으로 발생하였다.

우리는 테스트 코드에서 분명 팀을 조회한 적이 없는데..?

Fetchtype.EAGER로 설정하였기 때문에 멤버를 가져올 때 즉시 로딩되어 N+1 문제가 발생한 것이다.

이렇기 때문에 Eager를 사용하지 않는 것 같다.

Lazy로 바꿔 테스트하기

  • 먼저 Lazy loading으로 바꾸기 위해 멤버 클래스의 설정을 아래와 같이 변경하자.
@ManyToOne(fetch = FetchType.Lazy)
@JoinColumn(name="TEAM_ID")
private Team team;

그리고 아까 전의 테스트 코드를 실행하고, 결과를 보자.

이번에는 1개 밖에 안나온다.

어? 그러면 LAZY loading은 N+1 문제가 발생하지 않는건가?

이번엔 테스트 코드를 아래와 같이 바꾸고 실행해보자.

@Test
@Transactional
public void getMembers(){
    List<Member> all = memberRepository.findAll();
    for(Member member : all){
        System.out.println("member.getTeam().getName() = " + member.getTeam().getName());
    }
}

위 코드를 실행시키면 어떻게 될까?

먼저 모든 멤버를 조회하는 쿼리 1개가 발생하였고,

팀 10개를 모두 조회하는 쿼리 10개가 추가적으로 발생하였다.

또 다시 N+1 문제가 발생한 것이다.

이걸 어떻게 해결해야할까?

이것의 해결책이 Fetch Join이다.

fetch join을 하기위해, 아래와 같이 Repository에 코드를 추가하자.

@Query("SELECT m FROM Member m join fetch m.team")
List<Member> findAllByFetch();

위와 같이 JPQL에서 join fetch를 사용한 함수를, 아래의 테스트 코드를 실행해보자.

@Test
@Transactional
public void getMembersByFetch(){
    List<Member> all = memberRepository.finAllByFetch();
    for(Member member : all){
        System.out.println("member.getTeam().getName() = " + member.getTeam().getName());
    }
}

어떻게 될까?

아래와 같은 단 1개의 쿼리만 나간다!

N+1 문제가 해결 된 것이다.

=> 일단, Fetch Join을 통해 N+1 문제를 해결 할 수 있다는 것을 알게 되었다.

하지만, 서두에도 말했듯이 우리는 단순히 N+1 문제를 fetch join으로 해결한다를 알고 싶은 것이 아니다. 분석하기 위해 SQL을 자세히보자.

위 SQL을 보면, SELECT 절에서 member의 id, username 그리고 team의 id, name을 선택하며 team의 pk와 member에 존재하는 team_id fk를 inner join을 통해 데이터를 가져오고 있다.

실제로 저 쿼리로 가져오는 데이터의 형태는 어떻게 될까?

당연히 100명의 멤버의 정보를 모두 한 테이블로 가져온다.

이렇게 한 번의 쿼리로 모든 데이터를 가져오기 때문에 N+1 문제를 해결 한 것이다.

그럼 한 문제를 더 생각해보자.

1:N 관계의 테이블을 가정하고, 부모테이블이 1건 자식 테이블이 10건이 있을 때 Fetch join Query를 날리면 몇 건이 조회될까?

당연히 부모 테이블은 우리 예제에서 Team일 것이고, 자식 테이블은 Member 일것이며 Fetch join을 통해 가져온 결과는 하나의 테이블에서 10 row가 조회 될 것이다.

한 걸음 더

이번에는 각 team의 member가 몇명인지 조회하기 위해 fetch join을 통해 쿼리를 날려보자.

먼저 테스트를 위해 TeamRepository를 하나 만들고

아래와 같이 JPQL을 작성하자.

@Query("SELECT t FROM Team t join fetch t.member")
List<Team> findAllByFetch();

그리고 테스트를 하나 만들자.

@Test
@Transactional
public void getTeamsByFetch() {
    List<Team> teams = teamRepository.findAllByFetch();
    for (Team team : teams) {
        System.out.println("team.name: " + team.getName() + "member.size: " + team.getMember().size());
    }
}

테스트 코드를 보면 알겠지만, fetch join을 통해 team을 가져오고 그 team의 member의 수를 출력한다.

쿼리는 당연하게도 아래와 같이 1개만 나간다.

하지만, 큰 문제가 있다..
조회 결과를 보자.

결과는 조금 생략됐지만, 결과를 보면 우리는 team 0~9의 멤버수가 하나면 충분한데.. 지금 보면 결과가 중복되고 있다는 것을 알 수 있다.

실제 조회 결과 테이블을 한 번 보자.

what..? 100개의 row가 조회되고 있다. 엄청나게 많은 중복을 가지면서 말이다.

이것이 fetch join의 문제이다. 카테시안 곱을 통해 결과를 가져오기 때문에 중복되는 row가 생기는 것이다.

이걸 어떻게 해결할까?

중복제거에 사용되는 distinct를 이용해보자.

아래와 같이 JPQL 쿼리를 바꾸고, 결과를 보면.

@Query("SELECT DISTINCT t FROM Team t join fetch t.member")
List<Team> findAllByFetch();

와! 중복이 제거 되었다.

그렇지만 확실하게하기 위해 쿼리를 보고, 직접 h2 콘솔에서 실행해보자.

엥? 왜 결과가 똑같지..?

왜냐하면, join table의 결과가 모두 똑같지 않기 때문에 distinct가 작동하지 않는 것이다.

그럼 왜 실제 출력결과는 줄어든걸까?

JPQL의 DISTINCT가 아래와 같이 2가지의 역할을 하기 때문이다.

  • SQL에서 DISTINCT를 추가함으로써, 완벽히 중복 된 row를 제거.
  • 영속성 컨텍스트를 통해 애플리케이션단에서 중복을 제거.

따라서, 결과적으로 JPQL이 애플리케이션에서 중복을 제거하여 결과를 내보낸 것이다.

결과적으로 sql을 통해서 중복되는 결과를 줄일 수 없지만, 애플리케이션에서 줄일 수 있다는 것을 알게 되었다.

정리하자면, 1:N 관계에서

1에 해당하는 엔티티를 Fetch join하면 중복된 데이터를 얻지만, distinct를 통해 해결 할 수 있다.
N에 해당하는 엔티티를 Fetch join하면 문제없이 N+1문제를 해결 할 수 있다.

profile
글 쓰는 개발자

0개의 댓글