[JPA] n+1 문제 해결을 위한 fetch join

이민준·2022년 1월 10일
0

JPA

목록 보기
1/2

fetch join이란

우선 fetch join은 DataBase에서 알던 join문이 아닙니다.

JPQL에서 성능의 최적화를 위해 제공하는 기능이며, 연관된 엔티티나 컬랙션을 SQL 한 번에 함께 조회하는 기능입니다.

join fetch 라는 명령어로 사용 가능합니다.

그러면 그냥 native 쿼리에서의 join과 JPQL의 fetch join은 어떤 차이점을 갖고 있을까?

Join, Fetch Join 차이점 요약

  • 일반 Join
    - 오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화.
    - 조회의 주체가 되는 Entity만 영속성 컨텍스트에서 관리하기 때문에, 객체로 사용하려면 새롭게 쿼리문을 날려야함
    - 검색조건으로는 자주 사용함
  • Fetch join
    - 조회의 주체가 되는 Entity 이외에 Fetch join이 걸린 연관 Entity도 같이 영속성 컨텍스트에서 관리해줌
    - Lazy로 참조해도 패치조인이 먼저 작용되어 새롭게 쿼리문을 만들지 않음

실제 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;
 
    @Column(name = "name")
    private String username;
 
    private Integer age;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
 
    @Enumerated(EnumType.STRING)
    private RoleType roleType;
 
    @Temporal(TemporalType.TIMESTAMP)
    private Date createdDate;
 
    @Temporal(TemporalType.TIMESTAMP)
    private Date lastModifiedDate;
 
    @Lob
    private String description;
 
    public void setId(Long id) {
        this.id = id;
    }
 
    public void setUsername(String username) {
        this.username = username;
    }
 
    public Long getId() {
        return this.id;
    }
 
    public Member() {
    }
 
    public Member(Long id) {
        this.id = id;
    }
 
    public void setTeam(Team team) {
        this.team = team;
    }
 
    public Team getTeam() {
        return this.team;
    }
}
cs

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Entity
public class Team {
    @Id @GeneratedValue
    @Column(name="team_id")
    private Long id;
 
    private String name;
 
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
 
    public Long getId() {
        return id;
    }
 
    public void setId(Long id){
        this.id = id;}
 
    public String getName() {
        return name;
    }
 
    public void setName(String name) {
        this.name = name;
    }
}
 
cs

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hello");
        EntityManager entityManager = entityManagerFactory.createEntityManager();
 
        EntityTransaction tx = entityManager.getTransaction();
        tx.begin();
 
        try {
            Team teamA = new Team();
            teamA.setName("팀A");
            entityManager.persist(teamA);
 
            Team teamB = new Team();
            teamB.setName("팀B");
            entityManager.persist(teamB);
 
            Member member1 = new Member();
            member1.setUsername("member1");
            member1.setTeam(teamA);
            entityManager.persist(member1);
 
            Member member2 = new Member();
            member2.setUsername("member2");
            member2.setTeam(teamB);
            entityManager.persist(member2);
 
            Member member3 = new Member();
            member2.setUsername("member3");
            member3.setTeam(teamA);
            entityManager.persist(member3);
 
            Member member4 = new Member();
            member2.setUsername("member4");
            entityManager.persist(member4);
 
            entityManager.flush();
            entityManager.clear();
 
            String query = "select m From Member m";
 
            List<Member> result = entityManager.createQuery(query, Member.class).getResultList();
            for (Member member : result) {
                System.out.println("member.team.name = " + member.getTeam().getName());
            }
        tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            entityManager.close();
        }
        entityManager.close();
        entityManagerFactory.close();
    }
}
 
cs

JPA main을 실행해보면 아래와 같이 N+1 문제가 발생합니다.

member1 을 조회할 때 쿼리문을 날리고 DB에서 긁어와서 teamA 를 영속성 컨텍스트에 넣습니다.

그 다음 member2 를 조회할 때도 마찬가지로 teamB 를 영속성 컨텍스트에 넣습니다.

마지막 member3 을 조회할 때에는 1차 캐시에서 가져옵니다.

결국 쿼리문은 2번 추가적으로 나가게 되었고, 이게 N+1 이슈입니다. member가 100명이고 각각의 팀을 가지고 있을 경우 101번의 쿼리가 나가게 될 것 입니다.


