JPA로 개발할 때 가장 중요한 개념 중 하나이다. 페치 조인(Fetch join)
을 알아야 JPA의 성능 최적화를 시도해볼 수 있게 된다. 페치 조인에 대해 간단히 알아보면서 페치 조인을 사용하지 않으면 어떤 문제가 일어나는지 살펴본다.
페치 조인에 대해 간단히 정리해보면 다음과 같다.
join fetch
명령어로 사용한다.회원과 팀 엔티티가 있고, 회원:팀의 관계가 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());
}
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
우선 지연로딩
방식으로 구현되어 있기 때문에, Member
엔티티를 조회하더라도 Team
엔티티는 프록시로 조회하게 된다. 그리고 Team
엔티티의 필드에 접근할 때 실제로 SQL문이 나가서 DB에 접근하게 된다.
회원1 : DB (지연로딩)
회원2 : 1차캐시 (영속성 컨텍스트)
회원3 : DB (지연로딩)
팀A
를 조회하면서 영속성 컨텍스트에 팀A
를 저장해둔다.팀B
는 영속성 컨텍스트에 존재하지 않아서, DB로 쿼리문을 날리게 된다.결론적으로, 쿼리가 총 3번 나가게 되었다. 이렇게 되면 관련된 엔티티의 데이터 개수만큼 쿼리가 나가서 의도치 않은 성능 저하를 야기할 수 있다.
이 문제가 바로 N+1 문제
이다. N+1 문제
를 해결하기 위해 페치 조인을 사용해야 한다.
// ..
// Member, Team 세팅
// ..
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();
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
Member
엔티티들을 모두 조회하면서 한방쿼리로 연관된 엔티티인 Team
엔티티까지 조회하게 됐다. 즉, 페치 조인(Fetch join)
을 이용해N+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번 조회되지??
이것이 컬렉션 페치 조인에서 주의해야할 점이다! 일대다 조인은 뻥튀기(?)되는 문제가 발생할 수 있다. 즉, 같은 데이터가 중복 조회되는 문제가 있으니 주의해야한다.
일대다 조인에서의 중복 조회
아래의 그림처럼 조회되는 것이다. 실제
팀A
데이터는 하나지만, 조회할 때Member
엔티티와 조인되므로 중복 조회되는 문제가 발생하는 것이다. 실제 DB에서의 조인 실행 결과는 "[TEAM JOIN MEMBER]"와 같다.
즉 Member 의 데이터가 다르게 조회됨으로써 다른 로우가 되지만, JPA에서 전체 필드가 아닌 부분적으로 조회한 결과는 같은 결과를 가지는 로우가 2개가 돼서, 중복 조회되는 것이다.
중복조회되는 문제를 해결하려면, DISTINCT
명령어를 활용해서 중복 로우를 제거해주면 된다.
하지만 위의 그림처럼 SQL을 실행했을 때, 서로가 다른 결과라고 하면 DISTINCT
명령어를 써도 제거되지 않는다.
즉, SQL에 DISTINCT
를 추가해도, 데이터가 다르므로 SQL 결과에서 중복 조회를 제거하는 데에 실패한다.
그래서, JPA에서는 DISTINCT
가 DB에서 뿐만 아니라, 애플리케이션 레벨에서도 중복 제거를 시도한다.
즉, 같은 식별자를 가진 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
정리하면, JPQL에서의 DISTINCT 명령어는 다음의 2가지 기능을 가진다.
일반 조인을 실행할 때에는, 연관된 엔티티를 함께 조회하지 않는다. 하지만, 페치 조인을 실행하면 연관된 엔티티도 조회하게 된다.
무슨 말이냐하면, 페치 조인을 하게되면 연관된 엔티티의 로우에 대한 정보도 가져오게 되지만, 일반 조인을 하게 되면 select 절에 명시한 테이블 혹은 로우에 대한 정보만 가져오게 된다. 아래의 예시는 페치 조인과 일반 조인의 sql문이다. 차이를 확인해보시기 바랍니다.
[일반 조인]
select m from Member m join m.team
[SQL문]
select m.id, m.name, m.email from Member m
inner join Team t on m.team_id = t.id
[페치조인]
select m from Member m join fetch m.team
[SQL문]
select m.id, m.name, m.email, t.id, t.name from Member m
inner join Team t on m.team_id = t.id
select 절에서 가져오는 데이터가 다르다! 그렇기에 페치 조인을 활용하게 되면 지연 로딩으로 연관관계가 설정되어 있어도, 일반 조인과는 달리 다대일 관계 혹은 일대일 관계의 객체가 초기화될 수 있는 것이다!
JPQL은 결과를 반환할 때 연관관계를 고려하지 않는다. 단지 Select 절에 지정된 엔티티만 조회할 뿐이다. 바로 위의 예제에서 일반 조인으로 실행하게 되면 Team 엔티티만 조회하게 되고, Member 엔티티는 조회하지 않는다.
다만, Fetch join 을 할 때에만 연관된 엔티티도 함께 조회한다(즉시 로딩). 페치 조인은 객체 그래프를 SQL 한 번에 조회하는 개념이다.
하이버네이트는 가능하지만, 가급적 사용하지 않는 것이 좋다. 페치 조인은 조회하려는 테이블과 연관된 객체를 모두 다 가져오는 것이다.
만약 팀A인 member들 중 10살 이상인 member들을 조회한다고 할 경우에 문제가 생길 수 있다. Cascade 옵션
이나 orphanRemoval 옵션
설정을 통해 컬렉션에 들어있는 값들만으로 어플리케이션의 객체와 DB의 상태 일관성이 깨질 수 있다.
상태 일관성이 깨질 수 있다??
이 말의 의미는, 객체에서의 상태와 DB의 상태가 다르다는 것을 의미한다. 즉,
fetch join
을 통해 Team에 속한 member 리스트를 가져올 때 어플리케이션에서는 member 가 1명이라고 나오지만, 실제 DB에서는 해당 팀에 속하는 member가 2명이면 상태 일관성이 깨진 것이다.
JPA에서 Fetch join은 연관된 모든 엔티티를 가져온다고 가정하고 설계되어 있고 그것을 권장한다.
하지만, 페치 조인 대상이 아닌 Team 에 대한 where 절 사용은 아무런 문제가 없다!
결론적으로, fetch join의 대상은 on, where 등에서 필터링 조건으로 사용하면 안된다.
왜 사용하면 안되는지 예시를 들며 살펴보자.
@Test
public void contextLoads() {
Team team = new Team();
team.setName("teamA");
em.persist(team);
Member member1 = new Member();
member1.setUsername("m1");
member1.setTeam(team);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("m2");
member2.setTeam(team);
em.persist(member2);
em.flush();
em.clear();
List<Team> result = em.createQuery("select t from Team t join fetch t.members m where m.username = 'm1'", Team.class)
.getResultList();
for (Team team1 : result) {
System.out.println("team1 = " + team1.getName());
List<Member> members = team1.getMembers();
for (Member member : members) {
System.out.println("member = " + member.getUsername());
}
}
}
// 실행결과
team1 = teamA
member = m1
Team 엔티티에 컬렉션 타입의 변수가 하나 더 있다고 가정할 때, 둘 이상의 컬렉션에 대해서도 페치 조인을 하게 되면 일대다대다 관계
가 되므로 문제가 생길 수 있다.
컬렉션에 대해 페치 조인은 딱 하나만 지정할 수 있다.
일대일, 다대일과 같은 단일 값 연관관계에서는 페치 조인해도 페이징이 가능하다.
일대다 관계에서는 중복 조회되는 문제(aka 뻥튀기?)가 있어서 이걸 페이징 처리하게 되면 의도한 결과가 나오지 않을 수 있다.
자바 ORM 표준 JPA 프로그래밍
https://www.inflearn.com/questions/15876
https://www.inflearn.com/questions/59632
https://yjksw.github.io/jpa-fetch-join-nickname/
아니 강의에 있는 내용을 소스 코드랑 같이 올리면 저작권 걸리는 거 아닌가요? ㄷㄷ