ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA N+1 문제를 해결해보자
    Spring Framework 2021. 8. 20. 17:41
    반응형

    1) N+1 문제란?

    ORM은 객체의 관계를 연결하는데요. 이를 활용하는 JPA는 엔티티 기준으로 쿼리를 사용하고, 엔티티에 연관된 모든 객체를 조회합니다.

     

    모든 연관된 객체를 조회 하기에 문제가 발생합니다.

     

    N개의 결과를 얻기 위해 DB를 조회했는데, 추가적으로 쿼리가 발생하는 것입니다. 즉 얻고자 하는 결과에서 1개가 더 붙어서 조회가 되는 현상입니다.

     

    마트에서 하는 1+1 행사라고 생각하시면 됩니다. "파를 샀더니, 마늘을 주네? 근데 난 마늘 안 먹는데... 이걸 굳이 왜..?"

     

    얻고자 하는 결과 외에 추가로 결과를 얻는다는 것은, 추가 쿼리가 발생합니다. 그만큼 성능적으로 손해 볼 수밖에 없습니다.

     

    2) 전체 구조

    // 애노테이션들 생략 했습니다.
    public class Post {
        private long postNo;
        private String title;
        private String author;
        private String content;
        private long viewCount;
    
        @ManyToOne(fetch = FetchType.EAGER)
        @JoinColumn(name = "boardNo")
        private Board board; // 게시물 작성된 게시판
    }
    • 게시물(post)게시판(board) 엔티티가 참조되고있습니다
    public interface PostRepository extends JpaRepository<Post, Long> {
        @Query("from Post p")
        List<Post> findAllPosts();
    }
    • 모든 게시물(post) 조회하는 repository입니다
        @Test
        public void findAllUsers() {
            // 모든 게시물 조회합니다.
            List<Post> posts = postRepository.findAllPosts();
            assertNotNull(posts);
            
            System.out.println("======================================================");
            posts.forEach(p -> System.out.println(p.getTitle()));
            System.out.println("======================================================");
        }
    • 모든 게시물(post) 조회 후 제목 출력합니다

     

    Hibernate: select post0_.postNo as postno1_1_, post0_.createdAt as createda2_1_, post0_.updatedAt as updateda3_1_, post0_.author as author4_1_, post0_.boardNo as boardno8_1_, post0_.content as content5_1_, post0_.title as title6_1_, post0_.userNo as userno9_1_, post0_.viewCount as viewcoun7_1_ from post post0_
    
    // 5개의 게시판에 대한 조회 발생
    Hibernate: select board0_.boardNo as boardno1_0_0_, board0_.name as name2_0_0_ from board board0_ where board0_.boardNo=?
    Hibernate: select board0_.boardNo as boardno1_0_0_, board0_.name as name2_0_0_ from board board0_ where board0_.boardNo=?
    Hibernate: select board0_.boardNo as boardno1_0_0_, board0_.name as name2_0_0_ from board board0_ where board0_.boardNo=?
    Hibernate: select board0_.boardNo as boardno1_0_0_, board0_.name as name2_0_0_ from board board0_ where board0_.boardNo=?
    Hibernate: select board0_.boardNo as boardno1_0_0_, board0_.name as name2_0_0_ from board board0_ where board0_.boardNo=?
    • 게시물(post)만 조회했지만, 게시판(board)도 같이 조회됩니다
    • 게시판이라는 +1이 발생했습니다
    • 불필요한 5개의 쿼리가 발생합니다

     

    2) 해결 방법

    2.1) LAZY

    가장 쉬운 방법은 FetchType으로 시점을 Eager --> Lazy 변경하면 됩니다.

     

    LazyEager와 다르게 한 번에 모든 쿼리를 발생시키지 않습니다. 실제 사용되는 시점까지 미뤄뒀다가, 추가로 쿼리를 발생시킵니다. 즉 A  엔티티에 B 엔티티가 참조되고 있더라도, B.get()을 할 때까지 해당 엔티티를 조회하지 않습니다. A 엔티티만 조회하게 됩니다. 


    2.1.1) Lazy 로딩 동작 

    Lazy 로딩은 런타임 시의 프록시 객체 기반으로 동작하는데요. 여기서 프록시 객체는 실제 객체를 참조를 보관하는 객체입니다. 

     

    A 엔티티에 B 엔티티도 포함되어 참조되고 있다고 가정하겠습니다. 그리고 B 엔티티는 Lazy 로딩으로 가지고 옵니다.

     

    A 엔티티를 조회하면 entitymanager.getreference()cglib 라이브러리를 활용하여, 프록시처럼 동작하게 되는 서브클래스를 만듭니다. 그리고 B 엔티티는 이 프록시 객체에 담기게 되는데요. 

     

    중요한 것은 프록시 객체를 만들 때는 참조만 하고 있을 뿐이지, 실제 데이터가 DB로부터 담기지 않았습니다. 즉 프록시 객체에 담긴, B 엔티티는 Null 상태입니다. 왜냐하면 아직 B 엔티티에 대해서 DB에 아무런 조회를 하지 않았습니다. LAZY 로딩이라는 것은 실제로 사용할 때까지 일을 미루기 때문입니다. 게으릅니다.

     

    추후에 B.get() 혹은 해당 메서드를 사용하게 된다면, 프록시 객체를 활용해서 실제 DB를 조회합니다.


    2.2) LAZY Test

    // 애노테이션들 생략 했습니다.
    public class Post {
        private long postNo;
        private String title;
        private String author;
        private String content;
        private long viewCount;
    
        // LAZY로 변경
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "boardNo")
        private Board board; // 게시물 작성된 게시판
    }
    • Eager --> LAZY 변경
    Hibernate: select post0_.postNo "생략" from post post0_
    • LAZY fetch 변경 시 게시판(board)에 대한 조회가 발생하지 않습니다

     

    2.3) 하지만?

        @Test
        public void findAllUsers() {
            List<Post> posts = postRepository.findAllPosts();
            assertNotNull(posts);
    
            System.out.println("======================================================");
            posts.forEach(p -> System.out.println(p.getTitle() + " / " +p.getBoard().getName()));
            System.out.println("======================================================");
        }
    • getBoard().getName() - 게시물(post)이 속한 게시판(board) 이름을 출력합니다
    // 게시판: free
    Hibernate: select board0_.boardNo as "생략" where board0_.boardNo=?
    마지막 테스트3333 / free
    포스트 수정쓰~ / free
    
    // 게시판: free1
    Hibernate: select board0_.boardNo as "생략" from board board0_ where board0_.boardNo=?
    n+2 / free1
    n+2 / free1
    
    // 게시판: free2
    Hibernate: select board0_.boardNo as "생략" from board board0_ where board0_.boardNo=?
    n+2 / free2
    n+2 / free2
    
    ... 생략
    • 해당 게시판(board)에 해당하는 게시물(post)을 조회할 때마다 게시판을 조회합니다

     

    LAZY fetch를 선택하더라도, 결국에는 추가로 쿼리를 날려야 합니다. 

     

    만약 게시물, 게시판 조회를 항상 나눠서 조회한다면 LAZY도 좋은 방법일 수 있다고 생각합니다.

     

    하지만 나눠서 조회, 한 번에 조회를 모두 사용해야 한다면 어떻게 할까요? LAZY, EAGER가 모두 필요합니다. 그렇다고 엔티티를 추가로 만드는 것은 좋은 방법이 아닌 것 같습니다.

     

    3) @EntityGraph

    조회하는 시점에 연관된 엔티티를 모두 동적으로 가지고 옵니다. 즉 LAZY fetch 설정을 했더라도, EAGER fetch 합니다. 덕분에 런타임 시의 성능 최적화를 할 수 있습니다. 

     

    스프링 API 문서에서는 해당 애노테이션을 repository의 메서드에 사용하라고 되어있는데요. 즉 하나의 엔티티를 사용하지만, 여러 개의 목적을 가진 메서드를 정의 후 LAZY, EAGER 활용하면 됩니다. 

    3.1) @entityGrapth test

    @EntityGraph(attributePaths = "board")
    @Query("from Post p")
    List<Post> findAllPosts();
    • @EntityGraph - 수행시 바로 가져올 쿼리 지정합니다
    @Test
    public void findAllUsers() {
    	List<Post> posts = postRepository.findAllPosts();
    	assertNotNull(posts);
    
    	System.out.println("======================================================");
    	posts.forEach(p -> System.out.println(p.getTitle() + " / " +p.getBoard().getName()));
    	System.out.println("======================================================");
    }
    • 기존의 테스트와 동일합니다
    Hibernate: select post0_.postNo as postno1_1_0_, board1_.boardNo as "생략" 
    from post post0_ left outer join board board1_ on post0_.boardNo=board1_.boardNo
    • 결과가 길어, 임의로 두줄로 표시했습니다
    • 게시판(board)에 대한 추가 쿼리가 발생하지 않습니다
    • left outer join 연산으로 한 번에 조회합니다

     

    기존 LAZY fetch의 게시판의 이름들을 모두 조회하는 select 쿼리를 발생하는 것과 다르게, join 연산으로 한 번에 가지고 옵니다. 

     

    4) 결론

    기본적으로 모든 Fetch는 LAZY로 설정하는 것이 좋다고 생각합니다. 왜냐하면 모든 경우에 한 번에 연관된 엔티티를 조회하지 않기 때문입니다. 

     

    하지만 EAGER가 필요한 경우가 있습니다. 이때는 @EntityGraph를 활용하여 필요한 메서드에만 EAGER fetch를 적용한다면, 성능적으로 이점이 있을 것 같다고 생각합니다. 

     

    5) 참고 문헌

    https://xebia.com/blog/advanced-hibernate-proxy-pitfalls/

    https://dzone.com/articles/jpa-lazy-loading

    반응형

    댓글

Designed by Tistory.