Posts [JPA] JPA N+1 문제
Post
Cancel

[JPA] JPA N+1 문제

N+1문제

  • JPA를 사용하면 자주 직면하게 되는 문제이다.
  • 예를 들어, 아래와 같은 연관 관계를 가진 Entity가 있다고 가정해보자
    스크린샷 2021-06-04 오후 4 05 09

  • 구체적인 코드는 아래와 같고 ORM은 spring-data-jpa를 사용한다.
    • Academy.java
    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
    
      package com.example.domain;
    
      import lombok.Builder;
      import lombok.Getter;
      import lombok.NoArgsConstructor;
    
      import javax.persistence.*;
      import com.example.domain.Subject;
      import java.util.ArrayList;
      import java.util.List;
    
      @Entity
      @Getter
      @NoArgsConstructor
      public class Academy {
    
          @Id
          @GeneratedValue
          private Long id;
    
          private String name;
    
          @OneToMany(cascade = CascadeType.ALL)
          @JoinColumn(name="academy_id")
          private List<Subject> subjects = new ArrayList<>();
    
          @Builder
          public Academy(String name, List<Subject> subjects) {
              this.name = name;
              if(subjects != null) {
                  this.subjects = subjects;
              }
          }
    
          public void addSubject(Subject subject) {
              this.subjects.add(subject);
              subject.updateAcademy(this);
          }
      }
    
    • Subject.java
    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
    
      package com.example.domain;
    
      import lombok.Builder;
      import lombok.Getter;
      import lombok.NoArgsConstructor;
    
      import javax.persistence.*;
    
      @Entity
      @Getter
      @NoArgsConstructor
      public class Subject {
    
          @Id
          @GeneratedValue
          private Long id;
    
          private String name;
    
          @ManyToOne(fetch = FetchType.LAZY)
          @JoinColumn(name = "academy_id", foreignKey = @ForeignKey(name = "FK_SUBJECT_ACADEMY"))
          private Academy academy;
    
          @ManyToOne(fetch = FetchType.LAZY)
          @JoinColumn(name = "teacher_id", foreignKey = @ForeignKey(name = "FK_SUBJECT_TEACHER"))
          private Teacher teacher;
    
          @Builder
          public Subject(String name, Academy academy, Teacher teacher) {
              this.name = name;
              this.academy = academy;
              this.teacher = teacher;
          }
    
          public void updateAcademy(Academy academy) {
              this.academy = academy;
          }
    
      }
    
    • Teacher.java
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    
      package com.example.domain;
    
      import lombok.Getter;
      import lombok.NoArgsConstructor;
    
      import javax.persistence.Entity;
      import javax.persistence.GeneratedValue;
      import javax.persistence.Id;
    
      @Entity
      @Getter
      @NoArgsConstructor
      public class Teacher {
    
          @Id
          @GeneratedValue
          private Long id;
    
          private String name;
    
          public Teacher(String name) {
              this.name = name;
          }
      }
    
    • AcademyService.java
    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
    
      package com.example.domain;
    
      import lombok.extern.slf4j.Slf4j;
      import org.springframework.stereotype.Service;
      import org.springframework.transaction.annotation.Transactional;
    
      import java.util.ArrayList;
      import java.util.List;
      import java.util.stream.Collectors;
    
      @Service
      @Slf4j
      public class AcademyService {
    
          private AcademyRepository academyRepository;
    
          public AcademyService(AcademyRepository academyRepository) {
              this.academyRepository = academyRepository;
          }
    
          @Transactional(readOnly = true)
          public List<String> findAllSubjectName() {
              List<Academy> academies = academyRepository.findAll();
              return extractSubjectNames(academies);
          }
    
          @Transactional
          public List<Academy> findAll() {
              List<Academy> academies = academyRepository.findAll();
              return academies;
          }
    
          private List<String> extractSubjectNames(List<Academy> academies) {
    
              System.out.println(">>>>>>>>>>>> [모든 과목을 추출한다] <<<<<<<");
              System.out.println("Academy Size: " + academies.size());
    
              List<String> subjectNames = new ArrayList<>();
              System.out.println("===========================================");
              for(Academy academy:academies) {
                  String subjectName = academy.getSubjects().get(0).getName();
                  subjectNames.add(subjectName);
              }
              System.out.println("===========================================");
    
              return subjectNames;
          }
      }
    
  • 만약 AcademyService의 findAllSubjectNames()를 호출하면 어떤일이 발생할까? 테스크 코드는 아래와 같다
    • AcademyServiceTest.java
    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
    
      package com.example.domain;
    
      import org.junit.jupiter.api.AfterEach;
      import org.junit.jupiter.api.BeforeEach;
      import org.junit.jupiter.api.Test;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.boot.test.context.SpringBootTest;
    
      import java.util.ArrayList;
      import java.util.List;
    
      import static org.hamcrest.MatcherAssert.assertThat;
      import static org.hamcrest.Matchers.is;
      import static org.junit.jupiter.api.Assertions.*;
    
      @SpringBootTest
      class AcademyServiceTest {
    
    
          @Autowired
          private AcademyRepository academyRepository;
    
          @Autowired
          private AcademyService academyService;
    
          @BeforeEach
          void setUp() {
              List<Academy> academies = new ArrayList<>();
    
              for(int i=0;i<10;i++) {
                  Academy academy = Academy.builder()
                          .name("강남스쿨" + i)
                          .build();
    
                  academy.addSubject(Subject.builder().name("자바웹개발" + i).build());
                  academies.add(academy);
              }
    
              academyRepository.saveAll(academies);
          }
    
          @AfterEach
          void cleanAll() {
              academyRepository.deleteAll();
          }
    
          @Test
          public void Academy여러개를_조회시_Subject가_N1_쿼리가발생한다() throws Exception {
              //given
              List<String> subjectNames = academyService.findAllSubjectName();
    
              //then
              assertThat(subjectNames.size(), is(10));
          }
      }
    
  • 실행 결과는 아래의 사진과 같이 academy 전체 조회하는 쿼리 한개와 각각의 Academy가 참조하고 있는 subject를 조회하는 쿼리10개가 발생한 것을 확인할 수 있다.(N+1쿼리!)
    1

  • 위의 예시와 같이 하위 엔티티들을 첫 쿼리 실행시 한 번에 가져오지 않고, Lazy Loading으로 필요한 곳에서 사용되어 쿼리가 실행될때 발생하는 문제가 N+1 쿼리 문제이다

  • 지금은 Academy가 10개이니 첫조회(1) + 10개의 Academy의 subject 조회(10) = 11 밖에 발생하지 않았지만, 만약 Academy 조회결과가 10만개라면 총 10만 1개의 쿼리가 실행될 것이다. 한 번에 서비스 로직 실행에서 DB조회가 10만번 일어난다는건 말이 안되는 일이다. 그래서 이렇게 연관관계가 맺어진 Entity를 한번에 가져오기 위해선 몇가지 방법들이 존재한다.

