[JPA] ToMany 관계 컬렉션 성능 최적화 (1) (N+1, fetch join, batch size)

손효재·2022년 9월 4일
4

JPA

목록 보기
7/11
post-thumbnail
post-custom-banner

이전글 : ToOne 관계 성능 최적화

컬렉션 조회성능 최적화

ToOne관계와 다르게 ToMany 관계의 조인에서는 DB가 뻥튀기 되서 최적화하기 어려워지기 때문에 최적화에 대해 더 많이 고민해야한다.

1. 엔티티를 DTO로 변환

엔티티를 외부로 노출하지말자!!

image

Untitled Untitled (1)

orderItems이 엔티티이기때문에, 프록시 초기화 이후 orderItems의 결과를 확인할 수 있다.

Untitled (2) Untitled (3)

이때, 단순히 response를 DTO로 한번 감싸는게 아니라, 내부에 있는 엔티티도 아예 매핑하면안된다!! (OrderItems)
엔티티와 완전히 의존을 끊어야한다.
엔티티는 모두 DTO로 변환해서 사용해야 엔티티가 변하더라도 API 스펙이 바뀌지 않는다!! (Address같은 값타입은 상관없다)

Untitled (4) Untitled (5) Untitled (6)

하지만, 지연 로딩으로 너무 많은 SQL이 실행된다.
order를 조회(1번)하는데, 주문이 2개이기때문에 결과로 2개의 order를 가져온다.
이때 member, delivery가 N번, orderItem이 N번, Item이 orderItem결과 만큼 수행된다.

참고: 지연 로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행한다. 따라서 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않는다.

2. 페치 조인 최적화

Untitled (7)

order가 1개이고, order item은 2개인데, join하면 관계형DB에서는 결과가 2개가 나온다.
order가 2개가 되어서 JPA에서 데이터를 가져올때 order가 2개가 되어버린다.

@Query("select o from Order o join fetch o.member m join fetch o.delivery d join fetch o.orderItems oi join fetch oi.item i")
List<Order> findOrderCollection();

페치조인으로 쿼리 한번에 결과를 가져올 수 있다.

Untitled (8) Untitled (9)

하지만 위와 같이 join을 했기때문에 DB결과가 중복되서 나타나고, 로그를 찍어보면 아래와 같이 같은 order가 2번으로 참조값까지 같게 중복되어 나온것을 확인할 수 있다.

Untitled (10)

이때, distinct를 사용한다.

@Query("select distinct o from Order o join fetch o.member m join fetch o.delivery d join fetch o.orderItems oi join fetch oi.item i")
List<Order> findOrderCollection();

하지만 DB상으로는 문제가 있다.

* DB에서 쿼리 실행결과

Untitled (11)

DB에서 distinct는 한 줄이 전부 똑같아야 중복제거가 된다. 하지만 order Item이 달라서 DB쿼리에서는 distinct가 안된다. distinct 명령을 날렸지만, 쿼리 결과를 뽑을때는 이전과 동일하다.

하지만 JPA에서는 distinct를 사용하면 자체적으로 distinct가 있으면, order(루트 엔티티)를 가져올때 order가 같은 Id 값이면 중복을 제거해준다!

Untitled (12)

distinct 대신 일대다 필드의 타입을 Set으로 선언하는 방법도 있다.

Untitled (13)

Set은 중복을 허용하지 않는 자료구조이기 때문에 중복등록이 되지 않는다.
Set이 순서가 보장되지 않기에 엔티티에서 컬렉션은 LinkedHashSet을 사용하여 순서를 보장한다.

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private Set<OrderItem> orderItems = new LinkedHashSet<>();

일대다 관계의 페치조인 단점

일대다 관계의 페치조인에서는 페이징처리를 하면 안된다!
페이징처리를 위해 firtResult와 maxResult를 추가했는데 아래의 warn로그가 발생한다.

Untitled (14)

→ 페치조인을 썻는데 페이징쿼리가 들어가서 메모리에서 페이징처리하려한다. 데이터가 많아지면 모든 데이터를 메모리에 올려서 페이징처리할것이고 메모리 초과가 발생한다