하지만 아래와 같이 query문을 수정한다면

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package hellojpa;
 
import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import java.util.List;
 
public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hello");
        EntityManager entityManager = entityManagerFactory.createEntityManager();
 
        EntityTransaction tx = entityManager.getTransaction();
        tx.begin();
 
        try {
            Team teamA = new Team();
            teamA.setName("팀A");
            entityManager.persist(teamA);
 
            Team teamB = new Team();
            teamB.setName("팀B");
            entityManager.persist(teamB);
 
            Member member1 = new Member();
            member1.setUsername("member1");
            member1.setTeam(teamA);
            entityManager.persist(member1);
 
            Member member2 = new Member();
            member2.setUsername("member2");
            member2.setTeam(teamB);
            entityManager.persist(member2);
 
            Member member3 = new Member();
            member2.setUsername("member3");
            member3.setTeam(teamA);
            entityManager.persist(member3);
 
            Member member4 = new Member();
            member2.setUsername("member4");
            entityManager.persist(member4);
 
            entityManager.flush();
            entityManager.clear();
 
            String query = "select m From Member m join fetch m.team";
 
            List<Member> result = entityManager.createQuery(query, Member.class).getResultList();
            for (Member member : result) {
                System.out.println("member.team.name = " + member.getTeam().getName());
            }
        tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            entityManager.close();
        }
        entityManager.close();
        entityManagerFactory.close();
    }
}
 
cs

아래 그림과 같이 한번의 쿼리문만 나가는것을 확인할 수 있습니다.

select과 동시에 fetch join에 사용되는 엔티티들도 영속성 컨텍스트에 바로 올려버리기 때문에 추가적인 sql이 동작하지 않는 것 입니다.

 

이번에는 아래와 같이 Join문으로 고쳐서 쿼리문을 작성해봤습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("hello");
        EntityManager entityManager = entityManagerFactory.createEntityManager();
 
        EntityTransaction tx = entityManager.getTransaction();
        tx.begin();
 
        try {
            Team teamA = new Team();
            teamA.setName("팀A");
            entityManager.persist(teamA);
 
            Team teamB = new Team();
            teamB.setName("팀B");
            entityManager.persist(teamB);
 
            Member member1 = new Member();
            member1.setUsername("member1");
            member1.setTeam(teamA);
            entityManager.persist(member1);
 
            Member member2 = new Member();
            member2.setUsername("member2");
            member2.setTeam(teamB);
            entityManager.persist(member2);
 
            Member member3 = new Member();
            member2.setUsername("member3");
            member3.setTeam(teamA);
            entityManager.persist(member3);
 
            Member member4 = new Member();
            member2.setUsername("member4");
            entityManager.persist(member4);
 
            entityManager.flush();
            entityManager.clear();
 
            String query = "select m From Member m join m.team";
 
            List<Member> result = entityManager.createQuery(query, Member.class).getResultList();
            for (Member member : result) {
                System.out.println("Member : "+member.getTeam().getName());
            }
        tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            entityManager.close();
        }
        entityManager.close();
        entityManagerFactory.close();
    }
}
 
cs

 
아래의 그림에서보면 똑같이 N+1 문제가 발생하는것을 볼 수 있습니다. 연관된 엔티티들을 함께 영속성 컨텍스트에 올리지 않기 때문에 select문이 추가적으로 발생합니다.


Distinct

이번에는 아래와 같이 코드를 고쳐서 team을 조회해봤습니다.

1
2
3
4
5
6
            String query = "select t From Team t join fetch t.members";
 
            List<Team> result = entityManager.createQuery(query, Team.class).getResultList();
            for (Team team : result) {
                System.out.println("team.name = " + team.getName()+"team.members size"+team.getMembers().size());
            }
cs

 

결과로는 아래와 같이 team이 3번 조회되는데, 그 이유로는 team과 관련된 member가 3개 이기 때문에 A, B, A가 조회된 것 입니다.

이것을 흔히 데이터 뻥튀기라고 합니다. DB입장에서 일대다 Join을 할 경우 흔히 일어나는 현상입니다. Join을 아래와 같이 실행하면

DB에서는 아래와 같은 데이터를 반환해줍니다.

따라서 JPQL의 경우에도 TeamA가 두번 조회된 것 입니다. Team의 members를 찍어보면 두명이 똑같이 들어가 있는것을 확인할 수 있습니다.