N+1문제의 해결책

1. Join Fetch

1
2
3
4
5
/**
* 1. join fetch를 통한 조회
*/
@Query("select a from Academy a join fetch a.subjects")
List<Academy> findAllJoinFetch();
  • 조회시 바로 가져오고 싶은 Entity 필드를 지정 (join fetch a.subjects)하는 것
  • AcademyRepository.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.domain;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface AcademyRepository extends JpaRepository<Academy, Long> {

    /**
     * 1. join fetch를 통한 조회
     */
    @Query("select a from Academy a join fetch a.subjects")
    List<Academy> findAllJoinFetch();

}
  • 실행 결과는 아래와 같이 단 하나의 쿼리만 발생하게 되고 세부적인 쿼리를 보면 inner join을 활용하여 academy와 subject테이블을 조회하는 것을 볼 수 있다.
    2
1
2
3
4
Hibernate: 
select academy0_.id as id1_0_0_, subjects1_.id as id1_3_1_, academy0_.name as name2_0_0_, subjects1_.academy_id as academy_3_3_1_, subjects1_.name as name2_3_1_, subjects1_.teacher_id as teacher_4_3_1_, subjects1_.academy_id as academy_3_3_0__, subjects1_.id as id1_3_0__ 

from academy academy0_ inner join subject subjects1_ on academy0_.id=subjects1_.academy_id
  • 추가로 만약 Subject의 하위 Entity인 Teacher까지 한번에 가져와야 할때도 아주 쉽게 해결할 수 있다.
