본문 바로가기
백엔드/JPA

개선된 hibernate 6.0 @EntityGraph 에 미친 영향

by ARlegro 2025. 3. 12.

이전에 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 최적화가 이뤄졌다 (뒤에 나)

 

@EnityGraph의 위력 - Hibernate 6.0 이후

이전에는 fetch join과 비교했을 때, 서로 장단점이 있었다.
하지만 6.0 이후에는 @EntityGraph의 처리방식과 최적화 개선으로 @EntityGraph부상할듯?

1️⃣ OneToMany관계 컬렉션 데이터 처리 최적화

Hibernate 내부적으로 JOIN FETCH와 유사한 방식으로 동작하며, 루트 엔티티(부모)의 중복을 제거하는 동일한 로직이 적용된다.
구체적인 내부 동작 과정

  1. SQL 쿼리 실행 후 여러 개의 행을 반환 받음(이때까지는 중복)
    • SQL 쿼리는 LEFT OUTER JOIN 사용 (중복이여도 받아 그냥~)
  2. 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

  1. FETCH 모드일 경우
    • attributePaths에 포함된 연관관계만 Fetch Join을 수행
    • 나머지 연관 관계는 JPA에서 정한 FetchType 설정을 따름 (만약 Eager라면 바로 가져옴)
    • 즉, EAGER로 설정되어있어도 명식적으로 attributePaths에 넣으면 fet
  2. 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 쿼리로 나가는건 아님

  • 비슷한 동작이라는 것.
    1. fetch join : inner join 사용
    2. @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