JPA - 컬렉션 fetch join

희운·2025년 7월 30일

JPA

목록 보기
4/5

Fetch join 을 하지 않았을때 발생하는 문제점

먼저 Fetch join 을 하지 않을때 어떤 문제점이 발생하는지 알아야 Fetch join 을 잘 사용할 수 있다


@GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return result;
    }

@Data
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        //속에 있는것도 DTO로 감싸자..!
        private List<OrderItemDto> orderItems;//DTO 안에도 엔티티 관련된 리스트가 있으면 안되고 모든걸 DTO
        //private List<OrderItem> orderItems -> 이렇게 하지 말아라 , OrderItem이 엔티티라서 이렇게 하면 안됨

        public OrderDto(Order order) { // order 의 개수만큼 호출된다.
            orderId = order.getId();
            name = order.getMember().getName();//lazy 로딩 초기화 -> Order 객체의 Member 자체가 실제 엔티티를 가진다.
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            //여기서 루프를 돌면서 orderItems 의 select 문이 나간다
            System.out.println("===== OrderItems 루프 시작 ======");
            orderItems = order.getOrderItems().stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
                    .collect(Collectors.toList());
        }

@Data
    static class OrderItemDto {
        //프론트(client)가 요구하는 api 스펙
        private String itemName;//상품명
        private int orderPrice;//주문 가격
        private int count;//주문 수량

        //private Item item -> 그냥 OrderItem 인 경우 이런 엔티티까지 있었다. 필요한것만 가져와서 사용

        public OrderItemDto(OrderItem orderItem) {
            System.out.println("=======Item ====== ");
            itemName = orderItem.getItem().getName();// depth 가 줄어든다.
            System.out.println("=====Item 끝 =====");
            orderPrice = orderItem.getOrderPrice();
            count = orderItem.getCount();
        }
    }

여기서 먼저 Lazy 로딩이 뭔지 알아보자.

엔티티를 접근해서 특정 필드에 접근하면 그때 연관관계에 있는 엔티티가 실제로 주입이 된다.
즉 order.getMember().getName() 다른 필드도 상관없음, 필드에 접근 getName() 을 한 경우 Order 의 Member 가 실제 엔티티가 주입인된다. 이렇게 주입이 어떻게 되는것이가?

기존에 Order 의 Member 은 프록시로 존재한다. 하지만 위와 같이 접근시 Member 엔티티를 select 하는 조회 쿼리가 추가로 발생한다.

쉽게 말하면, 객체 그래프 탐색을 하면 프록시로 객체가 존재하기 때문에 필드에 접근시 추가 쿼리가 생긴다는 것이다.
이건 매우 비효율적이다.

그럼 어떻게 하면 좋을까??
복잡한거 없다. 그냥 쿼리 조회할때 내가 필요한 엔티티 즉, client 에게 반환할 정보... 등 요구 사항에 맞춰서 필요한 데이터를 전부 쿼리를 통해서 한번에 가져오면 된다.
이렇게 가져오게 되면 Order 입장에서 Member 는 진짜 엔티티 Member 입장에서 Item 은 진짜 엔티티 이다.
이러면 추가 쿼리가 안생긴다.


public List<Order> findAllWithItem() {
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member m" + // member : xxToOne
                        " join fetch o.delivery d" + // delivery : xxToOne
                        " join fetch o.orderItems oi" +// -> 데이터 뻥튀기 : xxToMany
                        " join fetch oi.item i", Order.class)
                //.setFirstResult(1)
                //.setMaxResults(100) -> 페이징 쿼리가 sql 이 발생하지 않고 메모리에서 처리하게 된다.
                .getResultList();//
    }

다 가져오기 위해 fetch join 으로 다 가져왔다.
매우 간단하다.
이렇게 가져온 엔티티를 객체 그래프 탐색을 그냥 하면된다.

여기서 중요한것이 있다.
컬렉션 fetch join 을 할 경우 데이터가 row 에 중복이 생긴다는것이다.
즉 . Order 기준으로 OrderItem 은 List 로 존재한다. 그럼 둘이 join 을 해서 내가 Order 를 가져오면 어떻게 될까??
데이터가 뻥튀기 된다. 직접 확인해 보자.