1
2
3
4
5
/**
* 5. Academy+Subject+Teacher를 join fetch로 조회
*/
@Query("select a from Academy a join fetch a.subjects s join fetch s.teacher")
List<Academy> findAllWithTeacher();
  • a.subjects를 s로 alias하여 s의 teacher를 join fetch 하면 inner join을 통해 한번에 가져오는 것을 확인할 수 있다.
    3
1
2
3
4
Hibernate: 
select academy0_.id as id1_0_0_, subjects1_.id as id1_3_1_, teacher2_.id as id1_4_2_, academy0_.name as name2_0_0_, subjects1_.academy_id as academy_3_3_1_, subjects1_.name as name2_3_1_, subjects1_.teacher_id as teacher_4_3_1_, subjects1_.academy_id as academy_3_3_0__, subjects1_.id as id1_3_0__, teacher2_.name as name2_4_2_ 

from academy academy0_ inner join subject subjects1_ on academy0_.id=subjects1_.academy_id inner join teacher teacher2_ on subjects1_.teacher_id=teacher2_.id
  • 하지만 이 방법은 불필요한 쿼리문이 추가되는 단점이 있다.
    • 이 필드는 Eager 조회, 저 필드는 Lazy 조회를 해야한다까지 쿼리에서 표현하는 것은 불필요하다라고 생각할 수 도 있다. 이럴 경우엔 아래의 방법을 사용해보시면 좋다.

2. @EntityGraph

1
2
3
4
5
6
/**
* 2. @EntityGraph
*/
@EntityGraph(attributePaths = "subjects")
@Query("select a from Academy a")
List<Academy> findAllEntityGraph();
  • @EntityGrapth의 attributePaths에 쿼리 수행시 바로 가져올 필드명을 지정하면 outer join을 활용하여 Lazy가 아닌 Eager 조회로 가져오게 된다.
  • 원본 쿼리의 손상 없이(select a from Academy a) Eager/Lazy 필드를 정의하고 사용할 수 있다.
  • 실행을 하면 역시나 쿼리는 한 번만 실행되며 세부적인 쿼리는 아래와 같다
    4
1
2
3
4
Hibernate: 
select academy0_.id as id1_0_0_, subjects1_.id as id1_3_1_, academy0_.name as name2_0_0_, subjects1_.academy_id as academy_3_3_1_, subjects1_.name as name2_3_1_, subjects1_.teacher_id as teacher_4_3_1_, subjects1_.academy_id as academy_3_3_0__, subjects1_.id as id1_3_0__ 

from academy academy0_ left outer join subject subjects1_ on academy0_.id=subjects1_.academy_id
  • 만약 추가로 Teacher까지 한번에 가져오는 쿼리도 아래와 같이 표현할 수 있다.
1
2
3
4
5
6
/**
* 6. Academy+Subject+Teacher를 @EntityGraph 로 조회
*/
@EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
@Query("select a from Academy a")
List<Academy> findAllEntityGraphWithTeacher();

사용시 주의사항

  • join fetch와 @EntityGraph 사용시 출력되는 쿼리를 한 번 비교해보자

Join Fetch

1
2
3
4
5
6
7
8
9
10
11
SELECT academy0_.id          AS id1_0_0_, 
       subjects1_.id         AS id1_1_1_, 
       academy0_.name        AS name2_0_0_, 
       subjects1_.academy_id AS academy_3_1_1_, 
       subjects1_.name       AS name2_1_1_, 
       subjects1_.teacher_id AS teacher_4_1_1_, 
       subjects1_.academy_id AS academy_3_1_0__, 
       subjects1_.id         AS id1_1_0__ 
FROM   academy academy0_ 
       INNER JOIN subject subjects1_ 
               ON academy0_.id = subjects1_.academy_id

@EntityGrapth

