본문 바로가기

CS 잡지식

컬렉션(Collection) fetch join 시, 발생하는 [데이터 중복] 문제!(소위 [데이터 뻥튀기]라고도 부름)

[1:다]에서 생기는 문제이기도 함. (아래 사이트 참조)

(하이버네이터 6부터는 자동으로 데이터 중복 해결해줌. (Feat. distinct)

https://jbluke.tistory.com/346

 

1:多

 

jbluke.tistory.com

 

@Entity
@Table(name = "ORDERS")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id@GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="member_id")
    private Member member; 

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>(); // 컬렉션으로 정의됨.

 

Order : OrderItem = 1 : 다이며, 고로 OrderItem을 컬렉션으로 정의를 하였다. 

@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() { // V2에서 생겼던, (N+1)문제를 FETCH JOIN으로 해결!!
                                   // 컬렉션 사용 시, 생기는 [데이터 중복]문제도 해결함

    List<Order> orders = orderRepository.findAllWithItem(); // fetch join을 사용하여 새로 정의한 메서드

    List<OrderDto> result = orders.stream()

            .map(o->new OrderDto(o))
            .collect(Collectors.toList());

    return result;

}

 

public List<Order> findAllWithItem(){

        return entityManager.createQuery(
                "select DISTINCT o from Order o" + // distinct를 넣은 이유는 아래의 주석 참조!

                        " join fetch o.member m"+

                        " join fetch o.delivery d"+

                        " join fetch o.orderItems oi"+ // OrderItem은 컬렉션(Collection)으로 정의돼 있다.
                                                       // [데이터 중복]문제 발생 : DISTICT로 해결!

                        // [애플리케이션]에서 쿼리 결과를 들고 와서, [애플리케이션] 계층에서
                        // [데이터 중복]를 지워줌.
                        // (DB 사이드에서는 한 ROW가 완전히 똑같아야만 DISTINCT로 동작해서 중복이 지므로
                        //  DISTINCT를 사용하여도 [데이터 중복] 문제가 해결이 안 됨)
                        // 구체적으로는 JPA(하이버네이트)가 조회돼 Context에 캐싱된 Order 객체의 id를 보고
                        // 그 id가 같은 Order 객체에 중복 저장(정확히는,Order객체의 참조값)하지 않는다.
                        // !!하이버네이트 6.x.x부터는 DISTINCT를 넣지 않아도 [자동]으로 데이터 중복 문제를 해결
                        " join fetch oi.item i" ,Order.class)

                .getResultList();

}

 

이렇게 fetch join과 distinct로 [1+N] 문제와 [데이터 중복] 문제를 해결하였다. 

그러나 여기에서 치명적인 단점이 하나 있다. 

-> [페이징을 할 수가 없다](정확히는 할 수가 있는데, 하면 성능 이슈가 터지므로 권장하지 x)

즉 [1:다]를 [Fetch Join](Fetch Join이 없어도 똑같은 문제가 일어남)하는 순간 [페이징]이 불가능해진다.

(걍, 1:다 관계에서 join 시 생기는 문제라고 인식하면 된다.)

 

public List<Order> findAllWithItem(){

        return entityManager.createQuery(
                "select DISTINCT o from Order o" + // distinct를 넣은 이유는 아래의 주석 참조!

                        " join fetch o.member m"+

                        " join fetch o.delivery d"+

                        " join fetch o.orderItems oi"+  // [1:다] 관계에서의 fetch join
                        							    // 여기서 성능 이슈, 페이징 이슈 발생

                     
                       " join fetch oi.item i" ,Order.class)
                       
				// 페이징 쿼리 추가
				.setFirstResult(1) // 1번 row부터
                .setMaxResults(100) // 100번째 row까지만 긁어서 와라!

                .getResultList();

}

 

위의 페이징 쿼리 추가를 하였다. 

그 결과 하이버네이트는 아래와 같은 SQL문과 어떠한 Waring(Eroor는 아님) 메세지를 내뱉으면서 이상한 동작을 한다.

SQL문에 페이징을 나타내는 [limit 100 offest 1] 쿼리문이 없다.

왜 SQL 쿼리문에 페이징 관련 쿼리문이 없는 걸까??(그 이유는 아래를 천천히 읽으면 이해됨)

-> 이 말은 쉽게 말하면 아래와 같다

"넌 지금 페이징을 컬렉션 Fetch Join문에서 정의를 했어!! 그러니 나는 페이징 처리를 DB에서 하지 않고 메모리에다가

페이징 대상 Table을 올려 놓고 Sorting을 할거야(applying in memory)" 

-> 개발자 : ???? .............. ㅡㅡ

만약 페이징 대상 Table에 튜플이 1만개가 있으면 어떻게 될까(실무 레벨에서 1만개면 적지 않을까?ㅋㅋㅋ)

