DEV-STUDY/Spring

[JPA] (컬렉션) JOIN FETCH

HwangJerry 2023. 6. 27. 22:08

장점

만약 어떤 엔티티의 dto를 조회한다고 했을 때,

 

해당 엔티티의 필드 중 어떤 엔티티가 컬렉션으로 연관관계가 묶여 있을 경우

 

이 조회 기능에 fetch join을 사용하지 않고 dto로 변환하고자 한다면, 이 과정에서 LAZY인 연관관계를 조회하기 위해 무수히 많은 쿼리문이 발생할 것이다. (1+N 이슈)

@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
    List<Order> orders = orderRepository.findAll();
    List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(toList());
    return result;
}
public List<Order> findAll() {
    return em.createQuery("select o from Order o", Order.class)
            .getResultList();
}

 

이는 분명 성능상 크리티컬한 문제를 야기한다. 따라서 N+1 이슈를 방지하기 위해(==하나의 엔티티를 조회하기 위해 실직적으로 여러 쿼리가 나가는 것을 방지하고자) JPA를 사용할 때에는 JOIN FETCH를 보통 사용한다.

@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithItem();
    List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(toList());

    return result;
}
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();
}

단, 이 때에도 OneToOne 또는 ManyToOne인 경우에는 JOIN FETCH만 적용해도 큰 문제가 발생하지 않지만, 만약 OneToMany인 필드를 가진 엔티티를 조회해야 하는 경우에는 select distinct까지 해줘야 중복되지 않은 결과를 얻을 수 있다. (JPA가 JOIN FETCH만으로 콜렉션 엔티티 필드를 조회하여 불러오게 되면, 데이터는 조회한 이후 컬렉션을 기준으로 row를 구성하여 반환하기 때문에 컬렉션의 수 만큼 주 엔티티의 데이터 또한 뻥튀기되어 반환되게 된다(RDB JOIN 쿼리 상의 특징). 이를 어플리케이션 레벨에서 필터링하기 위해 distinct라는 JPQL을 사용하면, 이를 SQL의 distinct로 적용시켜서 불필요한 중복을 제거하여 엔티티 정보를 조회할 수 있게 해 준다.)

 

이렇게 JOIN FETCH를 하게 되면, 각 연관관계 필드에 LAZY로 되어 있어도, 내부적으로 inner join SQL을 적용하여 한 번의 쿼리로 모든 정보를 불러올 수 있게 된다.

 

단점

JOIN FETCH를 이용하면 SQL 쿼리를 1번만 보내어 조회를 할 수 있기 때문에 성능상으로 봤을 때, JOIN FETCH를 사용하지 않을 때와 비교하여 많은 성능 개선을 이뤄낼 수 있다.

 

하지만, 일대일 또는 다대일 JOIN FETCH와 다르게, 일대다 JOIN FETCH의 경우에는 페이징이 불가능하다는 점을 꼭 유념해야 한다. 즉, 일대다 JOIN FETCH를 수행하고 나서 .setFirstResult(1).setMaxResults(100).getResultList();와 같이 JPA 로직을 구성하게 되면, 쿼리 레벨에서 페이징을 수행하지 않고, 스프링 어플리케이션은 우선 요청된 엔티티 데이터를 메모리에 모두 로드시킨 뒤에, 메모리에서 페이징을 수행하려고 시도한다. 이는 메모리가 아웃될 수 있는 매우 위험한 매커니즘이므로, 일대다 관계의 컬렉션 필드를 가진 엔티티는 JOIN FETCH 방식으로 조회해서는 안된다.

참고: 페이징을 위한 메서드인 setFirstResult(숫자) 의 parameter인 숫자는 0부터 시작.

 

참고로, 컬렉션 JOIN FETCH는 1개의 컬렉션 필드에 대해서만 적용이 가능하다. 만약 둘 이상의 컬렉션 엔티티 필드를 가진 엔티티를 JOIN FETCH할 경우에는 데이터가 부정확하게 조회될 수 있음을 유의하자.(어차피 근데 컬렉션 JOIN FETCH는 사용하지 않아야 한다.) -> 이유: 일대다 JOIN FETCH만 해도 데이터가 뻥튀기되는 것이 심한데, JOIN FETCH를 여러 일대다 필드를 가진 엔티티에 적용하게 되면 일대다*일대다 조회가 되어버리는 것과 같아서 엄청난 뻥튀기 현상이 발생한다. 이 경우에, JPA는 이렇게 조회한 데이터를 잘못 맞출 수 있게 된다. (정합성 이슈 존재)

 

결론

OneToOne, ManyToOne 필드를 가진 엔티티 조회 : JOIN FETCH (O ; 사용을 적극 권장한다.)

OneToMany 필드를 가진 엔티티 조회 : JOIN FETCH (X ; 페이징을 메모리에서 수행하기 때문에, 사용하면 안된다.)