Fetch Join?
- SQL에서 이야기하는 조인의 종류가 아니라 JPQL에서 성능 최적화를 위해 제공하는 기능
- 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능으로, join fetch 명령어로 사용할 수 있다.
예제를 진행하기에 앞서 사용할 클래스는 다음과 같다.
@Entity
@Table(name = "members")
public class Member{
@Id
@Column(name= "member_id")
private Long id;
@Column
private String name;
@ManyToOne(fetch = FetchType.Lazy) // 테스트를 위해 지연 로딩을 사용한다.
@JoinColumn(name="team_id")
private Team team;
}
@Entity
@Table(name = "teams")
public class Team{
@Id
@Column(name="team_id")
private Long id;
@Column
private String name;
@OneToMany(mappedBy="team")
private List<Member> members;
}
Member와 Team은 N:1로 다대일 관계에 있다.
1. 엔티티 Fetch join
회원을 조회할 때, 연관된 팀을 함께 조회한다고 가정해보자.
그렇다면 두 테이블을 조회하기 위해서 join을 사용하게 될 것이고, 일반적으로 작성하는 JPQL은 다음과 같을 것이다.
select m from Member as m join m.team t
위처럼 작성한 JPQL은 select m.* from members as m inner join teams as t on m.team_id=t.team_id 로 바뀌어 실행되는데, 이때 조회 대상을 보면 m.*로써 Member 테이블에 해당하는 데이터만 조회하는 것을 볼 수 있다.
따라서 JPQL에서 회원과 연관된 팀을 조인한다고 해서 그 결과로 회원과 팀의 데이터가 조회될 것을 기대하면 안 된다.
(물론 select 문에 지정한 데이터가 m이기 때문에 당연한 결과라고 생각할 수 있다.)
실제로 위처럼 동작하는지 테스트 코드를 작성해서 확인해보자.
@Test
public void JPQL_기본_join_테스트() {
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin();
Team team = new Team();
team.setId(1L);
team.setName("팀1");
em.persist(team);
Member member = new Member();
member.setId(1L);
member.setName("회원1");
member.setTeam(team);
em.persist(member);
transaction.commit();
em.clear() // 캐시 초기화
String jpql = "select m from Member as m join m.team t";
TypedQuery<Member> query = em.createQuery(jpql, Member.class);
List<Member> resultList = query.getResultList();
Assertions.assertThat(resultList).isNotEmpty();
}
// 결과 - insert 문은 제외
...
Hibernate:
select
member0_.member_id as member_i1_3_,
member0_.name as name2_3_,
member0_.team_id as team_id3_3_
from
members2 member0_
inner join
teams team1_
on member0_.team_id=team1_.team_id
따라서 위처럼 조회해서 나온 결과로 팀을 조회한다면, ManyToOne에 설정한 fetch 전략(Lazy)에 따라 지연 로딩이 발생하여 추가 쿼리가 발생할 것이다.
// ... 앞서 작성한 테스트 코드 마지막에 다음 코드를 추가해보자
for (Member m : resultList) {
log.info("m.getTeam().getName()을 호출하면 추가 쿼리가 발생한다.");
log.info("m.getTeam().getName(): {}", m.getTeam().getName());
}
// 결과
2022-04-03 19:24:12.825 INFO 5871 --- [ main] com.example.demo.jpaTests.JpaTests : m.getTeam().getName()을 호출하면 추가 쿼리가 발생한다.
Hibernate:
select
team0_.team_id as team_id1_6_0_,
team0_.name as name2_6_0_
from
teams team0_
where
team0_.team_id=?
2022-04-03 19:24:12.847 INFO 5871 --- [ main] com.example.demo.jpaTests.JpaTests : m.getTeam().getName(): 팀1
예상한 대로 팀을 조회할 때, 추가 쿼리가 발생했다.
이번에는 fetch join을 사용해서 결과를 확인해보자.
fetch join은 앞서 작성한 jpql 쿼리문의 join 뒤에 fetch만 추가로 작성하기만 하면 된다.
select m from Member as m join fetch m.team t
추가로 테스트 코드를 작성해서 다시 한번 확인해보자
@Test
public void JPQL_fetch_join_테스트() {
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin();
Team team = new Team();
team.setId(1L);
team.setName("팀1");
em.persist(team);
Member member = new Member();
member.setId(1L);
member.setName("회원1");
member.setTeam(team);
em.persist(member);
transaction.commit();
em.clear();
// jpql 쿼리문만 변경해주면 된다.
String jpql = "select m from Member as m join fetch m.team t";
TypedQuery<Member> query = em.createQuery(jpql, Member.class);
List<Member> resultList = query.getResultList();
Assertions.assertThat(resultList).isNotEmpty();
}
// 실행되는 쿼리는 다음과 같다.
Hibernate:
select
member0_.member_id as member_i1_3_0_,
team1_.team_id as team_id1_6_1_,
member0_.name as name2_3_0_,
member0_.team_id as team_id3_3_0_,
team1_.name as name2_6_1_
from
members2 member0_
inner join
teams team1_
on member0_.team_id=team1_.team_id
JPQL에서 select는 m으로 회원의 엔티티만 선택했지만, 실행된 SQL을 보면 이전과는 다르게 연관된 팀까지 함께 조회하는 것을 확인할 수 있다.
또한 연관된 팀을 조회하는 코드를 추가한다고 하더라도 조회 당시에 팀까지 같이 조회가 되었으므로, 추가 쿼리는 발생하지 않는다.
@Test
public void JPQL_fetch_join_확인() {
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin();
Team team = new Team();
team.setId(1L);
team.setName("팀1");
em.persist(team);
Member member = new Member();
member.setId(1L);
member.setName("회원1");
member.setTeam(team);
em.persist(member);
transaction.commit();
em.clear();
String jpql = "select m from Member as m join fetch m.team t";
TypedQuery<Member> query = em.createQuery(jpql, Member.class);
List<Member> resultList = query.getResultList();
Assertions.assertThat(resultList).isNotEmpty();
for (Member m : resultList) {
log.info("m.getTeam().getName()을 호출해도 추가 쿼리가 발생하지 않는다.");
log.info("m.getTeam().getName(): {}", m.getTeam().getName());
}
}
// 결과
Hibernate:
select
member0_.member_id as member_i1_3_0_,
team1_.team_id as team_id1_6_1_,
member0_.name as name2_3_0_,
member0_.team_id as team_id3_3_0_,
team1_.name as name2_6_1_
from
members2 member0_
inner join
teams team1_
on member0_.team_id=team1_.team_id
2022-04-03 19:27:46.023 INFO 6120 --- [ main] com.example.demo.jpaTests.JpaTests : m.getTeam().getName()을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:27:46.023 INFO 6120 --- [ main] com.example.demo.jpaTests.JpaTests : m.getTeam().getName(): 팀1
즉, 회원을 조회할 때 fetch join을 사용하여 연관된 팀도 함께 조회되었으므로, 팀 엔티티는 프록시가 아니라 실제 엔티티이다.
따라서 회원 엔티티가 영속성 콘텍스트에서 detach된다고 하더라도 연관된 팀은 여전히 조회할 수 있다.
정말로 그런지 테스트 코드를 작성해서 확인해보자
@Test
public void JPQL_fetch_join_detach후_조회_테스트() {
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin();
Team team = new Team();
team.setId(1L);
team.setName("팀1");
em.persist(team);
Member member = new Member();
member.setId(1L);
member.setName("회원1");
member.setTeam(team);
em.persist(member);
transaction.commit();
em.clear();
String jpql = "select m from Member as m join fetch m.team t";
TypedQuery<Member> query = em.createQuery(jpql, Member.class);
List<Member> resultList = query.getResultList();
assertThat(resultList).isNotEmpty();
for (Member m : resultList) {
log.info("멤버를 준영속 상태로 만든다.");
em.detach(m);
log.info("준영속 상태로 만든 멤버를 사용하여 m.getTeam().getName()을 호출해도 추가 쿼리가 발생하지 않고 오류 또한 발생하지 않는다.");
org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> {
log.info("m.getTeam().getName(): {}", m.getTeam().getName());
});
}
}
// 준영속 이후 조회 결과
Hibernate:
select
member0_.member_id as member_i1_3_0_,
team1_.team_id as team_id1_6_1_,
member0_.name as name2_3_0_,
member0_.team_id as team_id3_3_0_,
team1_.name as name2_6_1_
from
members2 member0_
inner join
teams team1_
on member0_.team_id=team1_.team_id
2022-04-03 19:34:31.021 INFO 6564 --- [ main] com.example.demo.jpaTests.JpaTests : 멤버를 준영속 상태로 만든다.
2022-04-03 19:34:31.025 INFO 6564 --- [ main] com.example.demo.jpaTests.JpaTests : 준영속 상태로 만든 멤버를 사용하여 m.getTeam().getName()을 호출해도 추가 쿼리가 발생하지 않고 오류 또한 발생하지 않는다.
2022-04-03 19:34:31.035 INFO 6564 --- [ main] com.example.demo.jpaTests.JpaTests : m.getTeam().getName(): 팀1
준영속 상태에서 연관된 팀을 조회하더라도 오류와 추가 쿼리 없이 팀을 조회하는 모습을 확인할 수 있다.
2. 컬렉션 Fetch join
이번에는 반대로 팀에서 멤버를 조회하는 일대다 관계에서 Fetch join을 해보자
jpql은 다음과 같이 작성하면 된다.
select t from Team as t join fetch t.members m
@Test
public void OneToMany_fetch_join_테스트() {
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin();
Team team = new Team();
team.setId(1L);
team.setName("팀1");
em.persist(team);
for (int i = 1; i <= 10; i++) {
Member member = new Member();
member.setId((long) i);
member.setName(String.format("멤버%s", i));
member.setTeam(team);
em.persist(member);
}
transaction.commit();
em.clear();
String jpql = "select t from Team as t join fetch t.members m";
TypedQuery<Team> query = em.createQuery(jpql, Team.class);
List<Team> resultList = query.getResultList();
for (Team t : resultList) {
List<Member> members = t.getMembers();
for (Member m : members) {
log.info("m.getName을 호출해도 추가 쿼리가 발생하지 않는다.");
log.info("m.getName: {}", m.getName());
}
}
}
// 결과
Hibernate:
select
team0_.team_id as team_id1_6_0_,
members1_.member_id as member_i1_3_1_,
team0_.name as name2_6_0_,
members1_.name as name2_3_1_,
members1_.team_id as team_id3_3_1_,
members1_.team_id as team_id3_3_0__,
members1_.member_id as member_i1_3_0__
from
teams team0_
inner join
members2 members1_
on team0_.team_id=members1_.team_id
2022-04-03 19:42:47.182 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.182 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버1
2022-04-03 19:42:47.182 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.182 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버2
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버3
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버4
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버5
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버6
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버7
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버8
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버9
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183 INFO 7103 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버10
...
// 수많은 로그들이 출력되었다. 왤까?
SQL 실행 결과를 보면 팀과 연관된 회원을 같이 select 하는 모습을 확인할 수 있다.
그러나 조회된 팀을 순회하면서 멤버를 조회하면, 생각했던 것보다 많은 양의 로그가 출력되는 것을 볼 수 있다.
그 이유는, 일대다 조인으로 인해 row의 수가 증가했기 때문이다.
팀1에 소속되어있는 회원들은 10명으로, 팀 join 회원을 하게 되면 아마 다음과 같은 결과가 나올 것이다.
id | team_name | member_id | team_id(fk) | member_name |
1 | 팀1 | 1 | 1 | 멤버1 |
1 | 팀1 | 2 | 1 | 멤버2 |
1 | 팀1 | 3 | 1 | 멤버3 |
... | ||||
1 | 팀1 | 10 | 1 | 멤버10 |
즉, 팀1이 중복되어 10개가 나온 것이다. 그렇다면 중복된 데이터는 어떻게 제거할 수 있을까?
fetch join과 distinct
SQL에서 distinct는 중복된 결과를 제거한다. 그러나 위의 표에서 조회된 row들은 완전히 동일하지 않기 때문에 효과가 없다.
하지만 JPQL에서 distinct를 사용하면 SQL에 distinct를 추가하는 것은 물론이고 애플리케이션에서 한번 더 중복을 제거한다.
정말로 중복된 데이터를 제거하는지 테스트 코드를 작성해서 확인해보자
jpql은 다음과 같이 작성하면 된다. select distinct t from Team as t join fetch t.members m
@Test
public void OneToMany_fetch_join_distinct_테스트() {
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin();
Team team = new Team();
team.setId(1L);
team.setName("팀1");
em.persist(team);
for (int i = 1; i <= 10; i++) {
Member member = new Member();
member.setId((long) i);
member.setName(String.format("멤버%s", i));
member.setTeam(team);
em.persist(member);
}
transaction.commit();
em.clear();
String jpql = "select distinct t from Team as t join fetch t.members m";
TypedQuery<Team> query = em.createQuery(jpql, Team.class);
List<Team> resultList = query.getResultList();
// 중복 데이터가 제거될 것을 기대
assertThat(resultList.size()).isEqualTo(1);
for (Team t : resultList) {
List<Member> members = t.getMembers();
for (Member m : members) {
log.info("m.getName을 호출해도 추가 쿼리가 발생하지 않는다.");
log.info("m.getName: {}", m.getName());
}
}
}
// 결과
Hibernate:
select
distinct team0_.team_id as team_id1_6_0_,
members1_.member_id as member_i1_3_1_,
team0_.name as name2_6_0_,
members1_.name as name2_3_1_,
members1_.team_id as team_id3_3_1_,
members1_.team_id as team_id3_3_0__,
members1_.member_id as member_i1_3_0__
from
teams team0_
inner join
members2 members1_
on team0_.team_id=members1_.team_id
2022-04-03 20:00:17.366 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.366 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버1
2022-04-03 20:00:17.367 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.367 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버2
2022-04-03 20:00:17.367 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.367 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버3
2022-04-03 20:00:17.367 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.367 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버4
2022-04-03 20:00:17.367 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.367 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버5
2022-04-03 20:00:17.368 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.368 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버6
2022-04-03 20:00:17.368 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.368 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버7
2022-04-03 20:00:17.368 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.368 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버8
2022-04-03 20:00:17.368 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.368 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버9
2022-04-03 20:00:17.368 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.370 INFO 8215 --- [ main] com.example.demo.jpaTests.JpaTests : m.getName: 멤버10
실제로 실행되는 SQL 쿼리에 distinct가 추가되었고, 조회된 팀의 개수도 10개가 아니라 1개인 것을 확인할 수 있다.
Fetch Join의 한계
Fetch join을 사용하면 연관된 데이터를 함께 조회할 수 있기 때문에, SQL 호출 횟수를 줄여 성능을 최적화할 수 있다.
그러나 다음과 같은 한계가 존재한다.
- 별칭을 줄 수 없다.
- 둘 이상의 컬렉션을 fetch 할 수 없다.
- 컬렉션을 fetch join 하면 페이징을 사용할 수 없다.
컬렉션을 fetch join 하면 페이징을 사용할 수 없다. 이 부분은 다음에 따로 정리해보려고 한다.
오늘은 여기까지~ 그럼 모두들 안뇽~
참고
- 자바 ORM 표준 JPA 프로그래밍
- http://www.kyobobook.co.kr/product/detailViewKor.laf?mallGb=KOR&ejkGb=KOR&barcode=9788960777330
'개발' 카테고리의 다른 글
[K8S] Node Affinity 간단하게 사용해보기 (2) | 2024.01.30 |
---|---|
[RabbitMQ] - 3. Spring boot로 Work Queue에 다수의 consumer를 등록하여 task 처리하기 (1) | 2024.01.14 |
[RabbitMQ] - 2. Spring boot로 RabbitMQ 사용하기(hello world!) (3) | 2021.11.30 |
[RabbitMQ] - 1. Mac에서 RabbitMQ 설치하기 (3) | 2021.11.24 |
CentOS7 MySQL 포트 변경하며 생긴 이슈 (2) | 2021.05.07 |