[JPA] N + 1 이슈

ryudjae
|2021. 12. 17. 17:04
728x90

JPA를 사용하다 보면 가장 자주 마주하게 되는 이슈가 N+1이다. N+1이 발생할 때 데이터의 양이 아주 적으면 그냥 무심코 넘길 수 있지만 데이터가 많아질수록 성능에서 엄청난 차이를 가져온다.

이번 포스팅에서는 문제가 발생하는 예시와 해결 방안에 대해서 정리하려 한다.

Post를 호출하여 Comment를 사용할 것이다라고 가정하자.


➡️엔티티 클래스

post

@Entity
@Getter
@NoArgsConstructor
public class Post {

    @Id@GeneratedValue
    @Column(name = "post_id")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "post",cascade = CascadeType.ALL,fetch = FetchType.LAZY)
    private List<Comment> comments = new ArrayList<>();

    @Builder
    public Post(String name, List<Comment> comments) {
        this.name = name;
        if(comments != null){
            this.comments = comments;
        }
    }

    public void addComment(Comment comment){
        this.comments.add(comment);
        comment.updatePost(this);
    }
}

comment

@Entity
@Getter
@NoArgsConstructor
public class Comment {

    @Id@GeneratedValue
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @Builder
    public Comment(String content, Post post) {
        this.content = content;
        this.post = post;
    }

    public void updatePost(Post post){
        this.post = post;
    }
}

Repository

@Repository
public interface PostRepository extends JpaRepository<Post,Long> {
}

Service

@RequiredArgsConstructor
@Slf4j
@Service
public class PostService {

    private final PostRepository postRepository;

    @Transactional(readOnly = true)
    public List<String> findAllComments() {
        return extractSubjectNames(postRepository.findAll());
    }

    private List<String> extractSubjectNames(List<Post> posts) {
        log.info("Comments Size : {}", posts.size());

        return posts.stream()
                .map(a -> a.getComments().get(0).getContent())
                .collect(Collectors.toList());
    }
}

ServiceTest

 

@SpringBootTest
class PostServiceTest {

    @Autowired
    private PostRepository postRepository;

    @Autowired
    private PostService postService;


    @BeforeEach
    public void setUp(){
        List<Post>posts = new ArrayList<>();

        for(int i = 0;  i< 10; i++){
            Post post = Post.builder()
                    .name("게시글"+i)
                    .build();
            post.addComment(Comment.builder()
                            .content("댓글"+i)
                            .build());
            posts.add(post);
        }
        postRepository.saveAll(posts);
    }


    @Test
    @DisplayName("게시글 조회시 댓글 조회 N+1 발생")
    void N_plus_1_O()throws Exception{
        List<String> comments = postService.findAllComments();
        assertThat(comments.size()).isEqualTo(10);

    }
}

 

위 테스트 코드를 실행하면 아래처럼 Post를 조회하는 쿼리와 Post의 자신의 Comment를 조회하는 쿼리가 10번이 나간다.

 

이게 오늘 정리하는 N+1 쿼리이다.

위처럼 하위 엔티티를 한 번에 가져오지 않고 지연 로딩으로 필요한 곳에서 사용되어 쿼리가 실행될 때 발생하는 문제이다.

지금은 Post가 10개이니 처음 조회되는 쿼리 한 번과 10번의 Comment조회가 발생하여 총 11번의 쿼리가 발생한다.

지금은 11번만 나가지만 조회 결과가 1만 개면 DB 조회가 최소 1만 번은 일어나서 성능이 급격하게 저하된다.

이제 이러한 문제에 대한 해결책을 보자.

 

1.fetch join

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

fetch join은 바로 가져오고 싶은 Entity 필드를 지정하는 것이다.

@Test
@DisplayName("fetchjoin으로 게시글 가져옴")
void fetchJoin_getPost()throws Exception{
    List<Post> post = postRepository.findAllFetchJoin();
    assertThat(post.size()).isEqualTo(10);
      
}

테스트 코드를 실행하면 쿼리가 한 줄만 실행된다.

이로써 문제는 해결되지만 불필요한 쿼리문이 추가된다는 단점이 존재한다.

필드마다 Eager.Lazy 방식을 다르게 해서 쿼리가 불필요하면 @EntityGraph를 사용하면 된다.

 

2.EntityGraph

@EntityGraph의 attributePaths에 쿼리 수행 시 바로 가져올 필드명을 지정하면 Lazy가 아닌 Eager로 조회가 된다.

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

위처럼 원본 쿼리의 손상 없이 (Eager/Lazy) 필드를 정의하고 사용할 수 있게 된다.

테스트 코드를 작성해보자 

