JPA 게시글은 대부분 인프런의 김영한님의 강의인 '자바 ORM 표준 JPA 프로그래밍' 기반으로 내용을 정리했습니다.
패치(fetch) 조인 1 - 기본
정말 중요한 부분이므로 집중 필수!
- SQL 조인 종류가 아니고 JPQL의 기능 중 하나이다.
- JPQL에서 성능 최적화를 위해 제공하는 기능
- 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능이다. 두 번 쿼리가 나갈 것을 쿼리가 한 번만 나가도록 하는 기능.
- join fetch 명렁어 사용
- 패치 조인 ::= [LEFT [OUTER] | INNER] JOIN FETCH 조인경로
엔티티 패치 조인
- 회원을 조회하면서 연관된 팀도 함께 조회(SQL 한 번에)
- SQL을 보면 회원뿐만 아니라 팀(T.*)도 함께 SELECT
- JPQL
select m from Member m join fetch m.team
- SQL
SELECT M.* T.* FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID
JPQL로 작성한 코드에서는 Team의 데이터와 테이블을 소환하지 않았지만 SQL에서는 T의 모든 데이터와 Team 테이블을 Member와 join 시킨다. 예제를 보니 즉시 로딩과 비슷하게 생겼다. 둘의 차이점은 패치 조인은 내가 원하는 시점에 원하는 데이터, 객체 그래프를 조회할 거라는 걸 직접 명시적으로 동적인 타이밍에 정할 수 있다는 점이다.
예제
- 회원1과 회원2는 팀1에 소속되어 있다.
- 회원3은 2에 소속되어 있다.
- 회원4는 아무 곳에서도 소속되지 않았다.
- JOIN TEAM에 없는 경우 회원이나 팀C가 null값이기 때문이다.
- 패치 조인을 사용하는 이유는 화면에 뿔리 때 Member 외에 Team의 데이터도 같이 뿌리기 위함이다.
- 연속성 컨텍스트에 다섯개를 보관하고 위 사진과 같은 그림을 만들어서 쿼리 해준다.
@ManyToOne 패치 조인 예제
Team teamA = new Team();
teamA.setName("팀A");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("팀A");
em.persist(teamB);
Member member = new Member();
member.setUsername("회원1");
member.setTeam(teamA);
em.persist(member);
Member member2 = new Member();
member2.setUsername("회원2");
member2.setTeam(teamB);
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 member1 : result) {
System.out.println("member1.getUsername() = " +
member1.getUsername() + ", " + member1.getTeam().getName());
}
위 코드를 실행시켜서 로그를 분석해보자.
Hibernate:
/* select
m
From
Member m */ select
member0_.id as id1_0_,
member0_.age as age2_0_,
member0_.TEAM_ID as TEAM_ID5_0_,
member0_.type as type3_0_,
member0_.username as username4_0_
from
Member member0_
Hibernate:
select
team0_.id as id1_3_0_,
team0_.name as name2_3_0_
from
Team team0_
where
team0_.id=?
member1.getUsername() = 회원1, 팀A
member1.getUsername() = 회원2, 팀A
Hibernate:
select
team0_.id as id1_3_0_,
team0_.name as name2_3_0_
from
Team team0_
where
team0_.id=?
member1.getUsername() = 회원3, 팀B
for문 때문에 select 쿼리가 세 개가 나간 것을 볼 수 있다. Member 클래스에서 team과의 연관관계를 LAZY 즉, 즉시 로딩으로 설정하여 쿼리 세 개가 나간 것이다. 이유는 일단 JPQL에서 지정한 쿼리 때문에 Member에 대한 쿼리를 날린다. 그런데 for문에서 team의 데이터를 가져오기 때문에 그때 다시 Team에 대한 SELECT 쿼리가 날라간다. 회원2가 바로 출력된 이유는 이미 1차 캐시(영속성 컨텍스트)에 팀A에 대한 데이터가 존재하므로 SELECT 쿼리는 날라가지 않고, 마지막 쿼리인 팀B에 대한 데이터는 1차 캐시에 없기 때문에 SELECT 쿼리를 날려 데이터를 가져온다. 그래서 총 세 번의 쿼리가 날라간다.
- 회원1, 팀A → 팀A에 대한 SQL
- 회원2, 팀A → 팀A가 이미 1차 캐시에 있으므로 쿼리 X
- 회원3, 팀B → 팀B는 1차 캐시에 없으므로 쿼리 O
만약 위 회원 데이터가 세 개가 아닌 100개고 각자 다른 팀을 갖고 있다면..? 그럼 Team에 대한 쿼리만 100개가 나갈 것이다. 이런 경우를 N + 1이라 한다. 1은 제일 처음에 나간 Member에 대한 쿼리이고, N은 첫 번째 날린 쿼리의 결과(Result)의 수이다. N+1 문제는 즉시 로딩이던 지연 로딩이던 똑같이 발생하는 문제이다. 해결 방법 중 하나는 바로 지금 배울 패치 조인이다.
String query = "select m From Member m join fetch m.team";
Hibernate:
/* select
m
From
Member m
join
fetch m.team */ select
member0_.id as id1_0_0_,
team1_.id as id1_3_1_,
member0_.age as age2_0_0_,
member0_.TEAM_ID as TEAM_ID5_0_0_,
member0_.type as type3_0_0_,
member0_.username as username4_0_0_,
team1_.name as name2_3_1_
from
Member member0_
inner join
Team team1_
on member0_.TEAM_ID=team1_.id
member1.getUsername() = 회원1, 팀A
member1.getUsername() = 회원2, 팀A
member1.getUsername() = 회원3, 팀B
패치 조인을 사용하면 세 번의 쿼리가 한 번으로 줄어든 걸 볼 수 있다. 예제에서 보았던 for문 안에 member.getTeam()은 지연 로딩에서는 프록시 객체였는데, 패치 조인을 사용하면 프록시가 아니라 엔티티이다. 출력 로그를 보면 SELECT 쿼리에서 이미 Member와 Team을 join 해서 데이터를 가져오기 때문에 예제에서 쿼리를 날려 result에 담는 순간에 Team은 실제 엔티티가 담긴 것이다.
참고 : 지연 로딩으로 설정해도 패치 조인이 우선순위를 가져 패치 조인이 실행된다.
컬렉션 패치 조인
- 일대다 관계, 컬렉션 패치 조인
- JPQL
select t
from Team t join fetch t.members
where t.name = '팀A'
- SQL
SELECT T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'
- 예제
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()
+ " | members = " + team.getMembers().size());
}
Hibernate:
/* select
t
From
Team t
join
fetch t.members */ select
team0_.id as id1_3_0_,
members1_.id as id1_0_1_,
team0_.name as name2_3_0_,
members1_.age as age2_0_1_,
members1_.TEAM_ID as TEAM_ID5_0_1_,
members1_.type as type3_0_1_,
members1_.username as username4_0_1_,
members1_.TEAM_ID as TEAM_ID5_0_0__,
members1_.id as id1_0_0__
from
Team team0_
inner join
Member members1_
on team0_.id=members1_.TEAM_ID
team = 팀A | members = 2
team = 팀A | members = 2 //** 중복
team = 팀B | members = 1
실행했던 결과가 기대와는 다르게 나왔다. 팀A가 중복으로 출력됐는데 이유는 컬렉션의 종특이다. DB 관점에서 일대다 조인을 하면 데이터가 원래 저장되어 있던 수보다 더 많이 나올 수도 있다.
만약 위의 데이터를 join 했을 시엔 일반적으로 결과물이 아래와 같다.
즉 teamA는 하나지만 member가 둘이기 때문에 어쩔 수 없이 두 줄이 출력되는 것이다. 이런 경우에 DB에서 그냥 두 줄을 주는 것이라 JPA 입장에서는 어떤 후처리를 할 수가 없다. 그래서 그냥 일단 DB에서 주는 그대로 가져온다.
영속성 컨텍스트에는 팀A의 아이디가 똑같이 1번이기 때문에 하나로 관리가 된다.
패치 조인과 DISTINCT
- SQL의 DISTINCT는 중복된 결과를 제거하는 명령이다.
- SQL의 DISTINCT만으로는 모든 중복을 제거할 수 없다.
- JPQL의 DISTINCT의 두 가지 기능
- SQL에 DISTINCT를 추가
- 애플리케이션에서 엔티티 중복 제거
- 예제
String query = "select distinct t From Team t join fetch t.members";
List<Team> result= em.createQuery(query, Team.class).getResultList();
System.out.println("result = " + result.size());
Hibernate:
/* select
distinct t
From
Team t
join
fetch t.members */ select
distinct team0_.id as id1_3_0_,
members1_.id as id1_0_1_,
team0_.name as name2_3_0_,
members1_.age as age2_0_1_,
members1_.TEAM_ID as TEAM_ID5_0_1_,
members1_.type as type3_0_1_,
members1_.username as username4_0_1_,
members1_.TEAM_ID as TEAM_ID5_0_0__,
members1_.id as id1_0_0__
from
Team team0_
inner join
Member members1_
on team0_.id=members1_.TEAM_ID
result = 2
DISTINCT를 추가하니 중복이 제거된 걸 볼 수 있다. 이게 SQL에서 중복 제거에 성공한 건 아니다. SQL의 DISTINCT의 경우엔 모든 컬럼의 데이터가 같아야 중복으로 치고 제거해준다. 이 예제에선 팀A는 같지만 member의 값이 다르므로 SQL은 중복이 아니라고 친다. 그러면 어떻게 result는 2가 출력됐을까?
- DISTINCT가 추가로 애플리케이션에서 중복 제거를 시도한다.
- 같은 식별자를 가진 Team 엔티티를 제거한다.
- DISTINCT 추가 시 결과
- teamname = 팀A, team = Team@0x100
- username = 회원1, member = Member@0x200
- username = 회원2, member = Member@0x300
- teamname = 팀A, team = Team@0x100
패치 조인과 일반 조인의 차이
- 일반 조인 실행 시 연관된 엔티티를 함께 조회하지 않음
- JPQL
select t
from Team t join t.members m
where t.name = '팀A'
- SQL
SELECT T.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
WHERE T.NAME = '팀A'
- 예제
String query = "select t From Team t join t.members m";
List<Team> result= em.createQuery(query, Team.class).getResultList();
for (Team team : result) {
System.out.println("team = " + team.getName()
+ " | members = " + team.getMembers().size());
}
일반 Join으로 코드를 수정하고 실행해보자.
Hibernate:
/* select
t
From
Team t
join
t.members m */ select
team0_.id as id1_3_,
team0_.name as name2_3_
from
Team team0_
inner join
Member members1_
on team0_.id=members1_.TEAM_ID
Hibernate:
select
members0_.TEAM_ID as TEAM_ID5_0_0_,
members0_.id as id1_0_0_,
members0_.id as id1_0_1_,
members0_.age as age2_0_1_,
members0_.TEAM_ID as TEAM_ID5_0_1_,
members0_.type as type3_0_1_,
members0_.username as username4_0_1_
from
Member members0_
where
members0_.TEAM_ID=?
team = 팀A | members = 2
team = 팀A | members = 2
Hibernate:
select
members0_.TEAM_ID as TEAM_ID5_0_0_,
members0_.id as id1_0_0_,
members0_.id as id1_0_1_,
members0_.age as age2_0_1_,
members0_.TEAM_ID as TEAM_ID5_0_1_,
members0_.type as type3_0_1_,
members0_.username as username4_0_1_
from
Member members0_
where
members0_.TEAM_ID=?
team = 팀B | members = 1
놀랍게도 세 번의 쿼리가 발생한다. 그리고 첫 번째 SELECT를 잘 보면 Member와 Join은 하지만 컬럼에 team에 대한 값들만 가져오는 걸 볼 수 있다. 그리고 컬렉션은 컬렉션은 아니지만 컬렉션의 데이터가 로딩 시점에 로딩이 다 되지 않아 SELECT 쿼리가 세 번이 나간 것이다.
패치 조인과 일반 조인의 차이(이어서..)
- JPQL은 결과를 반환할 때 연관관계를 고려하지 않는다.
- 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다.
- 여기서는 팀 엔티티만 조회하고, 회원 엔티티는 조회하지 않는다.
- 패치 조인을 사용할 때만 연관된 엔티티도 함께 조회(즉시 로딩)
- 패치 조인은 객체 그래프를 SQL 한 번에 조회하는 개념이다.