1. JPA N+1 문제
1-1. JPA N+1 문제 개념
우선 아래 코드는 전형적인 N+1 문제가 발생하는 코드이다.
어떤 부분에서 발생하는지 살펴보자
// user entity class
@Entity
public class User{
...
@OneToMany(mappedBy="user")
private List<Posts> posts;
}
// post entity class
@Entity
public class Posts{
....
@Column
private String title;
....
@ManyToOne
private User user;
}
// user repository
@Repository
public Interface UserRepository extends JpaRepository<User, Long>;
// user service
@Service
public class UserService{
@Autowired
public UserRepository userRepository;
@Transactional
public void checkNPlus1(){
List<User> users = userRepository.findAll();
users.forEach(user -> {
List<String> titles = user.getPosts().stream()
.map(post -> post.getTitle()).toList();
});
}
}
위 코드의 checkNPlus1 함수에서는 아래 순서로 쿼리가 발생한다.
- user 목록 조회 ( select 1회 )
- user마다 post 목록 조회 ( select n회 )
즉, 정리하자면, 아래와 같이 쿼리가 발생한다.
# 1회
select u.id, u.name
from user u;
# n회 반복
select p.id, p.title, p.user_id
from posts p
where p.user_id = ?;
따라서 실제로는 n+1회의 쿼리가 발생하며, 이는 데이터베이스 부하와 서비스 성능 저하로 이어질 수 있다.
이 문제를 해결하기 위해서, 이번 게시글에서는 n+1 문제를 해결할 수 있는 여러 방법들에 대해서 비교하고, 어떤 상황에서 사용하는 것이 좋을지에 대해서 알아볼 것이다.
2. Fetch Join을 통한 문제 해결
2-1. Fetch Join vs Inner Join
기본적으로 JPQL은 Fetch Join과 Inner Join을 모두 제공하지만, n+1 문제를 해결하기 위해서는 Fetch Join을 사용해야 한다
이에 대한 답은 Fetch Join과 Inner Join의 작동 원리 차이에 있다.
| Fetch Join | Inner Join | |
| 영속화 수준 | JPQL의 주체 Entity와 연관 Entity | JPQL의 주체 Entity |
| 사용처 | 연관 데이터를 한 번에 조회 및 영속화 | 연관 데이터 기반 필터링 및 특정 값 조회 |
결론
- Inner Join을 통해 연관 Entity를 함께 조회하더라도, 연관 Entity는 비영속 상태이므로 추가 쿼리가 발생하게 된다. ( n+1 )
- Fetch Join을 통해 연관 Entity를 조회한다면, 연관 Entity가 영속 상태이므로 FetchType에 상관없이 추가 쿼리가 발생하지 않는다.
- 따라서 n+1 문제를 해결하기 위해서는 Fetch Join을 사용해야 한다.
2-2. Fetch Join을 통한 문제 해결 방법
Fetch Join을 통해서 위 코드를 수정해보자.
Fetch Join을 사용하기 위해서는 아래와 같이 JPQL을 기반으로 쿼리를 작성해야 한다.
// user repository
@Repository
public Interface UserRepository extends JpaRepository<User, Long>{
@Query("select distinct u from user u join fetch post p on p.user=u")
List<User> findAll();
}
해당 방법을 사용한다면 기존 n+1 문제가 발생하던 쿼리가 아래처럼 간략화된다.
# after : fetch join
select distinct u.id, u.name, p.id, p.title, p.user_id
from user u join posts p on u.id = p.user_id;
2-3. Fetch Join 장단점
장점
- 1회 조회로 연관 Entity까지 조회 가능
단점
- @Query를 직접 작성해야하기에 휴먼 에러 발생 가능
- Fetch Join의 페이지네이션 문제
- 문제 1 : Fetch Join 시 주체 Entity에 대한 여러개의 연관 Entity 쌍이 생성될 수 있는데, 이는 데이터 중복으로 인한 부정확한 페이징으로 이어질 수 있음
- 문제 2 : Hibernate는 Fetch Join에 페이지네이션을 적용하면 자동으로 서버 메모리 수준에서 페이징을 처리하는데, 이 때 데이터가 많다면 서버 메모리 부하가 발생
3. @BatchSize를 통한 최적화
3-1. @BatchSize 작동 원리
@BatchSize는 간단하게 말해서 최대 n개의 연관 Entity를 한 번에 DB에서 조회하는 기술이다.
따라서 현재 필요한 연관 Entity를 다음과 같이 조회한다.
select p
from posts p
where p.id in (?, ?, ....);
영속성 컨텍스트에서 아직 로드되지 않은 연관 Entity는 프록시 Entity로 존재한다.
그리고 한 번에 조회할 id를 수집하는 방법과 쿼리를 보내는 시기는 다음과 같다.
- 데이터 조회 시기 : 로드되지 않은 프록시 Entity에 접근
- 한 번에 조회할 ID 수집 방법 : 영속성 컨텍스트에서 아직 로드되지 않은 최대 batch_size만큼의 id를 수집
정리하자면, @BatchSize는 필요한 시기에 적절하게 데이터를 한 번에 로드하는 방법이다.
따라서 BatchSize를 적용한다면 n+1 문제를 ( n+1 / batch_size ) 쿼리로 최적화할 수 있다.
3-2. @BatchSize를 통한 문제 해결 방법
@BatchSize를 사용한다면 기존 코드를 다음과 같이 최적화할 수 있다.
// user entity class
@Entity
public class User{
...
@BatchSize(size=50)
@OneToMany(mappedBy="user")
private List<Posts> posts;
}
이를 통해서 쿼리는 아래와 같이 총 1 + (n/50)회 발생하게 된다
# 사용자 목록 조회 : 1회
select * from User u;
# batch size만큼 post 조회
select * from Posts p where p.id in (?, ?, ....);
3-3. @BatchSize 장단점
장점
- 페이지네이션을 적용하면서 n+1 문제 최적화 가능
- 구현이 간단함
단점
- fetch join에 비해 성능이 떨어지고 batch size가 작다면 성능이 하락함
- 한 번에 많은 Entity를 로드했을 때 메모리 사용량이 증가함
4. 마무리
결론부터 말하자면, 무작정 n+1 문제를 해결하기 위해서 fetch join을 사용하면 안된다는 것을 알았다.
사실 최고는 n+1 문제가 발생하는 상황을 만들지 않는 것이겠지만, 엔티티 간 연관관계가 복잡해질 수록 n+1 문제를 직면하는 것은 불가피할 것이다.
따라서 서비스 요구사항과 상황에 맞는 적절한 문제 해결 전략을 선택하는 것이 중요하다.
나라면 일반적인 상황에서는 Fetch Join을 사용하고, 페이지네이션이 필요하다면 @BatchSize를 사용할 것이다.