JPA fetch join VS EntityGraph

송동엽·2025년 9월 28일
1

JPA를 사용하다 보면 N+1 문제를 마주하게 된다. 연관된 엔티티를 조회할 때, 처음 쿼리(1) 이후 연관된 엔티티의 수(N) 만큼 추가 쿼리가 발생하는 문제는 애플리케이션의 성능을 저하시킨다.

JPA는 이 문제를 해결하기 위해 fetch join과 EntityGraph를 제공한다. 두 가지 모두 연관 관계를 한 번의 쿼리로 함께 가져와 성능을 최적화하는 것이 목표이다.

우리 회사 코드에서는 fetch join보다 EntityGraph를 많이 사용하고 있어서, 이유에 앞서 그 차이점이 먼저 궁금했다.

서론: FetchType이란

JPA의 FetchType은 연관된 엔티티의 조회 시점에 대한 이야기이다.

  • LAZY: 부모 엔티티를 조회할 때 연관된 엔티티를 조회하지 않는다. 실제로 사용하는 시점에 추가 쿼리를 통해 조회한다.
  • EAGER: 부모 엔티티를 조회할 때 연관된 엔티티를 즉시 조회한다.

그렇다면, FetchType.EAGER은 항상 JOIN 쿼리를 만드는가?
아니다. EAGER는 '즉시 로딩하라'라는, 조회 시점에 대한 이야기일 뿐이다. 그 방법이 JOIN일지 추가 쿼리일지는 JPA 구현체(Hibernate)가 결정한다.

JOIN 쿼리를 만들지 않는다는 예시는 아래와 같다.
아래와 같이 Team과 Member라는 1:N 관계 엔티티를 생각하자. Team은 Member에 대한 OneToMany 참조를 가진다.

@Entity
@Table(name = "teams")
class Team(
    @get:Id
    @get:GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    @get:Column(name = "team_name")
    var name: String,

	@get:OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    var projects: MutableList<Project> = mutableListOf()
)

위 상황에서 teamRepository.findAll()을 했을 때의 SQL은 아래와 같다.

2025-09-28T21:42:18.444+09:00 DEBUG 23629 --- [DataJpaDemo] [    Test worker] org.hibernate.SQL                        : 
    /* <criteria> */ select
        t1_0.id,
        t1_0.team_name 
    from
        teams t1_0
Hibernate: 
    /* <criteria> */ select
        t1_0.id,
        t1_0.team_name 
    from
        teams t1_0
2025-09-28T21:42:18.449+09:00 DEBUG 23629 --- [DataJpaDemo] [    Test worker] org.hibernate.SQL                        : 
    select
        p1_0.team_id,
        p1_0.id,
        p1_0.name 
    from
        projects p1_0 
    where
        p1_0.team_id=?
Hibernate: 
    select
        p1_0.team_id,
        p1_0.id,
        p1_0.name 
    from
        projects p1_0 
    where
        p1_0.team_id=?
2025-09-28T21:42:18.454+09:00 DEBUG 23629 --- [DataJpaDemo] [    Test worker] org.hibernate.SQL                        : 
    select
        p1_0.team_id,
        p1_0.id,
        p1_0.name 
    from
        projects p1_0 
    where
        p1_0.team_id=?
Hibernate: 
    select
        p1_0.team_id,
        p1_0.id,
        p1_0.name 
    from
        projects p1_0 
    where
        p1_0.team_id=?
2025-09-28T21:42:18.456+09:00 DEBUG 23629 --- [DataJpaDemo] [    Test worker] org.hibernate.SQL                        : 
    select
        p1_0.team_id,
        p1_0.id,
        p1_0.name 
    from
        projects p1_0 
    where
        p1_0.team_id=?
Hibernate: 
    select
        p1_0.team_id,
        p1_0.id,
        p1_0.name 
    from
        projects p1_0 
    where
        p1_0.team_id=?

teams을 한번 조회하고, 추가 쿼리로 projects를 조회했다. 테스트용으로 넣어둔 teams 레코드가 3개라서 projects 쿼리가 3개 나갔다(= N+1 쿼리가 발생했다).

Hibernate가 어떤 이유에서인지 JOIN보다 N+1 쿼리가 효율적이라고 판단한 것 같은데, 잘 납득은 되지 않는다. 추가로 알아볼 부분.

