본문 바로가기

JPA/JPQL

N+1과 fetch join

github 전체코드 주소(branch: fetch)

 

Club.java

package hellojpa;

import lombok.Data;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@Data
@Entity
public class Club {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "club")
    private List<Student> students = new ArrayList<>();
}

Student.java

package hellojpa;

import lombok.AccessLevel;
import lombok.Data;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Data
public class Student {

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

    private String name;

    private int age;

    @Setter(AccessLevel.PROTECTED)
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "CLUB_ID")
    Club club;

    void changeClub(Club club) {
        this.setClub(club);
        club.getStudents().add(this);
    }
}

JpaMain.java - club 및 student data 저장 로직

Club c1 = new Club();
c1.setName("c1");
em.persist(c1);

Club c2 = new Club();
c2.setName("c2");
em.persist(c2);

Student student = new Student();
student.setName("code-mania");
student.setAge(21);
student.changeClub(c1);
em.persist(student);

Student student2 = new Student();
student2.setName("code-mania2");
student2.setAge(21);
student2.changeClub(c1);
em.persist(student2);

Student student3 = new Student();
student3.setName("code-mania3");
student3.setAge(21);
student3.changeClub(c2);
em.persist(student3);

em.flush();
em.clear();

 

N+1

JpaMain.java를 실행하면 다음과 같이 데이터가 생성된다.

 

JpaMain.java 실행 후 H2DB

JpaMain.java - students 조회 로직

//일반조인
String query = "select s from Student s join s.club c";
List<Student> students = em.createQuery(query, Student.class).getResultList();

for (Student s : students) {
  System.out.println("name = " + s.getName());
  System.out.println("club = " + s.getClub().getName());
  System.out.println("================================");
}

위와 같이 작성한 로직에서 쿼리는 몇 번 나가게 될까?

 

JpaMain.java 실행 후 로그

Hibernate: select s.* from Student s inner join Club c on s.CLUB_ID=c.id
name = code-mania
Hibernate: select c.id, c.name from Club c where c.id=?
club = c1
================================
name = code-mania2
club = c1
================================
name = code-mania3
Hibernate: select c.id, c.name from Club c where c.id=?
club = c2
================================

로그를 살펴보면 student를 조회하는 쿼리가 제일 먼저 나갔다.
그 후 club을 join했음에도 지연로딩 설정으로 인해
영속성 컨텍스트에 없는 club을 조회할 때마다 select문이 나가게 된다.

이 때 최대로 나갈 수 있는 쿼리 개수는 N(조회된 총 데이터 수) + 1이다.

N + 1은 성능을 굉장히 저하시키는 문제이다.
이를 fetch join을 통해 해결할 수 있다.

Fetch join

JpaMain.java - 조회 로직

//페치조인
String query = "select s from Student s join fetch s.club c";
List<Student> students = em.createQuery(query, Student.class).getResultList();

for (Student s : students) {
  System.out.println("name = " + s.getName());
  System.out.println("club = " + s.getClub().getName());
  System.out.println("================================");
}

JpaMain.java 실행 후 로그

Hibernate: select s.*, c.* from Student s 
inner join Club c on student0_.CLUB_ID=club1_.id
name = code-mania
club = c1
================================
name = code-mania2
club = c1
================================
name = code-mania3
club = c2
================================

 

fetch 조인을 사용하면 지연로딩으로 설정되어있는 연관필드를 한 번의 쿼리로 들고 올 수 있다.
결과적으로 fetch 조인을 통해 N + 1 문제가 해결되었다.

 

컬렉션 페치조인

JpaMain.java - 조회 로직

//컬렉션페치조인
String query = "select c from Club c join fetch c.students s";
List<Club> clubs = em.createQuery(query, Club.class).getResultList();

for (Club c : clubs) {
  System.out.println("name = " + c.getName());
  System.out.println("student size = " + c.getStudents().size());
  System.out.println("================================");
}

JpaMain.java 실행 후 H2DB

H2 DB에 데이터가 위와 같고,
JPQL을 보면 club에서 collection 연관필드인 students를 경로탐색을 통해 조회하고 있다.

 

JpaMain.java 실행 후 로그

Hibernate: select c.*,s.* from Club c inner join Student s on c.id=s.CLUB_ID
name = c1
student size = 2
================================
name = c1
student size = 2
================================
name = c2
student size = 1
================================

club 테이블에는 2개의 데이터만이 있지만,
조회결과가 담긴 clubs 변수의 size는 3인 것을 알 수 있다.
왜 이런 것인지 확인해보기 위해 h2에서 직접 inner join을 해보자!

inner join 결과

데이터가 3개다. 왜냐하면 join한 student 테이블 중 club_id가 설정되어있는 행이 3개이기때문이다.
(club_id가 null이거나 club에 없는 값이면  inner jon이기때문에 조회되지 않는다)

 

이를 JPA가 들고 오면 우리가 로그에서 봤듯이 3개의 데이터가 조회된다.
그리고 club name이 c1인 Club 엔티티가 중복저장되어 있다는 것을 알 수 있다.
이러한 중복저장을 제거하기 위해서는 DISTINCT 키워드를 사용하면 된다.

 

DISTINCT

 

JPQL에서 DISTINCT는 2가지 기능을 제공한다.

  • SQL에 DISTINCT가 적용된다.(당연하다고 볼 수 있다)
  • 애플리케이션에서 중복되는 엔티티를 제거한다.
    지금 c1이 중복되고 있는데, DISTINCT를 사용하면 c1이 중복되지 않게 된다.

JpaMain.java - 조회 로직

//컬렉션페치조인
String query = "select distinct c from Club c join fetch c.students s";
List<Club> clubs = em.createQuery(query, Club.class).getResultList();

for (Club c : clubs) {
  System.out.println("name = " + c.getName());
  System.out.println("student size = " + c.getStudents().size());
  System.out.println("================================");
}

 

JpaMain.java 실행 후 로그

Hibernate: select distinct c.*, s.* from Club c inner join Student s on c.id=s.CLUB_ID
name = c1
student size = 2
================================
name = c2
student size = 1
================================

DISTINCT 키워드를 사용하면 중복되던 c1이 중복되지 않는 것을 확인할 수 있다.

 

참고강의: 배달의 민족 개발팀장 김영한 강사님의 JPA 강의

'JPA > JPQL' 카테고리의 다른 글

JPQL 엔티티 조회  (0) 2021.10.15
페치조인 특징과 한계  (0) 2021.10.12
경로표현식  (0) 2021.10.02
JPQL 함수  (0) 2021.09.25
JPQL에서의 Enum과 조건식  (0) 2021.09.17