본문 바로가기

Spring

@OneToMany Fetch Join 시 데이터 중복과 주의점

오늘은 JPA를 사용하면서 발생했던 OneToMany와 Fetch Join을 같이 사용할 때의 주의할 점과 발생하는 데이터 중복 해결 방법에 대해서 작성해보겠습니다. 참고자료는 김영한님의 자바 ORM 표준 JPA 프로그래밍 책입니다.

1. @OneToMany Fetch Join은 중복 데이터가 발생된다?

 

첫 번째로, @OneToMany 연관관계에서 Fetch Join으로 데이터를 가져오는 경우 중복 데이터가 생길 수 있습니다. 중복 데이터가 발생하는 원인은 fetch Join을 하면, 연관된 데이터를 모두 가져오기 때문에, 아래 같은 경우 team에 대해서 fetch join을 하면 Team의 레코드 개수는 6개가 될 것입니다. Team Id는 중복되지만, 연관된 Member ID는 다르기 때문에 다른 레코드로 인식하기 때문입니다.

 

Team ID Member ID
1 1
1 2
1 3
2 4
2 5
2 6

이러한 중복 데이터의 발생은 매우 자연스러운 현상입니다. 조인에 따라서 어쩔 수 없이 생기는 부분인데요, 김영한님 강의 중 설명에 따르면 사용자에 따라 중복 데이터가 필요할 수도 있고 아닐 수도 있기 때문에 남겨놓은 것 같다고 설명해주십니다. 

어떻게 중복 데이터를 제거할 수 있을까요?

1. JPQL Select Distinct로 가져오기

가장 간단한 방법은 JPQL의 select distinct로 중복을 제거해주는 것입니다. 헷갈리기 쉬운 것이, JPQL의 select distinct는 SQL의 Distinct와 다릅니다. 

 

SQL에서의 Distinct는, 모든 필드의 값이 같은 경우 동일한 레코드로 판단하여 중복 제거합니다. 아래 표의 경우에서는 2행과 3행은 중복으로 인식하여 제거되겠지만, 1행과 2행은 다른 행으로 인식할 것입니다. 

Team ID Member ID
1 1
1 2
1 2

그렇지만 JPQL의 select distinct는 동작이 조금 다릅니다. 가져오는 엔티티에 담기는 데이터를 기준으로 동일한 데이터를 삭제합니다. fetch Join으로 위 데이터를 가져오더라도, 엔티티인 Team을 기준으로는 다 동일한 데이터이므로 중복이 제거되게 됩니다.

 

하지만 위 방법은 간단하지만 복잡한 객체 그래프를 탐색하는 경우, 중복이 완전히 제거되지 않을 수 있습니다.

업브렐라의 엔티티를 예로 들어보겠습니다.

위 ERD에서 BusinessHours ↔ StoreMeta ↔ StoreDetail와 같은 경우,

가게의 상세 정보를 표시하기 위해 StoreDetail에서 StoreMeta, BusinessHours를 FetchJoin하여 가져왔을 때 중복이 제거되지 않았습니다. 이는 Distinct가 대상으로하는 엔티티의 중복만 제거하지, 엔티티가 다른 oneToMany 관계를 가지는 경우 이 엔티티에 대해서는 중복을 제거하지 못하기 때문입니다.

 

복잡한 객체 그래프 탐색 시 발생하는 데이터 중복

 

참고로, Hibernate 6.1 버전(스프링부트 3.0부터 적용)부터는 OneToMany를 fetch Join으로 가져올 시 자동으로 distinct를 적용하여 중복을 제거해준다고 합니다. 

 

2. Set을 이용하기

위의 경우처럼, 중복 데이터를 제거할 수 없는 경우, Set으로 데이터를 가져온다면 중복을 효과적으로 제거할 수 있습니다. 그렇지만 List 대신 Set을 사용할 때 유의해야할 점이 있습니다.

 

Set은 순서가 없는 자료구조이므로 순서가 보장되지 않습니다.

따라서, Set으로 데이터를 가져올 때는 어플리케이션 레벨에서 정렬해주어야합니다.

 

3. @BatchSize로 한번에 지연로딩하기

중복 데이터를 방지하는 가장 쉬운 방법은, OneToMany에서 fetch Join을 사용하지 않는 것입니다.