1만개가 모두 메모리에 올라와 버린다.(그래서 옛날 언젠가 인텔리J에서 메모리가 부족할 것 같다고 경고 메세지가 날려

진  적이 있었다. 그때 당시는 원인을 몰랐는데, 그 원인이 페이지 대상 tuple이 모두 메모리에 로딩돼서 그런 것이였다.ㅋ)

 

또 다른 문제가 있다. 

[1:다]에서 Collection을 fetch Join으로 들고 왔다고 하자!

이런 시나리오를 가정해보자.

Order객체는 2개가 있고,  각가 2개의 OrderItem 객체를 가진다고 해보자.

위 페이징이 추가된 코드에서 개발자가 원하는 결과는 

1] 애플리케이션에 [데이터 중복]이 해결된 단 2개의 Order 객체가 있다.

2]  이 2개를의 객체(or 2개의 tuple)을 가지고, 페이징 처리가 된다.

-> 위 동작을 예상할 것이다. 

그러나 개발자 입장이 아니라 DB 입장이 되보자.

DISTINCT 키워드는 DB 사이드가 아니라, 애플리케이션 사이드에서 JPA(하이버네이트)에서 처리가 된다. 

고로, DB에서 애플리케이션에서 조회 결과를 보낼 때는 위와 같이 보낸다. 

개발자는 [데이터 중복]이 해결된 상태에서 페이징 처리를 하기를 원하고,

DB에서는  [데이터 중복]의 해결이 해결되지 않기 때문에(DISTINCT는 TUPLE의 모든 속석이 같아야 제거가 됨)

DB에서 만약 페이징 처리를 해버리면, 위 개발자들의 원하는 대로 동작을 못한다. 

(만약, DB에서 다음 페이지 쿼리를 실행하게 되면 개발자의 예상과 다른 결과가 나온다.

setFirst = 1, setMax = 100을 하면, 애플리케이션에는 위 Table의 2번째 row~4번째 row가 반환되는 결과가 된다)

(개발자는 order_id=4인 Order객체 1개와 order_id = 11인 Order 객체 1개를 반환받기를 기대했을 것이다 )

그래서 페이징 코드를 추가하여서 SQL문을 날려도 페이징 관련 SQL문이 없는 것이다. 

JPA(하이버네이트)가 개발자가 원하는 대로 동작하게 하기 위하여,

애플리케이션에서 [데이터 중복]을 모두 제거한 뒤 

위 Table 모두를 [메모리]에 로딩해서, 페이징을 처리하려고 한다. (왜냐면, DB에서 페이징 처리를 해줘 들고 오면

개발자들이 원하는 결과를 반환할 수가 없기 때문)

 근데, 만약 애플리케이션에서 로딩된 TUPLE이 1만 개라면 엄청난 메모리 부하와 성능 이슈가 생겨 버리기에

-> 1:다 관계에서는 절대 Fetch Join을 포함한 그 어떠한 join을 사용해서는 안 된다.

 

정리

1. [1:다] 관계에서 [컬렉션] 조회 시, [데이터 중복] 문제 발생 : distince로 해결 가능

2. [1:다] 관계에서 [컬렉션]과의 join(fetch join을 포함한 모든 join) 연산 시, [메모리 부하] 이슈와

[페이징]을 사용할 수 없는 치명적 단점 존재 

-> 절대 [1:다] 관계에서는 페이징을 사용하면 안된다.  다른 방법을 찾아야 한다(대안이 있음)

 

3. 위에서는 설명을 하지 않았지만, JPQL 내에서 Fetch Join을 사용할 때, 

fetch join 대상은 무조건 1개만 사용을 해야 한다. -> 만약 JPQL 쿼리 내에 , 10개의 컬렉션에 대해서 Fetch Join을 한다고 해보자.그러면 관계 그래프가 (N x M x ..... x Z)개 만큼의 관계가 생겨 버린다. 이렇게 되면 일단 [데이터 중복]이 생긴다.(DISTINCT로 해결이 가능)그러나 제일 문제는 JPA가 무엇을 기준으로 데이터를 끌어 와야 할 지 몰라서 데이터가 부정합하게 조회될 수가 있다.

-> 위 1,2,3에 대한 대안이 아래 사이트에 있다. 

https://jbluke.tistory.com/427

 

[1:다]에서의 컬렉션(다) 조회 시 문제 : 페이징, (N+1)문제 등

https://jbluke.tistory.com/426 ( 이 사이트의 맥락에 이어서 설명을 하겠다) 컬렉션(Collection) fetch join 시, 발생하는 [데이터 중복] 문제!(소위 [데이터 뻥튀기]라고도 부름) [1:다]에서 생기는 문제이기도

jbluke.tistory.com