ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA - N+1 문제 해결하기, 성능개선(페이징 처리)
    CS/Spring 2024. 7. 22. 06:08

    N+1 문제

    1:N 관계의 엔티티를 JPA코드로 표현하면 다음과 같다.

    ( Member 엔티티(1), Post 엔티티(N) 관계라고 생각하면 된다. )

    @Entity
    public class One {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long idx;
        private String str;
        @OneToMany(mappedBy = "one")
        private List<Many> manyList = new ArrayList<>();
    }
    @Entity
    public class Many {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long idx;
        private String str;
        @ManyToOne
        @JoinColumn(name = "one_idx")
        private One one;
    }

     

    이때 다음과 같이 One의 전체 목록을 조회할 때, 발생한 쿼리의 결과는 다음과 같다. 

     @RequestMapping(method = RequestMethod.GET, value = "/read")
        public void read() {
        
            List<One> result = oneService.findAll();
            
            for (One one : result) {
                for (Many many : one.getManyList()) {
                    System.out.println(many.getStr());
                }
            }
        }
    #1. 
        select
            o1_0.idx,
            o1_0.str 
        from
            one o1_0
    
    #2. 
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx=?
    
    #3.
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx=?
    
    #4.
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx=?
     
    #5.
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx=?
    
    #6.
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx=?
    
    #7. 
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx=?
    
    #8.
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx=?
    
    #9.
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx=?
    
    #10.
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx=?
    
    #11.
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx=?

     

    Jpa에서는 엔티티의 필드에 객체가 있을 경우, 객체에 대한 쿼리까지 추가로 실행한다. 

    One의 데이터가 10개라면 우선 One 테이블을 조회하는 쿼리 한번, 각각의 One에 대해 One이 가진 Many를 조회하는 튜플을 실행한다.

    따라서 One 테이블 조회(1번) + 각 One이 가진 Many 조회(N번) = 총 1+N번의 쿼리가 실행된다.

     

    일반 SQL문을 생각해본다면, 그냥 SELECT * FROM One 한번의 쿼리면 되는데, 불필요한 쿼리 발생이 일어나는 것이다. 

     

    N+1문제 해결 방안

    1. fetchType - LAZY 사용

    💡LAZY & EAGER

    LAZY (지연로딩)
    @OneToMany(mappedBy = "one", fetch = FetchType.LAZY)
    One 테이블을 조회해서 many에 대한 정보를 불러올 때, 원래는 One 테이블에 대해 select 쿼리 실행 후, 연관관계 객체인 Member에 대한 테이블에 대해 select 쿼리를 실행해야하지만, LAZY를 걸어두면 실제로 데이터가 필요할 때까지 연관된 엔티티를 로드하지 않는다. 즉, 실제 member에 대해 정보를 요구할 때 까지 데이터베이스에서 조회를 하지 않는다. 

    EAGER (즉시로딩)
    @OneToMany(mappedBy = "one", fetch = FetchType.EAGER)
    LAZY와 반대로 엔티티가 로드될 때 연관된 엔티티도 함께 즉시 로드하는 방식이다. many에 대한 정보를 조회하는지 안하는지와 무관하게 One테이블에 대해 select 쿼리를 실행시킨 후 Member에 대한 select 쿼리도 실행시킨다.
    아무것도 적지 않는다면 default값은 EAGER이다.

     

    One 테이블에 대한 정보만 필요할 경우, Lazy를 걸어두면 불필요하게 N번의 Many테이블에 대한 조회 쿼리문은 실행되지 않을 것이다.

     

    하지만 이 방식이 N+1을 근본적으로 해결하는 방법은 아니다.

    연관 관계가 있다는 것 자체가 정보를 공유하는 것이기 때문에 대부분의 경우에서는 연관된 엔티티까지 같이 조회할 것이다.

    이 경우에는 결국 연관된 엔티티의 정보를 요청시에 N번의 쿼리문이 실행될 것이다. 

     

    2. 배치 사이즈 사용

    @Entity
    public class One {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long idx;
        private String str;
        @BatchSize(size = 5)
        @OneToMany(mappedBy = "one", fetch = FetchType.LAZY)
        private List<Many> manyList = new ArrayList<>();
    }

     

    위 코드와 같이 @BatchSize 를 사용할 경우 실행되는 쿼리는 다음과 같다.

    똑같은 코드에서 @BatchSize(size = 5) 만 붙여도 11번 실행되던 쿼리가 3번으로 줄어든다.

    One 테이블 조회 1번 + Many 테이블 조회 2번 = 총 3번

    #1
        select
            o1_0.idx,
            o1_0.str 
        from
            one o1_0
    
    #2
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx in (1, 2, 3, 4, 5)
    
    #3
        select
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str 
        from
            many ml1_0 
        where
            ml1_0.one_idx in (6,7, 8, 9, 10)

     

    실행된 쿼리문을 보면 알 수 있듯이, @BatchSize(size = 5)는 in 메서드를 사용해 설정한 size만큼 모아서 데이터를 조회한다.

    만약 데이터 수가 10개인데 size=5 라면 쿼리가 2번, size=2라면 쿼리가 5번 실행될 것이다. 즉, 데이터수/size 만큼 쿼리가 실행된다. 

     

    3. JPQL - JOIN FETCH 사용

    batch 처리를 한다고 해도, 실제 sql에서는 join문 사용해 조회하면 한번의 쿼리로 데이터를 조회할 수 있는데 3번의 쿼리가 실행이 된다. 그럴 경우 JPQL을 사용해 Join문을 사용한 쿼리를 직접 작성해 사용하면 된다. 

    // controller
    @RequestMapping(method = RequestMethod.GET, value = "/jpql03")
    public void jpql03() {
    
            List<One> result = oneService.jpql03();
    
            for (One one : result) {
                for (Many many : one.getManyList()) {
                    System.out.println(many.getStr());
                }
            }
        }
        
        
    //service
    public List<One> jpql03() {
    	return oneRepository.findAllOneWithMany();
    }
    
    
    //repository
    @Query("SELECT o FROM One o JOIN FETCH o.manyList")
    List<One> findAllOneWithMany();

     

    쿼리는 다음과 같이 조인한 쿼리 한번만 발생한다.

        select
            o1_0.idx,
            ml1_0.one_idx,
            ml1_0.idx,
            ml1_0.str,
            o1_0.str 
        from
            one o1_0 
        join
            many ml1_0 
                on o1_0.idx=ml1_0.one_idx

     

    💡 JOIN FETCH
    sql 문에서 join을 실행할 때, ~ JOIN ON one.idx=many.idx 와 같은 조건을 작성한다. JOIN FETCH을 사용하면 일일히 해당 조건을 작성해줄 필요 없이 알아서 찾아서 조회된다.

     

    페이징 처리

    n+1 문제 해결 방안은 아니지만, 또 한가지 성능을 개선시킬 수 있는 방법으로 페이징 처리가 있다. 

    페이징 처리는 대량의 데이터를 한번에 가져오는 것이 아니라 일정한 크기로 나누어 필요한 부분만 가져오는 방식이다. 

    페이징 처리를 통해 사용자가 데이터에 접근할 때 응답속도를 향상시킬 수 있다.

    예를들어, 수백게 이상의 게시글이 존재하는 사이트에서 게시글 목록을 보여줄 때, 페이지 번호를 누를 때마다 해당 페이지의 데이터만을 조회한다. 또 다른 예로, 인스타 돋보기를 쭉 내리다보면 아주 짧게 로딩이 있는 것을 볼 수 있는데 이것 또한 페이징 처리를 해놓은 것이다.

    페이징 처리 예시

    public class PostService{
    	public Page<Post> postList(){
                Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "idx"));
                Page<Post> posts = PostRepository.findAll(pageable);
                //slice 사용
                //Slice<Post> posts = PostRepository.findAll(pageable);
                return psots;
    	}
    }
    public class PostRepository{
    	Page<Post> findAll(Pageable pageable);
        //slice 사용
        //Slice<Post> findAll(Pageable pageable);
    }

     

    만약 페이지 번호가 필요하지 않을 경우 Page 대신 Slice를 사용할 수 있다. Slice는 다음 페이지가 있는지 여부를 알려줘 무한 스크롤에 사용될 수 있다 (e.g., 인스타 돋보기)

    💡Page vs Slice

    Page 인터페이스는 전체 데이터 수, 전체 페이지 수 등의 정보를 포함하고 있습니다. 따라서 Page를 생성하려면 추가적인 카운트 쿼리가 필요하다.

    Slice 인터페이스는 다음 페이지가 있는지 여부만 알려준다. 이는 추가적인 카운트 쿼리가 필요하지 않기 때문에 더 효율적일 수 있다.

     

    OneToMany를 조회할 때 JOIN FETCH와 페이징 처리를 같이 사용하면 생기는 문제점

    앞서 말한 세 가지의 방법이 n+1문제를 해결하고 쿼리 성능을 개선할 수 있다고 하지만, OneToMay관계의 엔티티를 조회할 경우 JOIN FETCH를 사용하면 문제가 생길 수 있다.

     

     
    idx name
    1 A
    2 B
    Many
    idx name one_idx
    1 A-1 1
    2 A-2 1
    3 B-1 2
    4 B-2 2
    5 B-3 2
    One JOIN FETCH Many
    one_idx one_name many_idx many_name
    1 A 1 A-1
    1 A 2 A-2
    2 B 3 B-1
    2 B 4 B-2
    2 B 5 B-3

     

    다음과 같이 OneToMay의 관계를 JOIN FETCH 할 경우, One의 데이터에는 중복이 발생한다. 

    이때 그냥 데이터를 조회하면 문제는 없지만, 페이징 처리가 필요할 경우 문제가 생긴다. 

     

    page=0, size=2의 페이징 처리를 할 경우 쿼리에 limit=2과 offset=0을 추가해 조회해 다음과 같은 결과가 생성된다.

    이렇게 될 경우 One쪽의 데이터가 중복돼어 원하지 않는 결과가 나올 수 있다. 

    One JOIN FETCH Many
    one_idx one_name many_idx many_name
    1 A 1 A-1
    1 A 2 A-2

     

     

    따라서, OneToMany관계의 데이터를 조회하고자 할 때 페이징이 필요한 경우 JOIN FETCH를 사용하기 보다는 BatchSize를 사용해 성능개선을 하는 것이 좋다. 

     

     

    정리

    • ManyToOne를 조회할 경우 : FETCH JOIN, BatchSize 모두 사용 가능 (대부분 FETCH JOIN이 더 효과적으로 쿼리 수를 줄여주기 때문에, FETCH JOIN을 사용한다.) 
    • OneToMany를 조회할 경우 : 
      • 페이징 처리 불필요 : FETCH JOIN, BatchSize 모두 사용 가능
      • 페이징 처리 필요 : BatcjSize 사용해 성능개선
        • 페이지 번호 필요 : Page 사용
        • 페이지 번호 불필요 : Slice 사용

     

Designed by Tistory.