일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이지만 데이터는 다(N)를 기준으로 row 가 생성된다.
우리가 예상한 DB는 2개이지만, 실제 DB에서는 페치조인으로 인해 row수가 증가하여 4개가 되고 페이징의 기준이 달라진다. 일대다 페치조인에서는 페이징을 하면 안된다.

그래서 Hibernate는 경고로그를 남기고 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 처리하는데 이것은 매우 위험하다.

• 참고: 컬렉션 페치 조인은 1개만 사용할 수 있다. 컬렉션 둘 이상에 페치 조인을 사용하면 안된다. 데이터가
부정합하게 조회될 수 있다. (좀 더 찾아보자)

2-1. 페이징과 한계돌파

페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?

  1. 먼저 ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다.
    ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  2. 컬렉션은 지연 로딩으로 조회한다.
Untitled (15) Untitled (16)

ToOne관계는 페치조인이라 쿼리 1번에 다 가져온다. 페치조인하지 않은 ToMany관계인 컬렉션 order item에서는 N+1 문제가 그대로 발생한다.

  1. 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
    이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
  • hibernate.default_batch_fetch_size : 글로벌 설정
  • 개별로 설정하려면 @BatchSize 를 적용하면 된다. (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)
Untitled (17)

한번의 in 쿼리로 한번에 가져온다. 컬렉션과 관련된 데이터(order item)를 in 쿼리로 한번에 가져온다.
이때 batch size는 in 쿼리의 개수를 의미한다.

Untitled (18)

item은 DB에서 결과적으로 4개가 호출됐는데 한번의 쿼리로 가져온다.

결과적으로 총 쿼리호출수는 1 + N + N → 1+1+1로 최적화된다.

전부 페치조인했을때는 쿼리 한번에 모든 데이터를 가져오는 장점이 있지만 데이터의 중복이 많은데, DB에서 어플리케이션으로 모두 전송한다. 그래서 데이터 전송량이 많아진다.

하지만, ToOne 관계는 페치조인하고 컬렉션은 batch size로 가져오면 페치조인에 비해 쿼리 호출수는 증가하지만, DB에서 데이터가 중복없이 최적화되어 가져올 수 있어서 (정규화된 상태로 조회) 조인보다 DB 데이터 전송량에서 최적화된다.

모두 페치조인하지않고 batch size로 가져와도 최적화 할 수 있다. 쿼리수는 엔티티 갯수만큼 많아지지만, 모두 in 쿼리로 가져와서 각 엔티티를 1번씩 조회한다.

결론

ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치조인으로 쿼리 수를 줄이고, 나머지는 hibernate.default_batch_fetch_size 로 최적화 하자.

  • hibernate.default_batch_fetch_size를 글로벌 설정으로 사용해 N+1 문제를 최대한 in 쿼리로 기본적인 성능을 보장하게 한다.
  • @OneToOne, @ManyToOne과 같이 1 관계의 자식 엔티티에 대해서는 모두 Fetch Join을 적용하여 한방 쿼리를 수행한다.
  • 컬렉션이 여러개일때, 가장 데이터가 많은 자식쪽에 Fetch Join을 사용한다.
    • Fetch Join이 없는 자식 엔티티에 관해서는 위에서 선언한 hibernate.default_batch_fetch_size 적용으로 100~1000개의 in 쿼리로 성능을 보장한다.

* default_batch_fetch_size 의 크기 설정에 대해
default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다. 이 전략은 SQL IN절을 사용하는데, DB에 따라 IN절 파라미터를 1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB 에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다.

1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.

@EntityGraph

페치 조인 쿼리를 작성하지 않고 @EntityGraph 어노테이션으로 SQL 한번에 조회할 수 있다.

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

Untitled (19) Untitled (19)

페치조인은 Inner Join, Entity Graph는 Outer Join이라는 차이점이 있다.

jpql로 쿼리를 작성하고 페치조인대신 @EntityGraph를 사용해도 되고, 메서드명으로 쿼리 생성전략을 사용하면서 @EntityGraph를 사용해 페치 조인을 사용할 수도 있다.

post-custom-banner

0개의 댓글