어쨌든 FetchType.EAGER가 반드시 JOIN 쿼리로 이어지지는 않는다.

1. fetch join

fetch join은 JPQL에서 제공하는 기능으로, 조회하려는 엔티티와 연관된 엔티티를 한 번의 JOIN 쿼리로 가져오도록 명시하는 방법이다.

특징

  • 엔티티에 설정된 FetchTypeLAZY이든 EAGER이든 무시하고 JOIN한다.
  • 연관된 엔티티에 대한 내용은 JOIN 절에만 있고 SELECT 절에는 없다. 그럼에도 불구하고 연관된 엔티티까지 조회한다.
  • 연관된 엔티티는 JOIN 절 외에서(WHERE 등에서) 사용할 수 없다.
    • 따라서 JOIN 절에 alias를 두는 유일한 이유는 여러 다리를 건너 fetch join하는 경우뿐이다.

사용법
[join 유형] JOIN FETCH [대상 엔티티] 형식으로 쿼리를 작성한다.
[join 유형]은 fetch join의 의도상 아무래도 LEFT를 많이 쓰게 된다. 생략하면 JOIN의 기본 동작인 INNER JOIN 으로 동작한다.

interface MagazineRepository : JpaRepository<Magazine, Long> {
    @Query("SELECT m FROM Magazine m LEFT JOIN FETCH m.articles WHERE m.id = :id")
    fun findByIdWithArticles(@Param("id") id: Long): Magazine?
}

2. EntityGraph

EntityGraph는 JPA 2.1부터 도입된 기능으로, @EntityGraph 어노테이션을 통해 데이터 조회 시 함께 가져올 연관 관계를 정의한다. JPQL을 쓰지 않고도 연관된 엔티티의 조회 시점을 쿼리마다 다르게 둘 수 있다.

Bealdung 등을 찾아보면 @EntityGraph의 장점을 FetchType과 비교해서 설명한다. 마치 @EntityGraph의 동작이 FetchType을 변경하는 것이라는 것처럼 들리는데, 그렇지는 않은 것 같다.
만일 그렇다면 위의 findAll() 예시에서 LAZY 관계를 @EntityGraph로 로딩했을 때 JOIN이 발생하지 않아야 하는데, JOIN이 발생했다.
이 부분은 더 공부가 필요하고, 우선은 '@EntityGraph는 JOIN 쿼리를 만든다'라고 이해하고 있다.

특징

  • 엔티티에 정적으로 설정된 FetchType을 쿼리마다 다르게 설정할 수 있다.
  • 쿼리 자체는 그대로 두고, 데이터를 가져오는 방식을 어노테이션으로 따로 지정하여 관심사의 분리가 가능하다.

사용법
쿼리메서드에 @EntityGraph 어노테이션을 달고 attributePaths에 함께 조회할 엔티티의 프로퍼티 이름을 문자열로 지정한다.

interface MemberRepository : JpaRepository<Member, Long> {
    @EntityGraph(attributePaths = ["team"])
    fun findByName(name: String): List<Member>
}

3. 그래서 뭐가 달라?

먼저 구글링해서 얻은 키워드로는 MultipleBagFetchException 발생 여부, 페이지네이션의 동작 등이 있었다. 그러나 실험한 결과로는 그 부분에서는 다르지 않았다(후술). 그래서 내가 얻은 결론은 '크게 다르지 않고, fetch join의 경우 INNER JOIN 되는 것을 주의해야 한다' 이다.

fetch join은 JOIN에 FETCH 키워드를 붙인 형태이다. JOIN 유형에 대해 별다른 명시가 없다면 INNER JOIN으로 동작한다. 반면 fetch join과 비슷한 usecase를 가지는 @EntityGraph는 연관 엔티티가 없더라도 부모 엔티티는 조회되어야 한다는 관점에서 LEFT JOIN으로 동작하므로, fetch join의 동작을 오해하기 쉽다.

4. 공통된 문제: 컬렉션 조회와 페이지네이션

fetch join과 @EntityGraph 모두 동일한 문제를 공유한다. 페이지네이션이 데이터베이스 레벨에서 처리되지 않는다.

먼저 fetch join을 생각해보자.
Team(1) : Member(N) 관계에서 Team을 페이지네이션으로 조회하며 members를 함께 가져온다고 가정하자.

