JPA - OneToMany 데이터 조회

·2024년 5월 1일
0

Spring/JPA

목록 보기
10/15

인프런 김영한 강사님 강의를 듣고 정리한 내용입니다.

OneToMany 데이터 조회

ManyToMany 관계는 권장되지 않으며 거의 사용하지 않는다. 그러므로 OneToMany 데이터 조회에 관해서 설명하겠다.

OneToMany에서도 XToOne 데이터 조회와 마찬가지로 DTO를 사용해야한다.

DTO 사용 시 주의사항

DTO를 사용해야하는 것은 당연하다. 놓치기 쉬운 부분은 바로 DTO 내부에도 엔티티가 들어가서는 안된다는 점이다.

OneToMany에서 엔티티 내부에 포함된 다른 엔티티의 컬렉션이 있고, 이를 조회해야하는 경우에는 추가적으로 DTO를 만들어주어야 한다.

public class OrderDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemDto> orderItems;
}

위와 같이 API 응답에서 Order 대신 사용하기 위해 OrderDto를 만들었다. 현재 API에서는 Order내부의 orderItems도 같이 조회하려고 한다.
원래대로라면 OrderItem의 컬렉션이 들어가겠지만, Dto 내부에 Entity가 들어가서도 안된다. 그래서 OrderItemDto를 만들어서 OrderItemDto의 컬렉션을 받도록 했다.

OneToMany에서 발생할 수 있는 문제

fetch join을 사용하더라도 조인 과정에서 데이터가 뻥튀기(중복 데이터 발생) 될 수 있다.
쿼리 수를 줄이기 위한 목적으로 fetch join을 사용했으나, 원치 않게 중복된 데이터를 얻게 되는 것이다.

 public List<Order> findAllWithItem() {
        return em.createQuery("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", Order.class)
                .getResultList();
    }

위 코드에서 조회하고자 하는 것은 Order의 데이터이다.

Order과 XToOne 연관관계로 묶인 member, delivery는 그대로 fetch join을 하더라도 데이터의 중복이 발생하지 않는다.

하지만 OneToMany로 묶인 orderItems는 하나의 Order에 여러개의 orderItems가 매핑되기 때문에 fetch join 결과로 나온 테이블에서는 중복된 Order 데이터가 발생하게 된다.

조회하고자 하는 데이터는 One쪽의 데이터이지만, OneToMany로 얽혀있는 엔티티에 의해서 Many를 기준으로 row가 생성되고, One의 데이터가 중복되어 발생하는 것이다.

하이버네이트 5 이하에서는 쿼리에서 distinct를 사용해 이 문제를 해결했다. DB에서는 완전히 중복된 row가 아니기 때문에 아무 일도 일어나지 않는다. 하지만 JPA에서 데이터를 읽어들일 때는 중복된 id를 제거하여 값을 읽어오게 된다.

스프링 3 이상에서는 하이버네이트 6를 지원하는데, 하이버네이트 6에서는 distinct를 사용하지 않고도 중복 데이터를 제거하여 값을 읽어오도록 지원한다.

하지만 fetch join을 사용할 때 XToOne에서 발생하지 않았던 문제들이 발생할 수 있다.

Fetch join 한계

OneToMany 상황에서 컬렉션을 페치조인할 때만 일어날 수 있는 문제들이다.

1. 페이징 불가능

1:N에서 1쪽으로 데이터 조회와 페이징을 하려고 시도하지만, DB에서는 쿼리 결과 N쪽을 기준으로 row가 생성되고, 1의 입장에서 봤을 때 중복된 데이터가 발생한다. 그래서 DB에서는 의도한대로 페이징을 할 수 없게 된다.
하이버네이트는 경고 로그를 남기고 메모리로 가져와서 페이징을 하게 된다.
페치 조인 결과 데이터가 얼마나 늘어날지 예측하기 힘들고, 데이터가 많다면 메모리에 올려서 페이징을 시도했을 때 Out of Memory 에러가 발생할 수 있다.

2. 컬렉션 페치 조인은 최대 1개까지만

데이터가 부정합하게 조회될 수 있으므로 컬렉션 2개 이상에 fetch join을 사용하면 안된다.

해결 방법

Fetch join을 해도 OneToMany 상황에서는 위와 같은 한계가 있었다. 이때 해결방법은 Collection이 아닌 엔티티들만 fetch join을 하는 것이다. 그리고 넘겨받은 join 결과를 Collection의 Lazy 강제 초기화에 사용하면 된다.

이렇게 사용하면 페이징을 사용할 수도 있고, 데이터가 부정합하게 조회되는 것도 방지할 수 있다.

DTO 직접 조회 - 기본

리포지토리에서 DTO를 직접 조회할 수 있다.
DTO 직접 조회를 할 때는 쿼리를 위한 리포지토리를 따로 분리한다.(커맨드 쿼리 분리)

컬렉션이 포함된 DTO 직접 조회에서는 다음 순서로 조회한다.
1. XToOne인 부분만 DTO 직접 조회(findOrders())
2. 컬렉션 DTO만 직접 조회(findOrderItems())
3. Entity DTO 내부의 컬렉션 set

public List<OrderQueryDto> findOrderQueryDtos() {
        List<OrderQueryDto> result = findOrders(); // query 1번 -> N개
        result.forEach(o -> { // query N번
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
    }

1. XToOne인 부분만 DTO 직접 조회

컬렉션을 제외한 XToOne인 부분들만 DTO 직접 조회를 시도한다.

 private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                        "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.orderStatus, d.address )" +
                        " from Order o" +
                        " join o.member m" +
                        " join o.delivery d", OrderQueryDto.class)
                .getResultList();
    }

