TIL - #24 N+1 이슈

Quann·2023년 1월 19일
0

01. 개요

프로젝트를 작성하면서, JPA를 사용하면서 빠질 수 없는 이슈가 성능 관련 이슈이고
그에 대표되는 이슈는 N+1 이슈이다.
이에 대한 학습을 서술하고자한다.


02. N+1 ?

N+1 이슈란, 쉽게 말해서 연관관계가 맺어진 하나의 엔티티를 조회할 경우, 조회된 데이터 만큼 연관관계가 맺어진 엔티티에 대한 조회 쿼리가 추가로 발생하여 발생하는 문제이다.

즉, 내가 조회를 위해 던진 하나의 쿼리 1 개가 가져오는 그에 연관된 엔티티에 대한 조회 N 개의 쿼리가 날라가는 문제이다.

가끔 아무 생각 없이 코딩하다가 프로젝트를 테스트하며 쿼리문을 체크했을 때, 내가 의도하지 않은 쿼리문이 여러개 날라갈 때가 있는데, 해당 상황이 N+1 이슈이다 !

02.01. 상황

@Entity
public class Post {
	@Id
    @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "post", fetch = FetchType.EAGER)
    private List<Comment> comments;
}
@Entity
public class Comment {
	@Id
    @GeneratedValue
    private Long id;
    
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "post_id")
    private Post post;
}

다음과 같이 평범한 Post, Comment 객체가 있을 때, 서로를 참조하고 있다.
이 때, 게시글은 댓글을 바라보고 있고, 댓글은 자신이 쓰여진 게시글 객체를 바라보고 있다.

그러면, 해당 객체를 DB에 저장하고 조회하기 위해 Repository를 만들었다고 가정했을때,

postRepository.findById(1L).orElseThrow ... 

를 가정해 5개의 댓글이 달린 1번 게시물을 조회 했다고 가정해보면,

내가 Post를 조회하기 위해 작성한 쿼리 1개와
해당 게시물에 달린 댓글을 조회하기 위한 5개(N개) 의 쿼리가 발생해
N + 1 이슈가 발생한다!

FetchType을 Lazy로 설정하면 Post 객체를 불렀을 때, 혹은 Comment 객체를 불렀을 때에는 N+1 이슈가 발생하지 않는 것 처럼 보일 수 있지만, Post 객체에서 comments를 사용하거나, Comment 객체에서 post 를 사용할 경우 그 순간 또 다시 N+1 이슈가 발생한다.(Lazy하게 Fetch를 설정한 경우, 조회 시점엔 불리지 않고 사용 시점에 쿼리가 발생해 조회가 가능하다.)

02.02. 예방

Fetch Join

가장 쉬운 방법으로, Fetch Join 을 사용하는 것이다.
JPQL에서 제공하는 Fetch Join 을 사용하여, 처음 조회시 부터 두 객체가 서로 조인된 상태로 가져오는 것이다.
이렇게 되면, 첫 조회에 원하는 딱 하나의 쿼리만 날라가게 되어 N+1 이슈를 방지할 수 있게 된다.
JPQL의 Fetch Join 의 경우, SQL에서 Inner Join으로 변경되어 쿼리가 날라단다.

@Query("select p from Post p join fetch p.comments")
List<Post> findAllFetchJoin();

다음과 같이 fetch join 쿼리문을 직접 작성하여 조회 시에 하나의 쿼리만 날라가게 하는 것이다.

하지만, Fetch Join은 데이터 호출 시점에 모든 연관관계의 데이터를 가져와서 결국엔 N만큼의 데이터는 긁어와지게 된다는 단점이 있다. Post의 Comments를 사용하지도 않는데, N+1 이슈를 예방하고자 fetch join을 사용하는 경우, 다시한번 생각해볼 필요가 있다.
또한, 페이징 쿼리의 사용이 불가하다. Pageable에 대한 응용이 어렵다.

이러한 단점이 있어 적절한 Trade-Off를 생각해 필요할 경우 사용해야겠다 !

@EntityGraph

@EntityGraph 애너테이션을 통해, 조회 쿼리 발생시 어떤 요소들을 미리 join 하여 가져올 것인지 설정이 가능하다.

@EntityGraph(attributePaths = "comments")
@Query("select p from Post p")
List<Post> findAllEntityGraph();

attributePaths 설정을 통해 어떤 요소들을 EAGER 하게 가져올 것인지 설정하는 것이다.
기본적으로 left join을 통해 조회가 되며, 관계가 복잡해질 시 사용이 불편해진다는 단점이 있다.

Batch Size

properties, yml을 통해 글로벌하게 Batch Size 조절을 하거나,

spring.jpa.properties.hibernate.default_batch_fetch_size=10

@BatchSize 애너테이션을 통해 특정 변수에 대해서만 사이즈 조절도 가능하다.

@BatchSize(size = 10)
@OneToMany(mappedBy = "post", fetch = FetchType.EAGER)
private List<Comment> comments;

SQL의 IN절을 통해 한번에 날릴 쿼리의 개수를 조절하는 것이다.
size는 IN 절에 들어올 최대 인자의 개수를 표시하면 되고, 지연 로딩의 경우 위의 예제에서는 쿼리 사용 시점에 10개를 미리 로딩해둔 후, 11번째 comment 에 대한 처리 발생시 11~20 번째 comment 조회를 위한 In절 쿼리가 발생하게 되는 것이다.

하지만, 결국 In 절 쿼리가 계속해서 날라가게 되고, N+1을 완전히 방지한다기 보다는 1번씩 더 조회하며 성능을 조금 더 최적화 하기 위함이 있다.


03. 결론

JPA에만 의존하기에는, 위에 서술했듯이, 각각의 트레이드오프가 존재하며 상황에 맞는 쿼리문의 작성이 필요하다.
이에 대해, Batch Size를 적절히 선정하거나, 지연 로딩을 기본으로 사용하고 성능 최적화가 필요한 부분에 대하여 Fetch Join 을 사용하거나, OneToMany 연관관계가 불필요할 경우 참조를 끊어버린다거나 다양한 방법이 존재한다.

하지만, 프로젝트마다 상황이 다를것이므로 적절한 트레이드오프를 생각해서 상황에 맞는 해결 방법의 선택이 가능하다.

또한, QueryDsl과 같은 동적 쿼리를 이용해 Fetch Join을 취하는 동시에 페이징까지 처리하는 방법 또한 적용이 가능하다.

다음에는 프로젝트에 QueryDsl을 적용해 페이징과 성능 최적화를 동시에 챙긴 메서드를 제작해보아야겠다고 생각했다.


04. 오늘의 한 문단

성능 최적화!

profile
코드 중심보다는 느낀점과 생각, 흐름, 가치관을 중심으로 업로드합니다!

0개의 댓글