@Test
@DisplayName("게시글 여러개를 EntityGraph 로 가져온다.")
void entityGraph()throws Exception{

    List<Post> post = postRepository.findAllEntityGraph();
    assertThat(post.size()).isEqualTo(10);

}

 

테스트 코드 결과를 보면 정상적으로 조회 쿼리가 작동한다.

 

 

두 방식 모두 카테시안 곱이 발생하여 comment의 수만큼 Post가 중복 발생하게 된다.

FetchJoin은 InnerJoin이고, @EntityGraph는 OuterJoin이다. 

➡️fetchJoin

select 
      post0_.post_id as post_id1_3_0_, 
      comments1_.id as id1_1_1_, 
      post0_.name as name2_3_0_, 
      comments1_.content as content2_1_1_, 
      comments1_.post_id as post_id3_1_1_, 
      comments1_.post_id as post_id3_1_0__, 
      comments1_.id as id1_1_0__ 
from post post0_ 
     inner join comment comments1_ 
             on post0_.post_id=comments1_.post_id

➡️@EntityGraph

select post0_.post_id as post_id1_3_0_, 
       comments1_.id as id1_1_1_, 
       post0_.name as name2_3_0_, 
       comments1_.content as content2_1_1_, 
       comments1_.post_id as post_id3_1_1_, 
       comments1_.post_id as post_id3_1_0__, 
       comments1_.id as id1_1_0__ 
from post post0_ left 
     outer join comment comments1_ 
             on post0_.post_id=comments1_.post_id

위 쿼리문은  위 두 방식의 조회 쿼리이다. 이제 중복을 확인해보자

@BeforeEach
public void setUp(){
   List<Post>posts = new ArrayList<>();

   for(int i = 0;  i< 10; i++){
         Post post = Post.builder()
               .name("게시글"+i)
                .build();
          post.addComment(Comment.builder(
                 .content("댓글"+i)
                 .build());
          post.addComment(Comment.builder()
                .content("댓글1"+i)
                .build());
            posts.add(post);
    }
    postRepository.saveAll(posts);
}

@Test
@DisplayName("Posts여러개를_joinFetch로_가져온다")
void postFetchJoin() throws Exception {
    //given
    List<Post> posts = postRepository.findAllFetchJoin();
    List<String> comments = postService.findAllSubjectNamesByJoinFetch();
    
    //then
    assertThat(posts.size()).isEqualTo(20);
    assertThat(comments.size()).isEqualTo(20);
    System.out.println("post size : " + posts.size());
    System.out.println("comments size : " + comments.size());
    }

테스트 코드를 실행해보면 Post의 개수가 10개가 아닌 20개로 생성된다.

 

이 문제의 해결법을 해결하는 데는 2가지의 방법이 있다.

첫 번째는 @OneToMany 필드를 Set 선언해주면 된다. Set은 중복을 허용하지 않아서 하나의 해결 방법이 된다.

@OneToMany(mappedBy = "post",cascade = CascadeType.ALL,fetch = FetchType.LAZY)
private Set<Comment> comments = new LinkedHashSet<>();

Set은 순서도 보장하지 않기 때문에 LinkedHashSet을 사용하면 순서도 보장이 된다.

 

그러나 List를 써야 하는 상황이라면 distinct을 사용하여 중복을 제거해주는 방식이다.

@Query("select DISTINCT a from Post a join fetch a.comments")
List<Post> findAllJoinFetchDistinct();
@Test
@DisplayName("distinct_FetchJoin")
void distinct()throws Exception{
    //given
    List<Post> posts = postRepository.findAllJoinFetchDistinct();
    //then
    assertThat(posts.size()).isEqualTo(10);
    System.out.println("post size : " + posts.size());
  }

중복되지 않고 정상적으로 조회가 된다.

@EntityGraph(attributePaths = "comments")
@Query("select DISTINCT a from Post a")
List<Post> findAllEntityGraphDistinct();
@Test
@DisplayName("distinct_EntityGraph")
void distinct_EntityGraph()throws Exception{
    //given
    List<Post> posts = postRepository.findAllEntityGraphDistinct();
    //then
    assertThat(posts.size()).isEqualTo(10);
    System.out.println("post size : " + posts.size());
}

또한 정상적으로 작동한다.

 

두 방식 중 자신의 프로젝트 상황에 맞게 사용하면 된다.

 

 

🧑🏻‍💻예제 코드

https://github.com/ryudongjae/blog-ex

 

GitHub - ryudongjae/blog-ex: 📁블로그 예제 코드

📁블로그 예제 코드 . Contribute to ryudongjae/blog-ex development by creating an account on GitHub.

github.com


Reference

https://jojoldu.tistory.com/165?category=637935

728x90