두 가지 방법이 있는데요, OneToMany가 아니거나, 혹은 fetch Join을 사용하지 않으면 됩니다.

가급적이면 ManyToOne에서 쿼리를 풀어나가는 것이 제일 이상적입니다. 그러나 비즈니스 요구사항에 따라 OneToMany를 반드시 사용해야하는 상황이라면, fetch Join을 여러 단계로 나누거나, 지연 로딩을 사용해서 해결할 수도 있습니다. 지연로딩을 하는 경우 그때그때 필요한 데이터를 쿼리하여 가져오기 때문에 중복되는 데이터가 발생하지 않습니다. 추천되는 방법은 여러 번의 fetch Join이 필요한 경우 manyToOne에서 fetch Join을 사용하고 나머지는 지연 로딩으로 풀어나가는 방법입니다.

 

그러나 지연로딩을 사용하는 경우 N+1 쿼리가 발생할 수 있기 때문에, 주의하여 사용이 필요합니다. 성능을 개선하기 위해 사용되는 방법이 @BatchSize 어노테이션을 이용한 지연로딩 최적화인데요. 

다시 아래의 예를 들어보겠습니다.

Team ID Member ID
1 1
1 2
1 3
2 4
2 5
2 6

지연로딩이므로 Select Team으로 team 1,2를 가져옵니다. 그리고 List<Member>를 지연 로딩으로 가져오는데요, 이때 쿼리를 Team 1, 2에 대해 2번 날리는 것이 아닌 Select Member where team_id in (1,2)와 같이 한 번에 지연 로딩을 합니다. 그리고 이 결과를 메모리에 로딩하여 Team 엔티티에 적절하게 조립하는 방식입니다. 이 방법을 사용했을 때 N+1 쿼리를 최대 테이블의 개수와 비슷하게 줄일 수 있다는 장점이 있어 아주 실용적입니다. 그리고, fetch Join의 단점을 효과적으로 보완합니다. 아래에서 fetch Join의 단점을 추가로 더 알아보겠습니다.

 

2. @OneToMany Fetch Join은 페이징을 할 수 없다?

간단하게 위 예시를 페이징한다고 생각해보겠습니다. 일대다는 데이터의 개수가 뻥튀기되기 때문에, 중복을 제거하기 전의 데이터로는 제대로 된 페이징을 할 수 없습니다. 따라서 JPA에서 일대다 fetch Join의 결과를 페이징하면 하이버네이트가 자동으로 메모리에 로딩하여 중복을 제거한 후 페이징을 하게되는데, 이는 매우매우 위험합니다. 쿼리의 결과를 메모리에 로딩하는데, 메모리를 초과하게 되면 서버 장애나기 쉬운 상황이겠죠.

 

3. @OneToMany Fetch Join은 한번만 할 수 있다?

 

JPA에서는 OneToMany를 2개 이상 List로 매핑할 수 없습니다. 매핑하려고 하는 경우 MultipleBagFetchException이 발생합니다.

 

마찬가지로 예를 들어, Team, Member, Game 테이블이 있고,

Team이 List<Member>와 List<Game>을 가지는 경우를 생각해보겠습니다.

그러면 fetch Join으로 모든 데이터를 가져오는 경우에, 위의 경우처럼 Cartesian Product만큼 데이터가 복사됩니다. 따라서 JPA는 예외를 발생시키는데, 이는 바뀌는 필드가 2개 이상이 되면 매핑 하기 힘들어지고 데이터가 기하급수적으로 늘어나기 때문입니다.

 

Team ID Member ID Game ID
1 1 1
1 2 1
1 3 1
2 1 2
2 2 2
2 3 2

 

하지만 둘 중 하나를 Set으로 바꾼다면 예외가 발생되지 않습니다. Member를 Set으로 받는다고 생각해보면 아래와 같습니다.

 

Team ID Member(Set) Game ID
1 1,2,3 1
1 1,2,3 2
1 1,2,3 3

 

그러면 훨씬 문제가 단순해져 한 가지의 중복만 제거하면 되는 문제가 되기 때문에 예외가 터지지 않습니다.

 

그러나 여기에서도 마찬가지로 더 좋은 해결방법은, 여러 번의 oneToMany fetch Join이 발생하지 않도록 엔티티 그래프를 가능한 다른 방향으로 설정하는 것입니다.