N+1문제
- JPA를 사용하면 자주 직면하게 되는 문제이다.
예를 들어, 아래와 같은 연관 관계를 가진 Entity가 있다고 가정해보자
- 구체적인 코드는 아래와 같고 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쿼리!)
위의 예시와 같이 하위 엔티티들을 첫 쿼리 실행시 한 번에 가져오지 않고, 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테이블을 조회하는 것을 볼 수 있다.
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을 통해 한번에 가져오는 것을 확인할 수 있다.
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 필드를 정의하고 사용할 수 있다.
- 실행을 하면 역시나 쿼리는 한 번만 실행되며 세부적인 쿼리는 아래와 같다
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가 생성된 것을 확인할 수 있다.
해결방안
- 해결방안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전략을 가져가는 것은 전적으로 서비스/레포지토리에서 결정해야하는 일이라고 생각한다.