이번 글에서는 JPQL의 join과 fetch join에 대해 알아보자.
아래는 이번 글에서 사용할 엔티티들이다.
- Member(N)와 TeamForEager(1)는 다대일 단방향 연관관계를 가진다.
- Member(N)와 TeamForLazy(1)는 다대일 및 일대다 양방향 연관관계를 가진다.
- TeamForEager와 TeamForLazy는 FetchType.EAGER와 FetchType.Lazy에 따른 동작 차이를 확인하기 위해 구분된 엔티티이며, 도메인 측면의 의미는 동일하다.
@Getter
@NoArgsConstructor
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private Integer age;
@ManyToOne(fetch = FetchType.EAGER)
private TeamForEager teamForEager;
@ManyToOne(fetch = FetchType.LAZY)
private TeamForLazy teamForLazy;
@Builder
public Member(Long id, String username, Integer age, TeamForEager teamForEager,
TeamForLazy teamForLazy) {
this.id = id;
this.username = username;
this.age = age;
this.teamForEager = teamForEager;
this.teamForLazy = teamForLazy;
}
}
///////////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////
@Getter
@NoArgsConstructor
@Entity
public class TeamForEager {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Builder
public TeamForEager(Long id, String name) {
this.id = id;
this.name = name;
}
}
///////////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////
@Getter
@NoArgsConstructor
@Entity
public class TeamForLazy {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "teamForLazy")
private List<Member> members = new ArrayList<>();
@Builder
public TeamForLazy(Long id, String name, List<Member> members) {
this.id = id;
this.name = name;
this.members = members;
}
}
JOIN
JPQL에서 내부 조인과 외부 조인은 다음과 같이 작성할 수 있다.
[...]는 생략 가능한 키워드이다.
- 내부 조인: select m from Member m [inner] join m.teamXX t
- 외부 조인: select m from m left [outer] join m.teamXX t
1. 글로벌 로딩 전략 EAGER
- 사용된 JPQL은 "select m from Member m join m.teamForEager"이다.
- Member와 Team에 대한 select ~ join 쿼리가 실행된다. 아직 Member만 실제 객체로 조회된 상황이다. Member 객체의 TeamForEager 참조 객체는 프록시 객체이다.
- 즉시 모든 Member 객체의 TeamForEager 참조 객체에 대한 select 쿼리가 각각 실행된다.
2. 글로벌 로딩 전략 LAZY
- 사용된 JPQL은 "select m from Member m join m.teamForLazy"이다.
- Member와 Team에 대한 select ~ join이 실행된다. 아직 Member만 실제 객체로 조회된 상황이다. Member 객체의 TeamForLazy 참조 객체는 프록시 객체이다.
- 각 Member 객체의 TeamForLazy 참조 객체의 데이터에 접근 할 때마다, 각각 select 쿼리가 실행된다.
@Transactional
@SpringBootTest
public class Join_FetchTypeTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private TeamRepository teamRepository;
@PersistenceContext
private EntityManager em;
@DisplayName("NO join + FetchType.EAGER")
@Test
void membersWithEager() {
TeamForEager teamA = TeamForEager.builder()
.name("teamA")
.build();
TeamForEager teamB = TeamForEager.builder()
.name("teamB")
.build();
teamRepository.saveTE(teamA);
teamRepository.saveTE(teamB);
Member memberA = Member.builder()
.username("userA")
.age(10)
.teamForEager(teamA)
.build();
Member memberB = Member.builder()
.username("userB")
.age(20)
.teamForEager(teamB)
.build();
memberRepository.save(memberA);
memberRepository.save(memberB);
em.flush();
em.clear();
List<Member> members = memberRepository.membersWithEager();
/*
* join절 없이, member 엔티티들을 조회한다.
* SELECT m1_0.id ... FROM member m1_0
* 즉시 각 member 엔티티들의 teamForEager 엔티티를 각각 조회한다.
* SELECT t1_0.id ... FROM team_for_eager t1_0 WHERE t1_0.id=?
* SELECT t1_0.id ... FROM team_for_eager t1_0 WHERE t1_0.id=?
*/
}
@DisplayName("YES join + FetchType.EAGER")
@Test
void membersWithEagerJoin() {
TeamForEager teamA = TeamForEager.builder()
.name("teamA")
.build();
TeamForEager teamB = TeamForEager.builder()
.name("teamB")
.build();
teamRepository.saveTE(teamA);
teamRepository.saveTE(teamB);
Member memberA = Member.builder()
.username("userA")
.age(10)
.teamForEager(teamA)
.build();
Member memberB = Member.builder()
.username("userB")
.age(20)
.teamForEager(teamB)
.build();
memberRepository.save(memberA);
memberRepository.save(memberB);
em.flush();
em.clear();
List<Member> members = memberRepository.membersWithEagerJoin();
/*
* join 절을 적용하여, member 엔티티들을 조회한다.
* SELECT m1_0.id ... FROM member m1_0 JOIN team_for_eager t1_0 ON t1_0.id=m1_0.team_for_eager_id
* 즉시 각 member 엔티티들의 teamForEager 엔티티를 각각 조회한다.
* SELECT t1_0.id ... FROM team_for_eager t1_0 WHERE t1_0.id=?
* SELECT t1_0.id ... FROM team_for_eager t1_0 WHERE t1_0.id=?
*/
}
@DisplayName("NO join + FetchType.LAZY")
@Test
void membersWithLazy() {
TeamForLazy teamA = TeamForLazy.builder()
.name("teamA")
.build();
TeamForLazy teamB = TeamForLazy.builder()
.name("teamB")
.build();
teamRepository.saveTL(teamA);
teamRepository.saveTL(teamB);
Member memberA = Member.builder()
.username("userA")
.age(10)
.teamForLazy(teamA)
.build();
Member memberB = Member.builder()
.username("userB")
.age(20)
.teamForLazy(teamB)
.build();
memberRepository.save(memberA);
memberRepository.save(memberB);
em.flush();
em.clear();
List<Member> members = memberRepository.membersWithLazy();
/*
* join절 없이, member 엔티티들을 조회한다.
* SELECT m1_0.id ... FROM member m1_0
*/
for (Member member : members) {
System.out.println("member.getTeamForLazy().getName() = " + member.getTeamForLazy().getName());
}
/*
* 각 Member 엔티티의 TeamForLazy 엔티티에 접근할 때마다 각각 조회한다.
* member.getTeamForLazy().getName() = teamA
* SELECT t1_0.id ... FROM team_for_lazy t1_0 WHERE t1_0.id=?
* member.getTeamForLazy().getName() = teamB
* SELECT t1_0.id ... FROM team_for_lazy t1_0 WHERE t1_0.id=?
*/
}
@DisplayName("YES join + FetchType.LAZY")
@Test
void membersWithLazyJoin() {
TeamForLazy teamA = TeamForLazy.builder()
.name("teamA")
.build();
TeamForLazy teamB = TeamForLazy.builder()
.name("teamB")
.build();
teamRepository.saveTL(teamA);
teamRepository.saveTL(teamB);
Member memberA = Member.builder()
.username("userA")
.age(10)
.teamForLazy(teamA)
.build();
Member memberB = Member.builder()
.username("userB")
.age(20)
.teamForLazy(teamB)
.build();
memberRepository.save(memberA);
memberRepository.save(memberB);
em.flush();
em.clear();
List<Member> members = memberRepository.membersWithLazyJoin();
/*
* join절을 적용하여, member 엔티티들을 조회한다.
* SELECT m1_0.id ... FROM member m1_0 JOIN team_for_lazy t1_0 ON t1_0.id=m1_0.team_for_lazy_id
*/
for (Member member : members) {
System.out.println("member.getTeamForLazy().getName() = " + member.getTeamForLazy().getName());
}
/*
* 각 Member 엔티티의 TeamForLazy 엔티티에 접근할 때마다 각각 조회한다.
* member.getTeamForLazy().getName() = teamA
* SELECT t1_0.id ... FROM team_for_lazy t1_0 WHERE t1_0.id=?
* member.getTeamForLazy().getName() = teamB
* SELECT t1_0.id ... FROM team_for_lazy t1_0 WHERE t1_0.id=?
*/
}
}
FETCH JOIN
- fetch join은 SQL에서 제공하는 join의 종류가 아니며, JPQL에서 데이터 조회 성능 최적화를 위해 제공하는 기능이다.
- fetch join은 연관된 엔티티 또는 컬렉션을 한 번의 쿼리로 함께 조회하는 기능이다.
- 그리고 JPQL을 사용할 때 발생할 수 있는 n + 1 문제의 해결 방법이기도 하다.
- ... left [outer] / [inner] join fetch ...으로 사용 가능하다.
- fetch join을 사용할 경우, 지연 로딩이 불가능하며 글로벌 로딩 전략이 설정되어있다 하더라도 즉시 로딩으로 실행된다.
엔티티(xToOne) 페치 조인
- 사용된 JPQL은 "select m from Member m join fetch m.teamForLazy t"이다.
- 해당 JPQL은 "select m, t from Member m join m.teamForLazy t"라는 JPQL과 동일한 의미를 가진다.
- 위의 두 JPQL들은 "select m.*, t.* from Member m inner join TeamForLazy t on m.TeamForLazy_id=t.id"라는 SQL로 변환되어 실행된다.
컬렉션(xToMany) 페치 조인
- 사용된 JPQL은 "select t from TeamForLazy t join fetch t.members m"이다.
- 해당 JPQL은 "select t, m from TeamForLazy t join t.members m"이라는 JPQL과 동일한 의미를 가진다.
- 위의 두 JPQL들은 "select t.*, m.* from TeamForLazy t inner join Member m on t.ID=m.TeamForLazy_id"라는 SQL로 변환되어 실행된다.
- 컬렉션 페치 조인 시에는 결과가 부풀려진다는 것을 주의하자.
- 하나의 team은 여러 개의 member를 갖고 있을 수 있다.
- 때문에 데이터베이스 조회 결과에서는 teamA - member1, teamA - member2처럼 1개의 team에 대해 다수의 row가 생성될 수 있다.
- 데이터베이스는 이러한 조회 결과를 그대로 어플리케이션에게 전달하기 때문에, 각 team에 대해 1개의 객체만 필요하더라도 다수의 객체가 존재하는 문제가 발생한다.
- 컬렉션 페치 조인 시 결과가 부풀려지는 문제를 해결할 때 사용하는 것이 distinct이다.
- SQL 쿼리의 distinct는 row와 row간에 모든 컬럼 값이 동일해야 중복으로 판단하여 중복 제거를 실시하기 때문에 이 문제를 해결할 수 없다(member에 대한 데이터는 다를 것이기 때문이다).
- 그러나 JPQL의 distinct는 그것과 더불어 어플리케이션 차원에서 데이터 조회 결과 컬렉션 내부에서 같은 식별자를 가진 엔티티 객체를 중복으로 판별하여 제거한다.
- 스프링부트 3.0 이상은 hibernate 6.1을 사용하며, hibernate 6.0 이상은 distinct가 자동으로 적용된다.
@Transactional
@SpringBootTest
public class FetchJoinTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private TeamRepository teamRepository;
@PersistenceContext
private EntityManager em;
@DisplayName("엔티티 페치 조인")
@Test
void membersWithFetchJoin() {
TeamForLazy teamA = TeamForLazy.builder()
.name("teamA")
.build();
TeamForLazy teamB = TeamForLazy.builder()
.name("teamB")
.build();
teamRepository.saveTL(teamA);
teamRepository.saveTL(teamB);
Member memberA = Member.builder()
.username("userA")
.age(10)
.teamForLazy(teamA)
.build();
Member memberB = Member.builder()
.username("userB")
.age(20)
.teamForLazy(teamA)
.build();
Member memberC = Member.builder()
.username("userC")
.age(10)
.teamForLazy(teamB)
.build();
Member memberD = Member.builder()
.username("userD")
.age(20)
.teamForLazy(teamB)
.build();
memberRepository.save(memberA);
memberRepository.save(memberB);
memberRepository.save(memberC);
memberRepository.save(memberD);
em.flush();
em.clear();
List<Member> members = memberRepository.membersWithFetchJoin();
for (Member member : members) {
System.out.println("member.getTeamForLazy().getName() = " + member.getTeamForLazy().getName());
}
/*
* join 절이 적용되고, fetch join이 적용된 엔티티를 select 절에 포함하여 한번에 함께 조회한다.
*
* SELECT m1_0.id, ... , t1_0.id, ... FROM member m1_0 JOIN team_for_lazy t1_0 ON t1_0.id=m1_0.team_for_lazy_id
* member.getTeamForLazy().getName() = teamA
* member.getTeamForLazy().getName() = teamA
* member.getTeamForLazy().getName() = teamB
* member.getTeamForLazy().getName() = teamB
*/
}
@DisplayName("컬렉션 페치 조인 + NO distinct")
@Test
void teamsWithFetchJoin() {
TeamForLazy teamA = TeamForLazy.builder()
.name("teamA")
.build();
TeamForLazy teamB = TeamForLazy.builder()
.name("teamB")
.build();
teamRepository.saveTL(teamA);
teamRepository.saveTL(teamB);
Member memberA = Member.builder()
.username("userA")
.age(10)
.teamForLazy(teamA)
.build();
Member memberB = Member.builder()
.username("userB")
.age(20)
.teamForLazy(teamA)
.build();
Member memberC = Member.builder()
.username("userC")
.age(10)
.teamForLazy(teamB)
.build();
Member memberD = Member.builder()
.username("userD")
.age(20)
.teamForLazy(teamB)
.build();
memberRepository.save(memberA);
memberRepository.save(memberB);
memberRepository.save(memberC);
memberRepository.save(memberD);
em.flush();
em.clear();
List<TeamForLazy> teams = teamRepository.teamsWithFetchJoin();
for (TeamForLazy team : teams) {
System.out.println("team.getName() = " + team.getName() + ":" + team.getMembers().size() + "명");
for (Member m: team.getMembers()) {
System.out.println(" -m.getUsername() = " + m.getUsername());
}
}
/*
* join 절이 적용되고, fetch join이 적용된 엔티티를 select 절에 포함하여 한번에 함께 조회한다.
* 하지만 컬렉션(일대다) fetch join 시에는 조회 결과가 부풀려진다는 것을 주의하자.
*
* SELECT t1_0.id, ... , m1_0.id, ... FROM team_for_lazy t1_0 JOIN member m1_0 ON t1_0.id=m1_0.team_for_lazy_id
* team.getName() = teamA:2명
* -m.getUsername() = userA
* -m.getUsername() = userB
* team.getName() = teamA:2명
* -m.getUsername() = userA
* -m.getUsername() = userB
* team.getName() = teamB:2명
* -m.getUsername() = userC
* -m.getUsername() = userD
* team.getName() = teamB:2명
* -m.getUsername() = userC
* -m.getUsername() = userD
*/
}
@DisplayName("컬렉션 페치 조인 + YES distinct")
@Test
void teamsWithFetchJoinDistinct() {
TeamForLazy teamA = TeamForLazy.builder()
.name("teamA")
.build();
TeamForLazy teamB = TeamForLazy.builder()
.name("teamB")
.build();
teamRepository.saveTL(teamA);
teamRepository.saveTL(teamB);
Member memberA = Member.builder()
.username("userA")
.age(10)
.teamForLazy(teamA)
.build();
Member memberB = Member.builder()
.username("userB")
.age(20)
.teamForLazy(teamA)
.build();
Member memberC = Member.builder()
.username("userC")
.age(10)
.teamForLazy(teamB)
.build();
Member memberD = Member.builder()
.username("userD")
.age(20)
.teamForLazy(teamB)
.build();
memberRepository.save(memberA);
memberRepository.save(memberB);
memberRepository.save(memberC);
memberRepository.save(memberD);
em.flush();
em.clear();
List<TeamForLazy> teams = teamRepository.teamsWithFetchJoinDistinct();
for (TeamForLazy team : teams) {
System.out.println("team.getName() = " + team.getName() + ":" + team.getMembers().size() + "명");
for (Member m: team.getMembers()) {
System.out.println(" -m.getUsername() = " + m.getUsername());
}
}
/*
* join 절이 적용되고, fetch join이 적용된 엔티티를 select 절에 포함하여 한번에 함께 조회한다.
* JPQL의 distinct 키워드를 사용하였다.
* SQL의 distinct는 로우의 모든 데이터가 동일할 때 중복으로 판별하여 중복 제거한다.
* JPQL의 distinct는 그것과 더불어 어플리케이션 차원에서 결과 컬렉션 내부의 같은 식별자를 가진 엔티티를 중복으로 판별하여 제거한다!
*
* SELECT t1_0.id, ... , m1_0.id, ... FROM team_for_lazy t1_0 JOIN member m1_0 ON t1_0.id=m1_0.team_for_lazy_id
* team.getName() = teamA:2명
* -m.getUsername() = userA
* -m.getUsername() = userB
* team.getName() = teamB:2명
* -m.getUsername() = userC
* -m.getUsername() = userD
*/
}
}