ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA n+1을 해결하는 방법
    카테고리 없음 2023. 4. 21. 15:26
    이번에는 JPA의 n+1을 해결하는 방법을 한번 살펴보자. n+1의 예를 한번들어보자. 어떤 Account 라는 엔티티와 Order라는 엔티티가 있다고 가정하자.
    @Entity
    public class Account {
    
      @Id
      @GeneratedValue
      @Column(name = "ACCOUNT_ID")
      private Long id;
    
      private String name;
    
      private String password;
    
      private String email;
    
      @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
      private List<Order> orders;
    }
    //getter setter etc..
    
    @Entity
    public class Order {
    
      @Id
      @GeneratedValue
      @Column(name = "ORDER_ID")
      private Long id;
    
      @ManyToOne
      @JoinColumn(name = "ACCOUNT_ID")
      private Account account;
    
      @Temporal(TemporalType.TIMESTAMP)
      private Date orderDate;
    
    //getter setter etc..
    }
    
    위와 같은 도메인이 있다고 가정할때 우리는 n+1 만날 수 있다. 예를 들어 Account를 찾고 그 안에 주문정보를 보고 싶다면 Account를 조회 후 해당하는 주문정보를 또 조회해야 한다. 예를들어 계정 정보가 10개가 있다면 주문 정보도 10번 조회를 해야 한다. 또한 주문정보에는 주문한 item들이 있기도 하기에 이게 계속 되다보면 성능에 영향을 미칠 수 도 있다. 이것을 해결 하기 위해 우리는 몇가지 방법을 알아볼 예정이다. Account를 찾아서 그 아래에 있는 리스트를 가져오려면 아래와 같은 쿼리문이 n번 날라간다.
    Hibernate: 
        select
            account0_.account_id as account_1_0_,
            account0_.email as email2_0_,
            account0_.name as name3_0_,
            account0_.password as password4_0_ 
        from
            account account0_
    Hibernate: 
        select
            orders0_.account_id as account_3_3_0_,
            orders0_.order_id as order_id1_3_0_,
            orders0_.order_id as order_id1_3_1_,
            orders0_.account_id as account_3_3_1_,
            orders0_.order_date as order_da2_3_1_ 
        from
            orders orders0_ 
        where
            orders0_.account_id=?
    Hibernate: 
        select
            orders0_.account_id as account_3_3_0_,
            orders0_.order_id as order_id1_3_0_,
            orders0_.order_id as order_id1_3_1_,
            orders0_.account_id as account_3_3_1_,
            orders0_.order_date as order_da2_3_1_ 
        from
            orders orders0_ 
        where
            orders0_.account_id=?
      ...
      ...
    //n개
    
    이것을 해결하려면 JPA의 패치 조인을 사용하면 해결 할 수 있다. jpql를 직접 써도 되지만 필자는 querydsl을 사용하였다.
    public List<Account> findByleftJoinOrders() {
      QAccount account = QAccount.account;
      QOrder order = QOrder.order;
      return from(account)
        .leftJoin(account.orders, order).fetchJoin()
        .fetch();
    }
    
    로그를 살펴보면 쿼리가 한번 출력 되는 것을 확인 할 수 있다.
    Hibernate: 
        select
            account0_.account_id as account_1_0_0_,
            orders1_.order_id as order_id1_3_1_,
            account0_.email as email2_0_0_,
            account0_.name as name3_0_0_,
            account0_.password as password4_0_0_,
            orders1_.account_id as account_3_3_1_,
            orders1_.order_date as order_da2_3_1_,
            orders1_.account_id as account_3_3_0__,
            orders1_.order_id as order_id1_3_0__ 
        from
            account account0_ 
        left outer join
            orders orders1_ 
                on account0_.account_id=orders1_.account_id
    
    하지만 여기의 예제에서는 문제가 있다 중복으로 데이터들을 가져온다. 그걸 방지하고자 distinct를 사용해서 중복을 제거하자.
    public List<Account> findByleftJoinOrders() {
      QAccount account = QAccount.account;
      QOrder order = QOrder.order;
      return from(account)
        .distinct()
        .leftJoin(account.orders, order).fetchJoin()
        .fetch();
    }
    
    다시 테스트를 해보면 중복이 제거된 상태로 출력이 된다. 근데 순서가 맞지 않게 정렬 되어 나온다. account도 그렇지만 그 안에 주문 정보도 마찬가지다. 아래와 같이 orderby를 넣어주면 끝이다.
    public List<Account> findByleftJoinOrders() {
      QAccount account = QAccount.account;
      QOrder order = QOrder.order;
      return from(account)
        .distinct()
        .leftJoin(account.orders, order).fetchJoin()
        .orderBy(account.id.asc(), order.id.asc())
        .fetch();
    }
    
    이렇게 fetch 조인을 써서 n+1을 막을 수가 있다. fetch 조인 말고도 방법이 몇가지 있는데 이것은 하이버네이트에서 제공해주는 방법이 있다. 하이버네이트에 의존적이긴 하지만 거의 대부분이 하이버네이트를 쓰기에 그렇게 많은 문제는 되지 않을 듯하다.
    @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
    @BatchSize(size = 5)
    private List<Order> orders;
    
    하이버네이트에서 제공해주는 BatchSize 어노테이션을 넣어주면 끝이다. 저 사이즈는 한번에 가져오는 갯수를 말하는것이다. 예를들어 Account가 10개 있으면 Order도 10번의 쿼리를 날려서 가져오는데 5개씩 2번의 쿼리만 날려서 가져오게 된다.
    Hibernate: 
        select
            account0_.account_id as account_1_0_,
            account0_.email as email2_0_,
            account0_.name as name3_0_,
            account0_.password as password4_0_ 
        from
            account account0_
    Hibernate: 
        select
            orders0_.account_id as account_3_3_1_,
            orders0_.order_id as order_id1_3_1_,
            orders0_.order_id as order_id1_3_0_,
            orders0_.account_id as account_3_3_0_,
            orders0_.order_date as order_da2_3_0_ 
        from
            orders orders0_ 
        where
            orders0_.account_id in (
                ?, ?, ?, ?, ?
            )
    Hibernate: 
        select
            orders0_.account_id as account_3_3_1_,
            orders0_.order_id as order_id1_3_1_,
            orders0_.order_id as order_id1_3_0_,
            orders0_.account_id as account_3_3_0_,
            orders0_.order_date as order_da2_3_0_ 
        from
            orders orders0_ 
        where
            orders0_.account_id in (
                ?, ?
            )
    
    
    위와 같은 쿼리를 날린다. in을 사용하여 데이터들을 가져오게된다. 만약 글로벌하게 설정하고 싶다면 아래와 같이 하면 된다. 필자는 Spring boot를 자주 사용해서 boot기준이다. 만약 boot를 사용하지 않느다면 아래와 같은 설정을 xml에 해주면 될 것으로 판단된다.
    spring.jpa.properties.hibernate.default_batch_fetch_size=5
    
    n+1의 마지막 방법으로 @Fetch(FetchMode.SUBSELECT)을 사용하면 된다. 이것 또한 하이버네이트에서 제공해주는 어노테이션이다. 방법 역시 간단하다.
    @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
    @Fetch(FetchMode.SUBSELECT)
    private List<Order> orders;
    
    위와 같이 어노테이션만 넣어 주면 끝난다.
    Hibernate: 
        select
            account0_.account_id as account_1_0_,
            account0_.email as email2_0_,
            account0_.name as name3_0_,
            account0_.password as password4_0_ 
        from
            account account0_
    Hibernate: 
        select
            orders0_.account_id as account_3_3_1_,
            orders0_.order_id as order_id1_3_1_,
            orders0_.order_id as order_id1_3_0_,
            orders0_.account_id as account_3_3_0_,
            orders0_.order_date as order_da2_3_0_ 
        from
            orders orders0_ 
        where
            orders0_.account_id in (
                select
                    account0_.account_id 
                from
                    account account0_
            )
    
    그럼 위와 같이 서브 쿼리를 작성해서 날라간다. 필자가 말한 위의 3가지 방법 모두 사용가능하지만 @Fetch와 @BatchSize 는 너무 정적이다. 굳이 필요 없을 때에도 쿼리를 날려야만 한다. 그게 그렇게 많은 상관이 없다면 해도 되지만 정적인것을 역시 맘에 들지 않는다. 그래서 필자는 fetch조인을 그때그때 사용하는 것이 낫다고 판단한다. (물론 필자생각) 이것저것 다 해보고 자신에게 맞는 것만 한다면 아무거나 해도 상관은 없다. 이상으로 n+1에 대한 해결 방법을 알아봤다. 출처 : 자바 ORM 표준 JPA 프로그래밍 (김영한)

    댓글

Designed by Tistory.