1. 페치 조인(fetch join)이란?
- SQL 조인 종류가 아니다.
- JPQL에서 성능 최적화를 위해 제공하는 기능이다.
- 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능이다.
- join fetch 명령어 사용한다.
- [LEFT [OUTER] | INNER ] JOIN FETCH 조인 경로
2. 엔티티 페치 조인
SQL로 한 번에 회원을 조회하면서 연관된 팀도 함께 조회하고싶다면 어떻게 해야할까?
SQL을 보면 회원 뿐만 아니라 팀도 함께 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
지연 로딩할 때와 같이 쿼리문이 나간다.
위의 그림을 살펴보면 fetch join을 진행한 뒤 맨 아래처럼 1차 캐시에 저장한다.
만약 select m from Member m; 으로 쿼리를 만들고 결과물을 가져오려고 하면, 첫번째 루프를 돌면서 team의 이름을 가져와야하는데 이때 프록시이기 때문에 영속성 컨텍스를 통해서 데이터를 가져오라고 요청을 한다.
회원1은 팀A(SQL), 회원2 팀B(SQL), 회원3 팀A(1차캐시) 이런 순서로 실행과 처리가 되고, 회원이 만약 100명이면 쿼리가 100번이 추가로 더 나가게된다. (N+1 문제 발생)
이걸 해결하기 위해서는 아래와 같이 fetch join을 사용하면 된다.
package hellojpa;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityTransaction;
import jakarta.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Team teamA = new Team();
teamA.setName("teamA");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("teamB");
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(teamB);
em.persist(member2);
Member member3 = new Member();
member3.setUsername("회원3");
member3.setTeam(teamA);
em.persist(member3);
Member member4 = new Member();
member4.setUsername("회원4");
em.persist(member4);
em.flush();
em.clear();
String jpql = "select m from Member m join fetch m.team";
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();
for (Member m : resultList) {
System.out.println("username = " + m.getUsername() + "," + "team Name = " + m.getTeam().getName());
}
tx.commit();
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
em.close();
}
emf.close();
}
}
프록시가 아닌 진짜 데이터를 가져오기 때문에 이미 다 채워져 있는 상태로 아래와 같이 SELECT 쿼리 1번만 나가고 결과도 잘나오는 것을 확인할 수 있다.
3. 컬렉션 페치 조인
일대다 관계에서 사용한다.
//JPQL
select t
from Team t join fetch t.members
where t.name = 'teamA'
//SQL
SELECT T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = 'teamA'
아래 예제를 통해 어떤 결과가 나온지 살펴보자
package hellojpa;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.EntityTransaction;
import jakarta.persistence.Persistence;
import java.util.List;
public class Main {
public static void main(String[] args) {
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();
try {
Team teamA = new Team();
teamA.setName("teamA");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("teamB");
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(teamB);
em.persist(member2);
Member member3 = new Member();
member3.setUsername("회원3");
member3.setTeam(teamA);
em.persist(member3);
Member member4 = new Member();
member4.setUsername("회원4");
em.persist(member4);
em.flush();
em.clear();
String jpql = "select t from Team t join fetch t.members";
List<Team> resultList = em.createQuery(jpql, Team.class).getResultList();
for (Team team : resultList) {
System.out.println("name = " + team.getName() + "," + "members = " + team.getMembers().size());
}
tx.commit();
} catch (Exception e) {
tx.rollback();
e.printStackTrace();
} finally {
em.close();
}
emf.close();
}
}
현재 설정되어 있는 하이버네이트 버전은 6.4.2이다.
하이버네이트 6부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용되어진다.
만약, 하이버네이트 5라면 팀A가 2줄이 나오고, 팀B가 1줄이 나와서 총 3줄의 결과가 나오게된다.
이 때 중복을 제거하기 위해서 DISTINCT이라는 중복된 결과를 제거하는 명령어를 사용해줘야한다.
select distinct t
from Team t join fetch t.members
where t.name = 'teamA'
JPQL의 DISTINCT는 1) SQL에 DISTINCT를 추가 2) 애플리케이션에서 엔티티 중복 제거 2가지 기능을 제공한다.
SQL에서 DISTINCT를 추가해도 데이터가 다르면 SQL 결과에서 중복 제거 실패한다.
DISTINCT가 추가로 애플리케이션에서 중복 제거를 시도한다.
같은 식별자를 가진 엔티티를 제거한다.(ex. Team 엔티티 제거)
다대일 연관관계는 데이터가 뻥튀기 되지 않는데, 일대다 연관관계에서는 데이터가 뻥튀기가 되기 때문에 늘 생각하도록 하자!
4. 페치 조인과 일반 조인의 차이
- JPQL은 결과를 반환할 때 연관관계를 고려하지 않는다.
- 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다.
- 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회한다.(즉시 로딩)
- 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념이다.
select t from Team t join t.member m;
위의 JPQL과 같이 일반 조인으로 실행하면 연관된 엔티티를 함께 조회하지 않는다.
select t from Team t join fetch t.member m;
이렇게 바꾸게 되면 쿼리 한번만 나가고 select절에 member와 team의 모든 데이터를 다 가져온다.
5. 페치 조인의 특징과 한계
페치 조인 대상에는 별칭을 줄 수 없다.(관례)
select t from Team t join fetch t.members as m;
위와 같이 페치 조인을 사용하여 연관된 엔티티를 다 가지고 오겠다는 의미인데 where 절을 주고 m.age > 10 이런 식으로 조건을 주는 것은 페치 조인을 사용하는게 의미가 없다.(객체 그래프 탐색은 연관된 객체를 다 가져온다는 의미인데 해당 의미, 즉 사상과는 일치하지 않는다) 5개의 데이터가 있는데 3개의 데이터만 가져와서 조작하게 되면 데이터가 삭제되는 등 큰 문제가 발생할 수도 있다. 하이버네이트는 가능하지만, 가급적 사용하지말자! 만약, 필요하다면 별도의 쿼리로 각각 조회하는 것이 좋다.
둘 이상의 컬렉션은 페치 조인 할 수 없다.
이 부분도 데이터 정합성의 문제가 발생할 수 있다. 일대다대다가 되기 때문에 데이터가 뻥튀기가 되어서 조회될 수 있다. 컬렉션은 1개만 페치 조인할 수 있다고 생각하고 사용하자
컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징이 가능하다. (데이터 뻥튀기가 되지 않기 때문에)
하이버네이트는 경고 로그를 남기고 쿼리에서 페이징하지 않고 메모리에서 페이징을 한다(매우 위험)
일대다 같은 경우 데이터가 뻥튀기가 되고, 페이징을 사용하면 데이터가 잘려서 결과물을 가져오게 된다.(결과값은 2개인데 페이징 때문에 1개만 나오게 됨)
페이징을 사용해야 한다면
일대다는 반대로 다대일로 처리할 수 있으니 JPQL을 작성할 때 방향을 뒤집어서 작성을 하거나,
fetch join을 과감하게 삭제하고 페이징 처리를 해서 사용할 수 있다. 단, 이렇게 하면 성능 이슈가 있어 @BatchSize라는 어노테이션을 사용하거나 persistence에 batchSize를 글로벌로 설정해주면 N+1의 문제도 해결되고 성능 최적화도 할 수 있다.
아니면 직접 쿼리를 짜서 DTO로 반환하는 방법도 있다.
ex) Team 엔티티 안에 있는 members에 @BatchSize(size = 100); 를 설정하면 IN 쿼리로 100개를 조회해서 가져올 수 있다.
ex) persistence.xml 안에 <property name="hibernate.default_batch_fetch_size" value="100"/> 설정
다른 분이 트러블 슈팅과 관련하여 적용한 내용이 있어 글의 링크를 가져왔다.
연관된 엔티티들을 SQL 한 번으로 조회하기 때문에 성능 최적화가 된다.
엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선권을 가진다.
실무에서 글로벌 로딩 전략은 모두 지연 로딩으로 잡고, 최적화가 필요한 곳은 페치 조인을 적용하면 된다.
이렇게만 해도 N+1 문제가 해결된다.
6. 정리
- 모든 것을 페치 조인으로 해결할 수는 없다.
- 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.
- 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면 페치 조인보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적이다.
'TIL > JPA' 카테고리의 다른 글
[TIL/JPA] 기본개념 : JPQL에서 엔티티 직접 사용하기 (0) | 2024.09.17 |
---|---|
[TIL/JPA] 기본개념 : 다형성 쿼리 (0) | 2024.09.15 |
[TIL/JPA] 기본개념 : 경로 표현식 (0) | 2024.09.10 |
[TIL/JPA] 기본개념 : JPQL(Java Persistence Query Language) (0) | 2024.09.09 |
[TIL/JPA] 기본개념 : JPA의 다양한 쿼리 방법 소개 (0) | 2024.09.04 |