1
2
3
4
5
6
            for (Team team : result) {
                System.out.println("team.name = " + team.getName()+"team.members size"+team.getMembers().size());
                for (Member member : team.getMembers()) {
                    System.out.println(member.getId());
                }
            }
cs

즉 아래와 같이 요약할 수 있습니다. 쿼리문을 통해 받은 teams에는 teamA의 주소가 2개가 들어가 있고 실제 teamA는 하나이며 members로 2명을 가지고 있습니다.

이러한 중복을 제거하기위해서 SQL에 distinct를 입력해주는 방법이 있는데, 원래 DB에서는 이러한 방식으로 중복 제거가 안됩니다. 그 이유는 아래의 그림에서 두개의 row의 모든 데이터가 같지 않기 때문입니다. SQL의 distinct는 모든 칼럼의 값들이 같아야 제거가 됩니다.

하지만 fetch join과 함께 사용하면 JPA가 앱단으로 들고올 때 객체에서 중복된 값들을 제거해줍니다.

따라서 아래와 같은 결과를 얻을 수 있습니다.

1
2
            String query = "select distinct t From Team t join fetch t.members";
 
cs


fetch join의 한계

 

1. fetch join의 대상에는 별칭을 줄 수 없다.

fetch join은 기본적으로 team 관련된 모든 member들을 전부 데려오기 때문에, 아래와 같이 뒤에 조건을 절대 붙이면 안됩니다.

 

1
2
            String query = "select t From Team t join t.members as m where m.name == 'minjoon'";
 
cs

 

그 이유로는 당연할 수 있는데, team에는 원래 여러 맴버들이 들어 있는데 위와 같이 쿼리문을 날리면 minjoon 이름만 들어가있는 team이 생깁니다. 따라서 기존의 데이터와 많이 다른 새로운 데이터가 창조됩니다. 이럴 경우, 데이터의 정합성 이슈가 발생할 수 있습니다.

 

따라서 member에 대한 조건을 넣고 싶으면 team을 조회하는게 아니라 처음부터 member를 조회해야합니다.

 

2. 둘 이상의 컬랙션은 fetch join 할 수 없다.

일대다의 연관관계에서도 데이터가 뻥튀기가 되는데 이럴 경우 일대다대다 이다. 당연히 데이터가 정상적으로 넘어오지않는다.

 

3. 컬렉션을 fetch join 하면 paging이 안된다.

일대다 매핑관계에서 데이터 뻥튀기를 하는데 paging이 들어간다고 생각해보면 아찔하다. paging은 철저히 DB 중심적으로 동작한다.

아래의 그림에서 paging이 sql에 들어가있다고 생각해보면 첫번째 팀A만 가져오는 상황을 생각해볼 수 있습니다. 이 경우 앱단에서 객체로 값을 조회해보면 팀A는 회원1만 가지고있는 팀으로 조회됩니다. 따라서 큰 문제가 발생합니다.

해결 방법으로는 일대다가 아니라 다대일 관계에서 사용하도록하면 됩니다.

 

Batch

collection을 fetch join 하는 경우, 여러가지 문제점들이 발생합니다.

이를 해결하기 위해서 Batch라는 방법도 존재합니다.

아래와 같이 어노테이션을 붙여서 사용 가능합니다.

 

1
2
3
4
 
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
cs

 

1
2
String query = "select t From Team t ";
 
cs

 

이렇게 셋팅하고 코드를 돌려보면 아래와 같은 결과를 얻어낼 수 있습니다. 처음 team을 가져오는 select와 member를 조회할 때 배치사이즈만큼 team에 존재하는 member를 한번에 가져옵니다. 현재 코드에서는 team안에 memberA와 memberB가 존재하므로 2개의 member를 같이 가져왔음을 알 수 있습니다.


결론

JPA의 70%~80% 의 성능 문제는 N+1 문제이다. 이를 해결하기 위해서는

  1. fetch join
  2. batch
  3. 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른
    결과를 내야 하면, 페치 조인 보다는 일반 조인을 사용하고 필요
    한 데이터들만 조회해서 DTO로 반환하는 것이 효과적

참조: https://www.inflearn.com/course/ORM-JPA-Basic/dashboard

profile
러닝커브가 장점인 개발자입니다.

0개의 댓글