JPQl에서 성능 최적화를 위해 제공하는 기능으로, SQL의 조인과는 다르다.
Fetch Join을 사용하면 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회가 가능하다.
즉, 페치 조인은 성능 최적화에 주로 사용되며, N+1 문제를 해결하는 데 효과적이다.
Fetch Join이 필요한 예시를 살펴보자.
이전에 살펴봤던 Member(회원) 엔티티와 Team(팀) 엔티티가 있다.
해당 엔티티간의 관계는
회원:팀의 관계가 N:1이고, 연관관계는 지연 로딩 방식으로 구현되어있다.
Team teamA = new Team();
teamA.setName("팀A");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("팀B");
em.persist(teamB);
Member member1 = new Member();
member1.setUsername("회원1");
member1.setTeam(teamA);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("회원2");
member2.setTeam(teamA);
em.persist(member2);
Member member3 = new Member();
member3.setUsername("회원3");
member3.setTeam(teamB);
em.persist(member3);
em.flush();
em.clear();
String query = "select m from Member m";
List<Member> result = em.createQuery(query, Member.class)
.getResultList();
for (Member member : result) {
System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
}
위의 코드를 살펴 볼 때,
연관관계가 지연 로딩으로 구현되어 있으므로
List<Member> result = em.createQuery(query, Member.class)
.getResultList();
해당 코드를 실행할 때 1번의 쿼리가 나가게된다.
for (Member member : result) {
System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
}
그리고 위의 코드에서 member.getTeam.getName() 을 호출 할 때, 해당 Member의 Team을 조회하는 쿼리가 날라가게 된다. 쿼리는 아래와 같다.
Hibernate:
/* select
m
from
Member m */ select
member0_.MEMBER_ID as member_i1_5_,
member0_.age as age2_5_,
member0_.TEAM_ID as team_id4_5_,
member0_.username as username3_5_
from
Member member0_
Hibernate:
select
team0_.TEAM_ID as team_id1_11_0_,
team0_.createdBy as createdb2_11_0_,
team0_.createdDate as createdd3_11_0_,
team0_.lastModifiedBy as lastmodi4_11_0_,
team0_.lastModifiedDate as lastmodi5_11_0_,
team0_.name as name6_11_0_
from
Team team0_
where
team0_.TEAM_ID=?
member = 회원1, 팀A
member = 회원2, 팀A
Hibernate:
select
team0_.TEAM_ID as team_id1_11_0_,
team0_.createdBy as createdb2_11_0_,
team0_.createdDate as createdd3_11_0_,
team0_.lastModifiedBy as lastmodi4_11_0_,
team0_.lastModifiedDate as lastmodi5_11_0_,
team0_.name as name6_11_0_
from
Team team0_
where
team0_.TEAM_ID=?
member = 회원3, 팀B
회원2의 경우에는 팀A가 회원1에 의해서 이미 영속성 컨텍스트의 1차캐시에 올라갔으므로 조회하는 쿼리를 발생시키지 않는다.
위의 경우에는 처음에 회원을 조회하는 쿼리 1번, N명 회원의 팀을 조회하는 쿼리 1번해서 총 1+N번의 쿼리가 나가게 되어 N+1 문제가 발생하므로, 성능상 매우 좋지 않다.
해당 문제를 해결하기 위해 fetch join(페치 조인)을 사용해보자.
em.flush();
em.clear();
String query = "select m from Member m join fetch m.team";
List<Member> result = em.createQuery(query, Member.class)
.getResultList();
for (Member member : result) {
System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
}
tx.commit();
위의 코드에서 jpql 페치 조인을 사용하였다.
Hibernate:
/* select
m
from
Member m
join
fetch m.team */ select
member0_.MEMBER_ID as member_i1_5_0_,
team1_.TEAM_ID as team_id1_11_1_,
member0_.age as age2_5_0_,
member0_.TEAM_ID as team_id4_5_0_,
member0_.username as username3_5_0_,
team1_.createdBy as createdb2_11_1_,
team1_.createdDate as createdd3_11_1_,
team1_.lastModifiedBy as lastmodi4_11_1_,
team1_.lastModifiedDate as lastmodi5_11_1_,
team1_.name as name6_11_1_
from
Member member0_
inner join
Team team1_
on member0_.TEAM_ID=team1_.TEAM_ID
member = 회원1, 팀A
member = 회원2, 팀A
member = 회원3, 팀B
페치 조인을 사용하였을 때, 쿼리문이 1번만 나가고, 회원 엔티티와 팀 엔티티를 모두 조회한 것을 확인할 수 있다.
위의 쿼리 결과는 아래와 같다.
일대다 관계에서 컬렉션을 페치 조인하면 어떻게 될지 살펴보자.
String query = "select t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
.getResultList();
for (Team team : result) {
System.out.println("team = " + team.getName() + "|" + team.getMembers().size());
}
tx.commit();
Hibernate:
/* select
t
from
Team t
join
fetch t.members */ select
team0_.TEAM_ID as team_id1_11_0_,
members1_.MEMBER_ID as member_i1_5_1_,
team0_.createdBy as createdb2_11_0_,
team0_.createdDate as createdd3_11_0_,
team0_.lastModifiedBy as lastmodi4_11_0_,
team0_.lastModifiedDate as lastmodi5_11_0_,
team0_.name as name6_11_0_,
members1_.age as age2_5_1_,
members1_.TEAM_ID as team_id4_5_1_,
members1_.username as username3_5_1_,
members1_.TEAM_ID as team_id4_5_0__,
members1_.MEMBER_ID as member_i1_5_0__
from
Team team0_
inner join
Member members1_
on team0_.TEAM_ID=members1_.TEAM_ID
team = 팀A|2
team = 팀A|2
team = 팀B|1
위의 결과를 보면 이상한 점이 있다. 팀A가 2번 출력되었다.
해당 fetch join 결과를 보면 아래와 같다.
팀A의 회원이 2명 이기 때문에, 팀A의 결과가 2 Row가 나온다. 이것을 해결하기위해 DISTINCT를 사용해보자.
JPQL에서 DISTINCT를 사용하게 되면 SQL에 DISTINCT를 추가하고, 엔티티의 중복 제거를 동시에 수행한다.
위의 회원과 팀 예시에서 같은 식별자를 가진 Team 엔티티를 삭제한다고 보면 된다.
String query = "select distinct t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
.getResultList();
for (Team team : result) {
System.out.println("team = " + team.getName() + "|" + team.getMembers().size());
}
Hibernate:
/* select
distinct t
from
Team t
join
fetch t.members */ select
distinct team0_.TEAM_ID as team_id1_11_0_,
members1_.MEMBER_ID as member_i1_5_1_,
team0_.createdBy as createdb2_11_0_,
team0_.createdDate as createdd3_11_0_,
team0_.lastModifiedBy as lastmodi4_11_0_,
team0_.lastModifiedDate as lastmodi5_11_0_,
team0_.name as name6_11_0_,
members1_.age as age2_5_1_,
members1_.TEAM_ID as team_id4_5_1_,
members1_.username as username3_5_1_,
members1_.TEAM_ID as team_id4_5_0__,
members1_.MEMBER_ID as member_i1_5_0__
from
Team team0_
inner join
Member members1_
on team0_.TEAM_ID=members1_.TEAM_ID
team = 팀A|2
team = 팀B|1
해당 결과를 보면 중복이 제거된 것을 확인할 수 있다.
하지만 컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버린다.
이 것을 해결하기 위해선 어떻게 할까?
대부분의 페이징 + 컬렉션 엔티티 조회 문제를 해결할 방법을 알아보자.
Order 엔티티를 보면 Member와 N:1, OrderItem 리스트와 1:N, Delivery와 1:1 관계가 있다.
Order 엔티티 기준 Member 엔티티와 N:1 관계, Delivery 엔티티와 1:1 관계 이므로 페치조인을 했다.
위는 쿼리이다. offset과 limit가 걸려있어 페이징이 된 쿼리가 날라가는 것을 볼 수 있다.
위의 컨트롤러의 메소드를 보면 Order 엔티티를 OrderDto로 변환하는데,
OrderDto는 OrderItem 리스트를 호출해서 지연로딩을 하게 된다. 이때 쿼리가 발생한다.
하지만 기존 쿼리와는 다르게 where문에 IN 쿼리에 총 두건의 Order에 대한 orderItems를 다 끌고온다.
또한 아래를 보면 OrderItemDto에서도 orderItem의 getItem을 호출하므로 Item 엔티티의 지연로딩도 발생한다.
2개의 OrderItems에 2개씩 Item이 있기때문에 총 4개의 IN 쿼리문이 발생한다.
위의 방식은 application.yml의 hibernate.default_batch_fetch_size를 통해 가능하다.
페치 조인에 대해 알아보았다.
페치 조인을 사용하여 조회 쿼리의 갯수를 줄여서 N+1 같은 문제의 성능이슈를 해결 할 수 있을 것 같다.