JPA를 사용하다 보면 N+1 문제를 마주하게 된다. 연관된 엔티티를 조회할 때, 처음 쿼리(1) 이후 연관된 엔티티의 수(N) 만큼 추가 쿼리가 발생하는 문제는 애플리케이션의 성능을 저하시킨다.
JPA는 이 문제를 해결하기 위해 fetch join과 EntityGraph를 제공한다. 두 가지 모두 연관 관계를 한 번의 쿼리로 함께 가져와 성능을 최적화하는 것이 목표이다.
우리 회사 코드에서는 fetch join보다 EntityGraph를 많이 사용하고 있어서, 이유에 앞서 그 차이점이 먼저 궁금했다.
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 쿼리로 이어지지는 않는다.
fetch join은 JPQL에서 제공하는 기능으로, 조회하려는 엔티티와 연관된 엔티티를 한 번의 JOIN 쿼리로 가져오도록 명시하는 방법이다.
특징
FetchType이 LAZY이든 EAGER이든 무시하고 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?
}
EntityGraphEntityGraph는 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>
}
먼저 구글링해서 얻은 키워드로는 MultipleBagFetchException 발생 여부, 페이지네이션의 동작 등이 있었다. 그러나 실험한 결과로는 그 부분에서는 다르지 않았다(후술). 그래서 내가 얻은 결론은 '크게 다르지 않고, fetch join의 경우 INNER JOIN 되는 것을 주의해야 한다' 이다.
fetch join은 JOIN에 FETCH 키워드를 붙인 형태이다. JOIN 유형에 대해 별다른 명시가 없다면 INNER JOIN으로 동작한다. 반면 fetch join과 비슷한 usecase를 가지는 @EntityGraph는 연관 엔티티가 없더라도 부모 엔티티는 조회되어야 한다는 관점에서 LEFT JOIN으로 동작하므로, fetch join의 동작을 오해하기 쉽다.
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 쿼리를 생성하기 때문이다.
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가지 사실이 필요하다.
예시를 들어보자.
teams 테이블
| id | name |
|---|---|
| 1 | 개발팀 |
members 테이블
| id | team_id | name |
|---|---|---|
| 11 | 1 | 홍길동 |
| 12 | 1 | 임꺽정 |
products 테이블
| id | team_id | name |
|---|---|---|
| 21 | 1 | 카카오톡 |
'개발팀'의 members와 products를 둘 다 JOIN해서 가져오면, 결과는 대략 아래와 같다.
| teams.ID | teams.name | members.id | members.name | products.id | products.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의 꼼꼼한 동작을 하나 알게 되었다.