JPA에 익숙해지기 위해 N+1을 발생시키고 그 과정에서 영속성 컨텍스트와 @Transactional 동작 방식을 이해하는 글 입니다.
EAGER는 보통 실무에서 쓰이지는 않지만 JPA 이해 과정에서 사용한 점 양해 부탁드립니다.
실습 예제는 여기에 있습니다.
서론
'학교'와 '학생'은 1:N의 관계에 있습니다.
그러므로 '학교'는 기본키, '학생'은 외래키로 활용되며 아래와 같이 Entity를 만들 수 있습니다.
@Before로 데이터 세팅
@Before
public void setup() {
List<School> schools = new ArrayList<>();
for(int i=1; i<=5; i++){
School school = new School("school"+i);
school.addStudent(new Student("Lee"+i));
school.addStudent(new Student("Kim"+i));
schools.add(school);
}
schoolRepository.saveAll(schools);
}
School Entity
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
public class School {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "school", cascade = CascadeType.ALL)
private List<Student> students = new ArrayList<>();
public School(String name) {
this.name = name;
}
public void addStudent(Student student){
this.students.add(student);
student.updateSchool(this);
}
}
Student Entity
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "school_id")
private School school;
public Student(String name) {
this.name = name;
}
public void updateSchool(School school){
this.school = school;
}
}
본론
EAGER(즉시로딩)에서 N+1 테스트
EAGER에서 N+1을 실행하기 위해 findAll()를 사용하겠습니다.
FetchType.EAGER 테스트
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "school_id")
private School school;
N+1 테스트를 위해 School에 FetchType.EAGER로 설정했습니다.
① Student의 모든 데이터를 조회하기 위해 findAll()을 사용
@Test
@DisplayName("N+1 예제 - 1")
public void test1() throws Exception {
System.out.println("== start ==");
List<Student> studentList = studentRepository.findAll();
System.out.println(studentList.get(0).getSchool().getName());
System.out.println("== end ==");
}
findAll()을 통해 Student 테이블을 조회할 때 School을 가져오면서 쿼리가 총 6번이 발생하는 N+1 현상이 나타났습니다.
위의 결과가 N+1이 발생하는 이유는 findAll()은 기본적으로 위의 코드에서 Student 엔티티에 대해서만 모두 출력해야만 합니다. 하지만 Student가 갖고 있는 School 필드가 존재하고 즉시로딩인 EAGER 조회 방식으로 인하여 관련된 School 테이블이 전부 읽히게 되어 N+1이 발생한 것입니다.
② 위의 테스트에 @Transactional 사용
@Test
@Transactional
@DisplayName("N+1 예제 - 2")
public void test2() throws Exception {
System.out.println("== start ==");
List<Student> studentList = studentRepository.findAll();
System.out.println(studentList.get(0).getSchool().getName());
System.out.println("== end ==");
}
@Transactional 하나로 N+1이 발생하지 않았습니다.
발생하지 않은 이유는 이미 @Before로 저장 시에 영속성 컨텍스트 1차 캐시에 저장되었기 때문입니다.
그래서 1차 캐시에 저장된 데이터를 읽어온 것이라 N+1이 발생하지 않은 것 입니다.
JPA는 저장과 조회 시에 1차 캐시에 저장되기 때문입니다.
@Before와 @After는 테스트 메소드 앞뒤로 붙기 때문에 테스트 메소드의 @Transactional 영향을 받아 저장 역시 영속성 컨텍스트 범위 안에서 사용 되기 때문입니다.
@Before 역시 테스트 코드의 트랜잭션에 영향을 받기 때문에 같은 트랜잭션 내에 영속성 컨텍스트로 분류됩니다.
너무 당연한 얘기지만 이건 N+1에 대한 해결 방법이 아닙니다.
단순히 1차 캐시가 존재할 때는 N+1이 안보일 수도 있다라는 생각으로 봐주세요.
FetchType.LAZY 테스트
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "school_id")
private School school;
③ LAZY 설정 후 1번 테스트 다시 실행
@Test
@DisplayName("N+1 예제 - 1")
public void test1() throws Exception {
System.out.println("== start ==");
List<Student> studentList = studentRepository.findAll();
System.out.println(studentList.get(0).getSchool());
System.out.println("== end ==");
}
LazyInitializationException 예외가 발생합니다.
@Transactional이 없기 때문에 영속성 컨텍스트가 존재하지 않아 발생하는 예외입니다.
LAZY 조회 시에는 지연로딩이 발생하여 student만 우선 조회하고 school을 조회할 시에 영속성 컨텍스트의 기능인 lazy load를 실행해야 하지만 영속성 컨텍스트가 없어서 예외가 일어납니다.
EAGER 조회 시에는 에러가 나지 않았던 이유는 LAZY에서는 지연로딩을 사용하기 때문에 getSchool()이 실행 될 때 실제 쿼리가 발생되기 위해서는 영속성 컨텍스트가 필요했습니다.
하지만 EAGER는 한 방에 조회하는 기능이기에 지연로딩이 필요하지도 않고 한번에 저장하면 끝이기 때문에 1차 캐시등에 관한 부분도 필요 없고 결과적으로 영속성 컨텍스트도 필요가 없어서 에러가 나지 않았습니다.
④ LAZY 설정 후 2번 테스트 다시 실행
@Transactional이 있으면 영속성 컨텍스트가 존재하기 때문에 N+1도 발생하지 않고 예외도 발생하지 않습니다.
그러므로 Student 까지만 정상 조회를 할 것 입니다. 그리고 School을 조회해야할 때 반복적으로 for문을 돌리거나 DTO에 세팅할 때 School을 참조하면 그때 지연 로딩이 발생하게 되는데, 의도하지 않은 지연로딩으로 인해 수많은 쿼리가 발생한다면 해당 문제도 N+1이라고 봅니다. 해당 문제는 JPQL의 Fetch Join을 이용해 한꺼번에 들고 올 수 있습니다.
정리
EAGER 사용 + findAll() = 항상 N+1 발생
LAZY 사용 + findAll() = 트랜잭션이 존재하지 않으면 예외, 트랜잭션이 존재하면 N+1 해결됨.
다만, 불필요한 Lazy Load를 해올 시 N+1 발생 가능성 있음
위의 케이스에서 유일한 해결법은 Lazy Loading을 사용하는 것이었습니다.
그러므로 JPA는 올바른 사용을 위해서는 EAGER보다는 LAZY를 더 권장하는 것 입니다.
그렇다면 불필요한 Lazy Load를 해올 시 N+1이 발생하는 경우를 아래에서 확인해보겠습니다.
LAZY(지연로딩)에서 N+1 테스트
아래는 지연로딩 환경에서 조회 기능을 이용해 N+1을 발생 시켜봤습니다.
StudentService
@Transactional
public void findAllSchoolNames(){
List<Student> students = studentRepository.findAll();
getSchoolName(students);
}
private List<String> getSchoolName(List<Student> students){
return students.stream()
.map(a -> a.getSchool().getName())
.collect(Collectors.toList());
}
@Test
@DisplayName("N+1 예제 - 3")
public void test3() throws Exception {
System.out.println("== start ==");
studentService.findAllSchoolNames();
System.out.println("== end ==");
}
N+1이 발생합니다. findAll()로는 Student 1줄이 뜨지만 관련된 School을 가져오기 위해 람다식을 이용해 접근하면서 총 5번의 쿼리가 발생했습니다.
이렇게 불필요하게 쿼리가 발생하는 것도 N+1입니다. 이러한 상황에서는 JPQL의 Fetch Join을 통해 해결하게 됩니다.
'📖 ORM' 카테고리의 다른 글
Fetch Join 사용 시 조건문(Condition) 올바르게 사용하기 - 실습으로 배우는 JPA 4편 (0) | 2022.11.03 |
---|---|
@Transactional 사용 시 자기 호출(Self-Invocation) 이슈 - 실습으로 배우는 JPA 3편 (2) | 2022.07.06 |
@OnDelete와 CascadeType.ALL, orphanRemoval 속성 (0) | 2022.06.24 |
@ManyToOne과 @OneToMany로 배우는 JPA 기초 사용법 - 실습으로 배우는 JPA 1편 (2) | 2022.06.20 |
Kotlin 에서의 JPA Builder (0) | 2021.03.29 |