위의 findAllWithItem 을 컬렉션 fetch join을 통해서 한 결과이다ㅏ.
OrderId 를 보면 2개가 아니라 4개이다. 역시 뻥튀기 되었다.

그럼 어플리케이션 단에서 List<Order> order 으로 받을때도 중복 Order 가 생기는지 직접 확인해 보았다


@GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithItem();
        for (Order order : orders) {
            System.out.println("order ref = " + order + " id = " + order.getId() );
        }
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return result;

    }

결과는 이상하게 2개만 나왔따.

order ref = Order{id=1, member=jpabook.jpashop.domain.Member@765d8449, orderItems=[jpabook.jpashop.domain.OrderItem@610ff2f9, jpabook.jpashop.domain.OrderItem@7ca043fb], delivery=jpabook.jpashop.domain.Delivery@c07844, orderDate=2025-07-30T17:30:30.501906, status=ORDER} id = 1
order ref = Order{id=2, member=jpabook.jpashop.domain.Member@49dbc5cb, orderItems=[jpabook.jpashop.domain.OrderItem@3569594c, jpabook.jpashop.domain.OrderItem@2688a90d], delivery=jpabook.jpashop.domain.Delivery@682a17f8, orderDate=2025-07-30T17:30:30.576967, status=ORDER} id = 2

이게 어떻게 된것인가.
하이버네이버 6이전에는 이렇게 중복된 Order 와 같이 데이터가 뻥튀기 되었을때 중복을 제거하기 위해서
distinct 를 select 절 뒤에 추가하였다. 즉
1. DB SQL -> distinct 쿼리 발생
2. 어플리케이션 단에서 중복 루트 엔티티 제거
두가지 기능을 지원하였다.

하지만 지금은 distinct 없이도 컬렉션 fetch join 시에 알아서 distinct 를 추가해서 중복을 제거해준다고 한다. 매우 편해졌다.


컬렉션 Fetch join 의 문제점

컬렉션 페치 조인을 하면 1 대 다 관계로 인해 데이터가 뻥튀기 된다( row 수 증가)
위에서 본것처럼 row 가 여러개 발생하기 때문에 페이징 쿼리가 발생하지 않는다
내가 Order 를 OrderItem 과 fetch join 을 하면 OrderItem 의 개수만큼 row 가 생긴다.
아래 코드를 보자



 public List<Order> findAllWithItem() {
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member m" + // member : xxToOne
                        " join fetch o.delivery d" + // delivery : xxToOne
                        " join fetch o.orderItems oi" +// -> 데이터 뻥튀기 : xxToMany
                        " join fetch oi.item i", Order.class)
                .setFirstResult(1)
                .setMaxResults(100) //-> 페이징 쿼리가 sql 이 발생하지 않고 메모리에서 처리하게 된다.
                .getResultList();//
    }

아래가 발생한 실제 sql

 select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        oi1_0.order_id,
        oi1_0.order_item_id,
        oi1_0.count,
        i1_0.item_id,
        i1_0.dtype,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_0.artist,
        i1_0.etc,
        i1_0.author,
        i1_0.isbn,
        i1_0.actor,
        i1_0.director,
        oi1_0.order_price,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    join
        order_item oi1_0 
            on o1_0.order_id=oi1_0.order_id 
    join
        item i1_0 
            on i1_0.item_id=oi1_0.item_i

나는 페이징 쿼리를 날렸는데 페이징 쿼리는 전혀 발생하지 않았다.

즉 jpa 는 데이터 뻥튀기가 발생한것을 알고 페이징 쿼리 자체를 db 로 보내지 않는다.
만약 보낸다고 해도 큰일이 발생한다.페이징이 안될것이다. 데이터 뻥튀기로인해서

여기서 어플리케이션 단에서 메모리위에서 페이징을 하지만 데이터가 엄청 많으면 메모리가 터질수도 있기때문에

절대 , 일대다 fetch join 에서는 페이징을 하지말자.
페이징이 없으면 그냥 사용해도 된다 어차피 중복을 제거해주니까

profile
기록하는 공간

0개의 댓글