ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • N + 1 정복하기 <2. FetchType.EAGER>
    프레임워크/Spring Boot 2024. 2. 20. 18:09

     

     

    (소스코드: https://github.com/blog-example/-JPA-N_Plus_1)

    ORM을 사용하면 만나는 흔한 이슈 중 하나인 N + 1 !

    명확하게 설명할 수 있을 정도로 머리속에 집어넣어보겠습니다.

     

    지난글에서 N + 1이 무엇이고 왜 발생하고 어떤 코드에서 발생하는지 살펴보았습니다.

    이번 글에서는 FetchType.EAGER와 N + 1의 관계에 대해서 살펴보겠습니다.

     

    [글의 진행]

    1. FetchType.EAGER 적용하기

    2. FetchType.EAGER 공식문서 확인하기

    3. FetchType.EAGER 실행확인하기

    4. FetchType.EAGER의 또 다른 동작

     


     

     

    지난 글에서 연관관계가 있는 엔티티 로딩시 성능 최적화를 위해서 FetchType.LAZY를 사용하면서 N + 1 이슈가 발생하는 것을 확인하였습니다. 그렇다면 FetchType을 EAGER로 사용하면 N + 1 문제를 해결할 수 있을까요?

     

    1. FetchType.EAGER 적용하기

    지난 글에서 사용하던 코드를 이어서 사용해보겠습니다.

    우선 게시글에서 댓글과 연관관계를 맺을 때 사용했던 FetchType을 EAGER로 변경해보겠습니다.

    (다른 코드는 변경하지 않았습니다)

    @Getter
    @NoArgsConstructor
    @Entity
    @Table(name = "posts")
    public class Post {
    
      ...생략
    
      @OneToMany(mappedBy = "post", fetch = FetchType.EAGER) // FetchType.EAGER 추가
      private List<Comment> comments = new ArrayList<>();
    
    
    }

     

     

    EAGER를 사용했으니 최초에 Post 엔티티를 생성하면서 모든 Comment를 로드할 것이고, 이미 모든 Comment가 메모리에 올라가있는 상태이기 때문에 이후에 Comment에 접근하더라도 별도의 쿼리가 발생하지 않을 것이라는 가정입니다.

      public List<PostDto> getPosts() {
        List<Post> posts = postRepository.findAll(); // 모든 데이터를 반영한 엔티티를 만들 것으로 예상
    
        List<PostDto> postDtos = new ArrayList<>();
        for (int i = 0; i < posts.size(); i++) {
          Post post = posts.get(i);
          List<Comment> comments = post.getComments(); // 접근하여도 데이터베이스에 갈 이유가 없음
    
          List<CommentDto> commentDtos = new ArrayList<>();
          for (Comment comment : comments) {
            commentDtos.add(CommentDto.of(comment, post.getPostId()));
          }
    
          postDtos.add(PostDto.of(post, commentDtos));
        }
    
        return postDtos;
      }

     

    하지만 예상은 여지없이 빗나가버립니다~~

    N + 1이 그대로 발생하는 것을 확인할 수 있습니다. EAGER는 왜 예상과 다르게 동작할까요?

     

     

     

    2. FetchType.EAGER 공식문서 확인하기

     

    hibernate 공식문서를 찾아가서 EAGER가 어떻게 동작하길래 예상과 다르게 작동하는지 확인해보겠습니다.

    Fetching 섹션에 가면 힌트를 얻을 수 있습니다.

     

    애플리케이션에서 데이터를 사용할 수 있게 데이터베이스에서 데이터를 가져오는 것을 Fetching이라 하는데, 어떻게 Fetching 할 것이냐는 하나의 큰 요소라고 이야기합니다. 너무 많은 데이터를 가져오는 것은 overhead를 가져오고, 너무 적은 데이터를 가져오는 것은 추가 fetching을 발생시키기 때문에 적절하게 해야한다 라는 이야기 같습니다.

     

    문서의 조금 아래에 보면 SELECT 섹션이 있습니다.

    문서를 읽어보면 EAGER나 LAZY 둘 중 어느 것을 선택해도 the second select가 issue 된다는 것을 확인할 수 있습니다.

    단지 immediately 발생할 것이냐, when it neended 시점에 발생할 것이냐의 차이가 있는 것 같습니다.

    https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#fetching

     

    3. FetchType.EAGER 실행 확인하기

    문서에서 언급한 내용대로 작동하는지 확인을 해보겠습니다. 일단 둘다 두 번의 Select 쿼리가 나가지만 시점이 다르다고 하였습니다.

    그렇다면 EAGER 환경에서는 post가 최초에 로딩되는 순간에 comment를 가져오는 추가쿼리도 같이 발생할 것이라고 예상됩니다.

     

     

    간단하게 코드를 수정해보겠습니다.

    comment에 접근하는 부분을 모두 주석처리한 뒤, 두 번째 select가 발생하는지를 확인해보겠습니다.

      public List<PostDto> getPosts() {
        List<Post> posts = postRepository.findAll();
    
        List<PostDto> postDtos = new ArrayList<>();
    //    for (int i = 0; i < posts.size(); i++) {
    //      Post post = posts.get(i);
    //      List<Comment> comments = post.getComments();
    //
    //      List<CommentDto> commentDtos = new ArrayList<>();
    //      for (Comment comment : comments) {
    //        commentDtos.add(CommentDto.of(comment, post.getPostId()));
    //      }
    //
    //      postDtos.add(PostDto.of(post, commentDtos));
    //    }
    
        return postDtos;
      }

     

    EAGER 옵션으로 실행하니 comment에 대한 접근이 없어도 select 쿼리가 나가는 것을 확인할 수 있습니다.

     

    반대로 LAZY로 옵션을 되돌려보니 첫 번째 쿼리만 발생하는 것을 확인할 수 있습니다.

     

     

     

    4. FetchType.EAGER 의 또 다른 동작

    이렇게만 바라보면 EAGER를 쓰는게 그렇게 큰 메리트가 없어보입니다.

    하지만 EAGER는 또 다른 동작이 있습니다.

     

    공식문서의 SELECT 아래에 있는 JOIN 부분을 확인해보면 EAGER는 outer join으로 데이터를 가져오기도 한다고 적혀있습니다.

    https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#fetching

     

     

    이를 확인하기 위해서 게시글 상세를 조회하는 서비스 로직을 추가해보겠습니다.

      public PostDto getPost(long id) {
        Post post = postRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("no matched post with pid: " + id));
    
        List<CommentDto> commentDtos = new ArrayList<>();
        for (Comment comment : post.getComments()) {
          commentDtos.add(CommentDto.of(comment, post.getPostId()));
        }
    
        return PostDto.of(post, commentDtos);
      }

     

     

    그리고 FetchType을 LAZY로 설정하고 먼저 실행시켜보도록 하겠습니다.

    위에서 게시글 목록을 가져올 때와 마찬가지로 Comment에 접근하는 시점에 두 번째 select 쿼리가 발생합니다.

     

     

    EAGER 타입은 어떨까요?

    Left Join을 사용해서 두 번째 쿼리 없이 한번에 불러오는 것을 확인할 수 있습니다.

     

     

     


     

     

    N + 1을 FetchType.EAGER로 해결할 수 있는지 여부를 확인해보았습니다.

    게시글 단건을 로딩할 때는 Join을 사용하여 1번의 쿼리로 로드를 하는 것을 확인했으나, 게시글 목록을 로딩할 때는 두 번째 쿼리를 사용해서 엔티티를 로딩하는 것을 확인했습니다.

     

    다음 글에서는 다른 접근 방법으로 N + 1을 다루어보겠습니다.

    감사합니다.

     


    참고

    https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#fetching

     

     

     

     

Designed by Tistory.