fetch join에서 했던 것처럼 컬렉션을 제외한 나머지 엔티티들만 조인하여 DTO로 반환한다. (fetch join은 엔티티만 조회 가능하므로, DTO 직접조회에서는 fetch join 사용 불가능.)

2. 컬렉션 DTO만 직접 조회

 private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return em.createQuery("select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                        " from OrderItem oi" +
                        " join oi.item i" +
                        " where oi.order.id = :orderId", OrderItemQueryDto.class)
                .setParameter("orderId", orderId).getResultList();
    }

그리고 컬렉션 DTO를 따로 쿼리한다. 이 때 orderId가 필요하므로, 컬렉션 DTO 내부에 상위 엔티티의 id를 포함한다.

쿼리 횟수

위와 같이 조회했을 때 나가는 쿼리는 Order 조회 1번 + OrderItems 조회 N(OrderItem 갯수)번으로 1+N번의 쿼리가 시도된다.

DTO 직접 조회 - 최적화

위와 같이 DTO 조회를 해도 나쁘지 않은 성능을 보이지만, OrderItem 갯수가 많아지면 성능이 악화된다. 그래서 in 절을 사용하고 default_batch_fetch_size을 설정하여 쿼리를 단 2번으로 줄일 수 있다.

application.properties에서
spring.jpa.properties.default_batch_fetch_size를 설정해준다.
배치 크기는 100~1000 사이로 설정하면 되고, DB와 application이 견딜 수 있는 부하만큼 설정한다. (메모리 사용량은 100으로 설정하나 1000으로 설정하나 차이가 없다. 읽어야 하는 데이터 갯수는 똑같기 때문이다.)

batch size를 설정하면, in 쿼리에서 한 번에 읽을 수 있는 갯수가 정해진다. 만약 batch size가 100이고, 읽어야하는 데이터 갯수가 1000개라면, 1000/100 = 10번의 쿼리만 시도된다.

컬렉션이 포함된 DTO 직접 조회를 최적화 하면 다음 순서로 조회한다.
1. XToOne인 부분만 DTO 직접 조회(findOrders())
2. Entity의 Id들만 리스트로 뽑아냄(toOrderIds())
3. 컬렉션 DTO만 직접 조회(findOrderItems())
4. Entity Id와 컬렉션 DTO Map으로 만들기
5. Entity DTO 내부의 컬렉션 set

  public List<OrderQueryDto> findAllByDto_optimization() {
        List<OrderQueryDto> result = findOrders();

        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

        result.forEach(r -> r.setOrderItems(orderItemMap.get(r.getOrderId())));
        return result;
    }

1번은 최적화 전과 같다. 최적화를 하는 부분은 컬렉션 DTO를 in절로 조회하는 것이기 때문이다.

컬렉션 DTO만 직접 조회

private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
        List<OrderItemQueryDto> orderItems = em.createQuery(
                        "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                                " from OrderItem oi" +
                                " join oi.item i" +
                                " where oi.order.id in :orderIds", OrderItemQueryDto.class)
                .setParameter("orderIds", orderIds)
                .getResultList();

        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(oi -> oi.getOrderId()));
        return orderItemMap;
    }

위와 같이 뽑아낸 orderIds를 사용해 in절로 한꺼번에 조회할 수 있도록 했다. 그리고 5번을 쉽게 하기 위해서 groupingBy로 key가 orderId이고 value가 컬렉션인 Map을 만들어 반환했다.

이렇게 하면 컬렉션을 조회하기 위한 DB접근이 쿼리 1번으로 끝나고, 설정은 메모리에 올려서 어플리케이션에서 해주면 되므로 성능을 올릴 수 있다.

Flat DTO 조회

내부 엔티티를 모두 필드로 풀어서 저장하는 flat DTO를 만들어 조회하는 방법도 있을 수 있다.
이 방법을 사용하면 조회하는 필드가 한정되고, 쿼리가 단 1번밖에 나가지 않는다는 장점이 있다.
하지만 단점으로 컬렉션 갯수만큼 데이터 row가 늘어나서 늘어난 데이터를 받기 위해 네트워크 전송 비용이 많이 들게 된다. 또한 애플리케이션에서 데이터 조작을 위해 많은 코드를 작성해야한다는 점도 있다. 그리고 페이징이 불가능하다.

쿼리 횟수로 결정되는 네트워크 호출과, 데이터 용량으로 결정되는 네트워크 전송 사이의 Trade Off를 고려해야한다.

만약 데이터가 많을 경우, 중복 전송이 증가해서 batch size를 조정하여 조회하는 것과 성능 차이가 미비하다.

결론

컬렉션이 포함된 엔티티를 조회하는 경우에는 엔티티 내부에 포함된 컬렉션의 DTO도 만들어주어야 한다. OneToMany에서도 fetch join을 사용해 쿼리를 최적화 할 수 있으나, 컬렉션을 fetch join할 경우 페이징을 사용할 수 없기 때문에 컬렉션은 따로 조회하는 것이 권장된다. 컬렉션을 사용하지 않을 때는, batch size 조정을 통해 in절을 사용해서 컬렉션 조회를 최적화 할 수 있다.
Flat DTO를 사용하면 쿼리는 1번으로 줄지만, 코드 복잡도가 올라가게 된다.

쿼리 방식 선택 권장 순서

  1. 엔티티 조회 방식으로 우선 접근
    1. 페치조인으로 쿼리 수를 최적화
    2. 컬렉션 최적화
      1. 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화
      2. 페이징 필요X 페치 조인 사용
  2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
  3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate
profile
티스토리로 블로그 이전합니다. 최신 글들은 suhsein.tistory.com 에서 확인 가능합니다.

0개의 댓글