JPA 강의를 듣거나 책을 듣다보면 자주 등장하는 단골 주제가 있습니다.
N + 1 문제를 어떻게 해결할 것인지에 대한 방법들이 많이 등장합니다. 면접에서도 단골 문제로 등장할만큼 JPA를 사용한다면 반드시 알아야 할 개념인 것 같습니다.
N + 1 문제, 이렇게만 들으면 어떤 말인지 이해가 잘 안될 거 같습니다.
N + 1 문제를 한 문장으로 정의하자면,
연관 관계가 설정된 엔티티를 조회할 경우에 발생할 수 있는 이슈중의 하나로, 연관 관계가 설정된 엔티티(1개)를 조회할 경우에, 조회한 엔티티와 연관 관계가 설정된 엔티티를 조회하는 추가 조회 쿼리(N개)가 발생하는 현상을 말합니다.
예를 들어, User(회원)을 전체 조회하기 위해서 한번의 쿼리가 나가면 회원과 관련된 게시글들을 찾기위해 회원과 관련된 Article(게시글)도 조회하는 쿼리가 나가게 되는 것입니다.
각각 상황을 나눠서 테스트 케이스 작성을 통해, 쿼리가 어떻게 나가고 어떻게 해결할 수 있는지 살펴보도록 하겠습니다.
User(회원)과 Article(게시글)은 1:N, 일대다 관계를 가진다고 설정하였습니다.
@Entity
@Getter @Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "USERS")
public class User {
@Column(name = "USER_NO")
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "USER_NAME")
private String userName;
@OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
private List<Article> articleList = new ArrayList<>();
}
@Entity
@Getter
@AllArgsConstructor @Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Article {
@Column(name = "ARTICLE_NO")
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "TITLE")
private String title;
@Column(name = "CONTENT")
private String content;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "USER_NO")
private User user;
// 연관관계 편의 메서드
public void addUser(User user) {
this.user = user;
user.getArticleList().add(this);
}
}
Fetch 전략은 Eager로 먼저 작성하였습니다. N + 1 문제는 findAll( )로 조회시 Eager에서도 발생하고,
Lazy 로딩시에도 프록시 객체 내부에 연관관계를 가진 객체에 접근하게 되면 추가적으로 조회 쿼리가 발생하기 때문에 Fetch 전략을 어떤 것을 사용해도 N + 1 문제는 일어납니다.
가장 먼저 Eager로 설정했을때 어떻게 쿼리가 나가는지 보기 위해서, Eager로 Fetch 전략을 설정했습니다.
회원을 조회하고, 다양한 N + 1 문제에서 테스트 할 케이스들을 메서드로 작성하였습니다.
public interface UserRepository extends JpaRepository<User, Long> {
@Query("select distinct u from User u join fetch u.articleList")
List<User> joinFetch();
@Query("select distinct u from User u left join u.articleList")
List<User> selectJoin();
@EntityGraph(attributePaths = {"articleList"}, type = EntityGraph.EntityGraphType.FETCH)
@Query("select distinct u from User u left join u.articleList")
List<User> entityGraphFetch();
@Query(value = "select distinct u from User u join fetch u.articleList",
countQuery = "select count(u) from User u")
Page<User> selectUserByPaging(Pageable pageable);
}
N + 1 문제 상황을 한번 만들어보고, 쿼리가 어떻게 나가는 지 확인해보겠습니다.
User 엔티티의 Fetchtype을 Eager로 설정하고 테스트 케이스를 실행해보겠습니다.
테스트 코드는 아래와 같습니다.
@Test
@Transactional
@DisplayName("Eager일 경우 N + 1 테스트")
public void selectArticleByEager() {
List<User> userList = userRepository.findAll();
}
저도 오늘 실수를 했던 부분이 있었는데, @Transactional 애너테이션을 꼭 붙여주셔야 합니다. 이걸 저도 오늘 누락했다가 Session이 없다는 에러를 만나게 됬습니다.
쿼리가 어떻게 나가는지 실행해 보겠습니다.
현재 초기 데이터는 회원이 4명있고, 회원 한명당 게시글 데이터가 2개 있습니다.
일단 회원을 전체 조회하는 쿼리가 1개가 발생한 것을 확인할 수 있습니다.
그 이후에 발생한 쿼리가 N + 1 문제에서 N개의 쿼리에 해당하는데 확인해보겠습니다.
중간에 잘렸지만 회원을 조회하면서 회원이 4명이므로, 회원 4명과 연관된 게시글을 조회하기 위해 4번의 쿼리가 더 발생한 것을 확인할 수 있습니다.
그렇다면 Lazy로 fetch 전략을 변경하면 결과가 달라질 지 확인해 보겠습니다.
아까 보여드렸던 테스트 코드를 다시한번 실행해보겠습니다.
fetch 전략을 Lazy로 변경하니까 쿼리가 한번만 나가는 것으로 보일 수 있습니다.
하지만, 지연로딩의 핵심은 프록시를 사용한다는 것입니다. User 객체안의 Article들을 가지고 있는 List에 접근하게 되면 프록시 객체를 초기화해야 하기 때문에 추가 쿼리가 발생하게 됩니다.
지연로딩에서도 추가 쿼리가 발생하는지 확인할 수 있는 테스트 코드를 작성했습니다.
@Test
@Transactional
@DisplayName("Lazy일 경우 N + 1 테스트")
public void selectArticleByLazy() {
List<User> userList = userRepository.findAll();
for(User user : userList) {
user.getArticleList().size();
}
}
프록시 객체를 초기화주어야 하는데, Hibernate 구현체에는 프록시 객체를 초기화 할 수 있는 기능이 제공되지만 JPA 표준 명세에는 초기화 기능이 없습니다.
따라서 프록시 객체가 초기화 될 수 있도록 조회해온 User에 접근해서 articleList의 size( ) 메서드를 호출하면서 강제 초기화를 해야 합니다.
쿼리가 어떻게 나가는지 다시한번 테스트 코드를 실행해 보겠습니다.
N + 1 문제가 또 발생하게 된다는 걸 확인할 수 있습니다.
N + 1 문제를 포스팅하고, 또 관련 레퍼런스가 많은 이유는 이 문제를 충분히 해결할 수 있다는 것입니다.
만약 해결할 수 없는 문제라면 제가 포스팅도 하지 않았겠죠?
N + 1 문제를 해결할 수 있는 방법으로 3가지 정도의 방법을 사용해보았습니다.
지금부터 이 방법들을 사용해보고 어떤 것이 좀 더 좋은 해결책일지 찾아 보겠습니다.
Fetch Join은 연관된 엔티티를 전부 조회하겠다는 의미로, 한방 쿼리를 이용해 조회를 하는 방법입니다.
Fetch Join을 하게 되면, inner join을 이용해 데이터를 가져오게 되고, 데이터가 예상보다 커질 수 있습니다. 그 이유는 카티시안 곱이 발생하기 때문인데요. 한번 살펴 보겠습니다.
작성한 테스트 코드는 아래와 같습니다.
@Test
@Transactional
@DisplayName("Fetch Join 사용시 N + 1 테스트")
public void selectArticleByFetchJoin() {
List<User> userList = userRepository.joinFetch();
System.out.println("userList size() = " + userList.size());
}
쿼리는 한방 쿼리로 깔끔하게 조회가 된 것을 볼 수 있는데, 조회해온 User의 데이터가 8개로 나옵니다. 카티시안 곱이 발생한 것인데요. 카티시안 곱이란 현재 User는 4명이 있고, 게시글이 2개씩 있는데,
즉 N(유저) * M(게시글) = 8 User의 데이터가 게시글 개수 만큼 곱해져 커진 것입니다.
이 문제를 해결하기 위해서는 키워드를 하나만 붙여주면 해결할 수 있습니다.
JPQL을 작성할때 distinct를 붙여주면 됩니다. 이렇게 작성하면 어떻게 조회되는지 다시한번 보겠습니다.
원하던대로 중복이 제거되어서 총 4개의 User 데이터가 List에 담겨서 온것을 볼 수 있습니다.
아쉽지만 이것으로 모든 문제를 해결할 수 없습니다.
Fetch Join은 페이징을 하기 어렵다는 문제가 있습니다. 물론 안되는 것은 아닙니다.
Fetch Join을 사용해서 페이징을 해서 데이터를 조회하는 메서드를 작성했습니다.
@Query(value = "select distinct u from User u join fetch u.articleList",
countQuery = "select count(u) from User u")
Page<User> selectUserByPaging(Pageable pageable);
Fetch Join을 사용하게되면, count 쿼리가 자동으로 생성되지 않습니다. 따라서 직접 count 쿼리를 작성해줘야 하는데, 이 부분을 누락시키게 되면 예외가 발생합니다.
Fetch Join + Paging API를 사용한 테스트 코드는 아래와 같습니다.
@Test
@Transactional
@DisplayName("Fetch Join 사용시 N + 1 테스트 및 페이징 테스트")
public void selectArticleByFetchJoinPaging() {
Pageable paging = PageRequest.of(0, 2);
Page<User> pagingUsers = userRepository.selectUserByPaging(paging);
List<User> userList = pagingUsers.getContent();
System.out.println("userList size() = " + userList.size());
}
이 테스트 코드를 실행하면 어떻게 쿼리가 나가는 지 확인해보겠습니다.
조회 쿼리가 나가고, 데이터의 개수를 카운팅하는 카운팅 쿼리 두개가 나가는 것을 볼 수 있습니다.
총 2개의 데이터를 가져오도록 작성하였는데, 데이터도 2개가 나온것을 보면 성공적인 것 같아 보입니다.
하지만 이 결과속에 감춰진 치명적인 단점이 존재합니다.
페이징을 데이터베이스 조회시 했다면 limit 과 offset 처럼 데이베이스 내부에서 이미 처리하였을 것입니다.
하지만 콘솔창 어디에도 그런 구문을 보이지 않습니다.
비밀은 이곳에 있습니다. 저 메시지는 메모리에서 페이징을 시도했다는 뜻인데요, 메모리로 데이터를 끌고와서 페이징 처리 했다는 메시지입니다.
만약 회원이 수십만명, 게시글이 몇 천만개가 된다면 OOM(Out Of Memory)가 발생하게 될 위험이 있습니다.
EntityGraph를 사용하면 지정한 엔티티들은 Eager로 조회하여, N + 1 문제를 해결할 수 있습니다.
이 코드는 User와 연관된 articleList를 즉시로딩(Eager)으로 조회하도록 작성한 메서드 입니다.
이 코드를 테스트할 테스트 코드는 아래와 같습니다.
@Test
@Transactional
@DisplayName("Entity Graph 사용시 N + 1 테스트")
public void selectArticleByEntityGraph() {
List<User> userList = userRepository.entityGraphFetch();
}
이 코드를 실행하면 쿼리가 어떻게 실행되는지 확인해보겠습니다.
한번의 쿼리로 데이터를 한방에 조회하는 것을 확인할 수 있습니다.
EntityGraph는 left outer join을 사용하기 때문에 데이터가 예상한 것보다 많이 조회될 수 있고 최악의 경우 카티시안 곱이 발생할 수 있습니다.
따라서 distinct 키워드를 붙여 꼭 중복을 제거해줘야 합니다.
BatchSize란 여러개의 프록시 객체를 조회할 경우, Where 절이 같은 여러개의 SELECT 쿼리를 IN 쿼리로 만들어줍니다. 성능 개선을 위해 사용하게 됩니다.
BatchSize는 yml 파일에 batch_fetch_size를 설정하는 방법과, @BatchSize 애너테이션을 사용하는 방법이 있는데 이 포스팅에서는 @BatchSize 애너테이션을 사용하겠습니다.
@BatchSize 애너테이션은 size를 지정해줘야 하는데, 여기서 말하는 size는 IN 쿼리에 들어갈 수 있는 최대 갯수를 의미합니다.
BatchSize를 벗어나게 되면 여러개의 IN 쿼리로 분리되어 쿼리가 나가게 됩니다.
@Test
@Transactional
@DisplayName("Batch Size 적용시 N + 1 테스트")
public void selectArticleByBatchSize() {
List<User> userList = userRepository.findAll();
for(User user : userList) {
user.getArticleList().size();
}
}
쿼리가 어떻게 발생하는지 테스트 코드를 실행해보겠습니다.
@BatchSize를 사용하면 쿼리 한번에 연관된 엔티티를 전부 끌어오는 Fetch Join이나 EntityGraph와는 다르게 동작합니다.
User 엔티티를 조회하는 쿼리 1개와 BatchSize를 벗어나지 않는다면 IN 쿼리 1번, 즉 쿼리가
N + 1 에서 1 + 1로 어느정도 최적화를 할 수 있습니다.
만약 BatchSize를 벗어나는 양의 데이터를 조회한다면, 나가는 쿼리의 총 개수는 1 + (N / BatchSize)가 됩니다.
N + 1 문제를 발생시키고 그것들을 케이스를 나눠서 테스트 코드를 작성하는 것은 쉬운일이 아니였습니다.
그래도 이렇게 N + 1 개념을 잡고자 포스팅을 작성하면서 JPA의 매핑과 연관관계의 개념에 대해서도 어느정도 복습하고, 성능 개선을 위한 방법도 다시 보면서 많은 도움이 되었던 것 같습니다.
JPA 마스터가 되는 날까지 아자! 화이팅입니다!
와우..잘 보고 갑니다...