JPA에서 N + 1 문제가 발생하는 원인은 지연 로딩(lazy loading) + 잘못된 쿼리 작성 방식이다.
예를 들어, 데이터베이스에 User가 10명 있고, 각 User는 여러개의 Post가 있다고 가정한다.(User와 Post는 1:N 관계)
List<User> users = userRepository.findAll(); // (1) User 10명 조회. 1번 쿼리
for (User user : users) {
System.out.println(user.getPosts().size()); // (2) User마다 Post 조회. 10번 쿼리
}
이때 User 엔티티에서 posts는 다음처럼 지연 로딩으로 설정돼 있다고 가정한다.
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
private List<Post> posts;
(1) User를 가져올 때 한 번의 쿼리
SELECT * FROM users;
(2) for 루프를 돌면서 각 user.getPosts()가 호출될 때마다 매번 발생하는 쿼리(이 쿼리가 10명의 User에 대해 10번 실행됨)
SELECT * FROM posts WHERE user_id = ?;
-> 결과적으로 1 + N 번 쿼리가 실행됨
userRepository.findAll()로 가져온 User들이 연관 데이터를 사용할 것이라면, 이걸 미리 한 번의 쿼리로 가져오게 해야 성능 문제가 발생하지 않는다. 그런데, findAll()만 써놓고 아무 처리도 안 해주는 게 "잘못된 쿼리 작성 방식"
Fetch JoinJPQL에서 연관 엔티티를 함께 즉시 로딩(Eager Loading)으로 가져오도록 명시하는 방법
@Query("SELECT u FROM User u JOIN FETCH u.posts")
List<User> findAllWithPosts();
User와 연관된 Post들을 한 번에 Join해서 가져옴SELECT u.*, p.*
FROM users u
JOIN posts p ON u.id = p.user_id;
결과:
User + Post를 한 번에 메모리에 로딩함
user.getPosts()를 호출해도 추가 쿼리가 없음
@EntityGraphSpring Data JPA에서 사용하는 방식.
쿼리 메서드에 fetch 설정을 더해주는 것
@EntityGraph(attributePaths = "posts")
List<User> findAll(); // @EntityGraph에 의해 posts도 즉시 로딩됨
JPQL을 작성하지 않고도 Fetch Join과 동일한 효과를 낼 수 있음
내부적으로는 LEFT OUTER JOIN FETCH로 동작
SELECT u FROM User u LEFT JOIN FETCH u.posts
즉, posts 컬렉션을 LEFT OUTER JOIN FETCH로 한 번에 가져옴.
LEFT이기 때문에 User는 다 가져오고, posts가 없어도 null로 채워서 리턴
Repository에서 선언적 방식으로 Fetch 설정을 하고 싶을 때 자주 사용
지연 로딩(Lazy Loading) 자체는 유지하면서,
여러 개의 프록시 객체를 하나의 쿼리로 묶어서 가져오도록 최적화하는 설정 방법
배치 사이즈 설정법
application.yml또는 application.properties를 통해서 배치 사이즈 설정
spring:
jpa:
properties:
hibernate.default_batch_fetch_size: 10
예시
예를 들어 10명의 User를 가져왔고, 각각의 user.getPosts()를 호출할 때 기본 설정이면 10번의 쿼리가 발생함
이때 default_batch_fetch_size를 설정하면 JPA는 이런 식으로 쿼리를 묶어서 날림
SELECT * FROM posts WHERE user_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
-> 즉, 지연 로딩이지만 배치로 한 번에 가져오므로 N + 1 문제 해결 가능
(dafault_batch_fetch_size=10)spring.jpa.properties.hibernate.default_batch_fetch_size=10
List<User> users = userRepository.findAll(); // 1번 쿼리
for (User user : users) {
System.out.println(user.getPosts().size()); // 실제로는 여기서 1번 쿼리만 추가 발생
}
쿼리 흐름
1. findAll() 실행
-> SELECT * FROM users; - User 10명 메모리에 올라옴
2. 루프가 돌기 시작하면 첫 번째 user.getPosts() 호출됨
-> Hibernate가 "얘네 다 LAZY 프록시 상태네?"하고 감지함
3. default_batch_fetch_size=10이 설정되어 있으면
-> user_id 10개를 한 번에 IN절로 묶어서 쿼리함
SELECT * FROM posts WHERE user_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
User가 30명이고, default_batch_fetch_size=10이면?