@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
fun findAllWithMembers(pageable: Pageable): Page<Team>

DISTINCT가 들어간 이유는, DB에서 반환되는 레코드가 Member 수만큼 늘어나고 Team이 중복되기 때문이다. DISTINCT가 없다면 Page의 totalElements, totalPages가 지나치게 많게 잘못 나온다.

위 쿼리를 실행하면 Hibernate의 경고 로그를 확인할 수 있다.

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory!

teams에 members를 JOIN하기 때문에 각 teams 레코드가 중복된 개수를 미리 알 수가 없으므로, 쿼리 레벨에서 LIMIT, OFFSET을 적용하는 것이 무의미하다는 뜻이다. 결국 Hibernate는 LIMIT, OFFSET 없이 쿼리하여 그 결과를 메모리에 올린 후, 메모리에서 직접 페이징 처리를 한다. 데이터가 많을 경우 OOM으로 이어질 수 있다.

그리고 이 문제는 @EntityGraph를 사용해도 동일하게 발생한다. 결국 JOIN 쿼리를 생성하기 때문이다.

5. 공통된 문제: MultipleBagFetchException

Team 코드를 아래와 같이 바꿔보자.

@Entity
@Table(name = "teams")
class Team(
    @get:Id
    @get:GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    @get:Column(name = "team_name")
    var name: String,

    @get:OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    var members: MutableList<Member> = mutableListOf(),

    @get:OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    var projects: MutableList<Project> = mutableListOf()
)

OneToMany 참조가 2개 이상이고, 두 컬렉션의 타입이 모두 List이다. 이 둘을 하나의 JOIN 쿼리로 가져오려 하면 Hibernate에서 MultipleBagFetchException을 throw하여 막는다.

MultipleBagFetchException 이해를 위해서는 2가지 사실이 필요하다.

  1. teams에 members와 products를 모두 JOIN하면, 쿼리 결과로 members와 products의 데카르트곱이 반환된다. 이 말은 members와 products 각각이 중복된다는 뜻이다(실제로는 중복이 아님에도 불구하고).
  2. Hibernate에는 Bag이라는 중복을 허용하는 순서 없는 컬렉션이 있다. Hibernate는 java의 List 타입을 Bag으로 인식한다.

예시를 들어보자.

teams 테이블

idname
1개발팀

members 테이블

idteam_idname
111홍길동
121임꺽정

products 테이블

idteam_idname
211카카오톡

'개발팀'의 members와 products를 둘 다 JOIN해서 가져오면, 결과는 대략 아래와 같다.

teams.IDteams.namemembers.idmembers.nameproducts.idproducts.name
1개발팀11홍길동21카카오톡
1개발팀12임꺽정21카카오톡

products '카카오톡'이 중복되었다. Hibernate는 '카카오톡'이 중복된 것이 JOIN 때문에 잘못 나온 것인지, 아니면 원래 그런 것인지 판별할 방법이 없다. 사실 PK가 중복될 일은 없으니 알아서 중복 제거해도 될 것 같긴 한데, Bag이 중복 허용되는 컬렉션이다 보니 마음대로 중복 제거해버리기도 애매하겠다...

그래서 이런 상황에 MultipleBagFetchException을 발생시켜버린다.

fetch join은 그 자체가 JOIN이니까 말할 것도 없이 MultipleBagFetchException이 발생하고, @EntityGraph도 JOIN 쿼리를 만들어내므로 MultipleBagFetchException이 발생한다. fetch join이나 @EntityGraph로 2개 이상 OneToMany를 로딩하지 않도록 조심하자.

MultipleBagFetchException의 해결 방법으로는 Set 사용(DB에서 데카르트곱이 반환되는 것은 똑같으므로 권장되지 않는 해결법), FetchType.LAZY를 사용하고 2개 이상 OneToMany을 JOIN하지 않기 등이 있다.

별개로 신기했던 점은, 두 OneToMany를 모두 FetchType.EAGER로 선언해두고 부모 엔티티를 조회했을 때의 동작이었다. 두 OneToMany를 모두 JOIN 해버리고 MultipleBagFetchException이 발생할 줄 알았는데, 둘 중 하나만 JOIN하고 나머지는 N+1으로 쿼리되었다. Hibernate의 꼼꼼한 동작을 하나 알게 되었다.

Reference

0개의 댓글