이전에 EntityGraph vs Fetch Join 에서, EntityGraph는 OneToMany의 데이터를 가져올 때 데이터가 뻥튀기 되는(중복) 문제가 있다고 알고 있었다. (fetch join은 내부적으로 Hibernate가 최적화 해준다고 알고 있었음)
근데 과연 이게 맞을까? 에 대한 좀 더 공부하고 싶었다(미션하면서)
공부하면서 추가로, 진화된 @EntityGraph의 기능을 알게되었다.
결론부터 말하자면
Hibernate 6.0 이상에서 @EntityGraph를 사용할 때도 OneToMany 관계에서 자동으로 중복을 제거하는 최적화가 적용된다. (fetch join만 되는줄 알았다)
Hibernate 6은 내부적으로 부모 엔티티를 중복 없이 유지하도록 설계(JPA 표준에 맞게)
💢 하이버네이트 6.0 이전
OneToMany 관계의 데이터를 가져올 때 힘들었다
⛔ 문제 : 데이터를 가져올 때 부모 엔티티(One)가 자식 개수(Many)만큼 중복 포함된 리스트가 반환
- @EntityGraph로는 해결 불가!
- 6.0 이전에 했던 해결 방법 : fetch join
SELECT Distinct(p) FROM Parent p JOIN FETCH p.children
한계
- 무조건 fetch join 방법을 써야 했다
- fetch join의 단점(ex. Pageable or 쿼리 메서드 사용 불가, 다중 컬렉션 데이터 뻥튀기 등)을 안고가야 함
- 단순히 fetch join쓰면 해결되는 것이 아니라 Distinct 를 써서 해결해야 했다
SELECT **Distinct**(p) FROM Parent p JOIN FETCH p.children
🔔 하이버네이트 6.0 이후 - 간단 정리
주요 변경 사항
- JOIN FETCH만 사용해도 자동으로 중복이 제거 : distinct 처리 필요 X
- 위의 쿼리를 실행해도 부모 엔티티는 한 번만 반환된다
SELECT p FROM Parent p JOIN FETCH p.children
- 위의 쿼리를 실행해도 부모 엔티티는 한 번만 반환된다
- @EntityGraph 사용 시 중복 제거 적용
- @EntityGraph를 사용해도 동일한 최적화가 적용된다(완전 같은 구현은 아님 - 뒤에 나옴)
- 자동으로 중복이 제거된 부모 리스트를 반환합니다.
-
@EntityGraph(attributePaths = "children") List<Parent> parents = parentRepository.findAll();
- @EntityGraph를 사용해도 동일한 최적화가 적용된다(완전 같은 구현은 아님 - 뒤에 나옴)
- 이 외에도 @EntityGraph 최적화가 이뤄졌다 (뒤에 나)
@EnityGraph의 위력 - Hibernate 6.0 이후
이전에는 fetch join과 비교했을 때, 서로 장단점이 있었다.
하지만 6.0 이후에는 @EntityGraph의 처리방식과 최적화 개선으로 @EntityGraph부상할듯?
1️⃣ OneToMany관계 컬렉션 데이터 처리 최적화
Hibernate 내부적으로 JOIN FETCH와 유사한 방식으로 동작하며, 루트 엔티티(부모)의 중복을 제거하는 동일한 로직이 적용된다.
구체적인 내부 동작 과정
- SQL 쿼리 실행 후 여러 개의 행을 반환 받음(이때까지는 중복)
- SQL 쿼리는 LEFT OUTER JOIN 사용 (중복이여도 받아 그냥~)
- Hibernate는 부모 엔티티의 식별자 기반 집합을 생성하여 중복을 제거
- 즉, Hibernate는 결과 리스트에서 중복된 부모 엔터티를 자동으로 필터링
2️⃣ 선택적인 쿼리 사용
@EntityGraph 구현 방식은
❓특정 연관 필드를 JOIN FETCH로 가져올지?
❓추가 SQL 쿼리를 사용하여 가져올지?
를 결정할 수 있다
즉, 필요한 경우 여러 개의 SQL 쿼리(서브 쿼리)로 나눠서 실행할 수 있음.
- 이를 통해 대량 데이터 조회 시 성능이 향상될 수 있음.
- 특히, 다중 컬렉션 사용 시 유용
- @EntityGraph는 Hibernate가 개별 서브쿼리를 사용하여 로딩할 수 있도록 유연성을 가짐
- 예를 들어, 부모 엔티티를 먼저 가져오고, 컬렉션 필드를 별도 SQL로 가져오기
- 이는, 네트워크 트래픽을 줄이고, 중복 데이터 전송을 방지
- 심지어 페이징 기능도 사용 가능
반면, JOIN FETCH 는 다중 컬렉션 사용 시 결과 행(row) 개수가 기하급수적으로 증가할 수 있다.
예제:
SELECT p FROM Parent p
JOIN FETCH p.children
JOIN FETCH p.orders
- 부모가 100개, 자식이 각각 10개, 주문이 각각 5개라면 결과 행 수는 100 * 10 * 5 = 5,000개가 될 수 있음.
- 또한, 다중 List 컬렉션을 패칭할 경우 MultipleBagFetchException이 발생할 수 있음.
중간 정리 : JOIN FETCH vs @EntityGraph
- JOIN FETCH: JPQL기반으로 무조건 한 번의 SQL로 모든 연관 데이터를 가져옴
- 연관 데이터가 적을 경우 유리하지만, 다중 컬렉션을 로드할 경우 성능 저하 가능.
- @EntityGraph: 필요에 따라 쿼리를 나누어 실행(서브쿼리)하여 최적의 성능을 유지.
3️⃣ JPA 명세 준수 - 불필요한 로딩 방지
이전 버전 : JPA 명세가 정의한 FETCH 그래프와 LOAD 그래프를 명확히 구분하지 않았다.
그 결과, 예상보다 더 많은 데이터를 로드하는 경우가 많았다.
Hibernate 6에서는 이 동작이 수정되어, @EntityGraph 사용 시 불필요한 데이터 로딩을 방지할 수 있게 됐다.
✅ EntityGraph의 2가지 모드 - fetch, load
- FETCH 모드일 경우
- attributePaths에 포함된 연관관계만 Fetch Join을 수행
- 나머지 연관 관계는 JPA에서 정한 FetchType 설정을 따름 (만약 Eager라면 바로 가져옴)
- 즉, EAGER로 설정되어있어도 명식적으로 attributePaths에 넣으면 fet
- LOAD 모드일 경우
- EAGER로 설정된 모든 연관 관계를 로드하므로 이전과 다르게 동작할 가능성이 있음
- 따라서 Hibernate 6에서 @EntityGraph를 사용할 때 명확한 fetch 전략을 지정하는 것이 중요
@EntityGraph(attributePaths = {"user", "channel"}, type = EntityGraph.EntityGraphType.**LOAD**)
List<ReadStatus> findPagingAllByUser_Id(UUID userId);
@EntityGraph(attributePaths = {"user", "channel"}, type = EntityGraph.EntityGraphType.**FETCH**)
List<ReadStatus> findPagingAllByUser_Id(UUID userId);
(내부 구조 - 기본 모드는 FETCH)
참고 : @EntityGraph가 fetch join의 SQL 쿼리로 나가는건 아님
- 비슷한 동작이라는 것.
- fetch join : inner join 사용
- @EntityGraph : LEFT (OUTER) JOIN 사용
(fetch join 쿼리 예시)
@Query("SELECT rs FROM ReadStatus rs **join fetch** rs.user **join fetch** rs.channel WHERE rs.user.id = :userId")
List<ReadStatus> findAllByUserId(@Param("userId") UUID userId);
SELECT
rs1.id,
c1.id,
c1.channel_type,
c1.created_at,
c1.description,
c1.name,
c1.updated_at,
rs1.created_at,
rs1.last_read_at,
rs1.updated_at,
u1.id,
u1.created_at,
u1.email,
u1.password,
u1.profile_id,
u1.updated_at,
u1.username
FROM
read_statuses rs1
JOIN
users u1
ON u1.id=rs1.user_id
JOIN
channels c1
ON c1.id=rs1.channel_id
WHERE
rs1.user_id='0bfd1252-6eb3-4c35-af78-267151a417a6'
(@EntityGraph 쿼리 예시)
@EntityGraph(attributePaths = {"user", "channel"})
List<ReadStatus> findPagingAllByUser_Id(UUID userId);
SELECT
rs1.id,
c1.id,
c1.channel_type,
c1.created_at,
c1.description,
c1.name,
c1.updated_at,
rs1.created_at,
rs1.last_read_at,
rs1.updated_at,
u2.id,
u2.created_at,
u2.email,
u2.password,
u2.profile_id,
u2.updated_at,
u2.username
FROM
read_statuses rs1
LEFT JOIN
channels c1
ON c1.id=rs1.channel_id
LEFT JOIN
users u2
ON u2.id=rs1.user_id
WHERE
rs1.user_id='30f46211-ca04-4619-98ef-dfa5e064812c'
</aside>
'백엔드 > JPA' 카테고리의 다른 글
@ManyToOne의 optional, cascade 속성 (0) | 2025.04.21 |
---|---|
@NotNull과 @Column(nullabe=false) (0) | 2025.04.20 |
JPA 소개 (0) | 2025.04.18 |
상속관계매핑 전략별 속도 비교 (0) | 2025.02.14 |