1
2
3
4
5
6
7
8
9
10
11
SELECT academy0_.id          AS id1_0_0_, 
       subjects1_.id         AS id1_1_1_, 
       academy0_.name        AS name2_0_0_, 
       subjects1_.academy_id AS academy_3_1_1_, 
       subjects1_.name       AS name2_1_1_, 
       subjects1_.teacher_id AS teacher_4_1_1_, 
       subjects1_.academy_id AS academy_3_1_0__, 
       subjects1_.id         AS id1_1_0__ 
FROM   academy academy0_ 
       LEFT OUTER JOIN subject subjects1_ 
                    ON academy0_.id = subjects1_.academy_id 
  • JoinFetch는 Inner Join, EntityGraph는 Outer Join이라는 차이점이 있다.
  • 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 Subject의 수만큼 Academy가 중복 발생하게 된다.
  • 확인을 위해 아래의 테스트 코드를 추가해보자
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
    @Before
    public void setup() {
        List<Academy> academies = new ArrayList<>();

        for(int i=0;i<10;i++){
            Academy academy = Academy.builder()
                    .name("강남스쿨"+i)
                    .build();

            academy.addSubject(Subject.builder().name("자바웹개발" + i).build());
            academy.addSubject(Subject.builder().name("파이썬자동화" + i).build()); // Subject를 추가 !!!!!!!!!!
            academies.add(academy);
        }

        academyRepository.save(academies);
    }

    @Test
    public void Academy여러개를_joinFetch로_가져온다() throws Exception {
        //given
        List<Academy> academies = academyRepository.findAllJoinFetch();
        List<String> subjectNames = academyService.findAllSubjectNamesByJoinFetch();

        //then
        assertThat(academies.size(), is(20)); // 20개가 조회!?
        assertThat(subjectNames.size(), is(20)); // 20개가 조회!?
    }
  • ‘파이썬자동화’ subject를 추가 후 테스트 결과를 확인해보면 아래의 사진처럼 10개의 Academy가 아닌, inner join에 의해 20개의 Academy가 생성된 것을 확인할 수 있다.
    5

해결방안

  • 해결방안1) 일대다 필드의 타입을 Set으로 선언
    • Set은 중복을 허용하지 않는 자료구조이기 때문에 중복등록이 되지 않음
    • Set은 기본적으로 순서가 보장되지 않기에 LinkedHashSet을 사용하여 순서를 보장함
    1
    2
    3
    
      @OneToMany(cascade = CascadeType.ALL)
      @JoinColumn(name="academy_id")
      private Set<Subject> subjects = new LinkedHashSet<>();
    
  • 해결방안2) distinct를 사용하여 중복을 제거
    • Set보단 List가 적합한 경우에 사용
    • @Query에서 적용하는 것이니 join fetch, @EntityGraph 모두 동일하다.
    1
    2
    
      @Query("select DISTINCT a from Academy a join fetch a.subjects s join fetch s.teacher")
      List<Academy> findAllWithTeacher();
    
    1
    2
    3
    
      @EntityGraph(attributePaths = {"subjects", "subjects.teacher"})
      @Query("select DISTINCT a from Academy a")
      List<Academy> findAllEntityGraphWithTeacher();
    

@NamedEntityGraphs?

  • 보통 N+1 문제 해결을 얘기할 때 @NameEntityGraphs가 예시로 많이 등장한다
  • @NamedEntityGraphs의 경우 Entity에 관련해서 모든 설정 코드를 추가해야하는데, 블로그 작성자의 개인적인 의견으론 Entity가 해야하는 책임에 포함되지 않는다고 한다.
  • A로직에서는 Fetch전략을 어떻게 가져가야 한다는 것은 해당 로직의 책임이지, Entity의 책임이 아니라고 생각한다.
  • Entity에선 실제 도메인에 관련된 코드만 작성하고, 상황에 따라 유동적인 Fetch전략을 가져가는 것은 전적으로 서비스/레포지토리에서 결정해야하는 일이라고 생각한다.

2개이상 자식 테이블에 Join Fetch하고 싶을땐?

출처

This post is licensed under CC BY 4.0 by the author.