페치 조인(fetch join)
페치 조인이란 SQL 조인의 종류가 아니며, JPQL에서 성능 최적화를 위해서 제공해주는 기능이다.
페치 조인은 엔티티를 조회할 때 연관된 엔티티나 컬렉션을 한 번의 SQL로 함께 조회하는 기능을 제공함으로써 N+1문제를 해결할 수 있게 도와준다.
일반 조인은 실행 시 연관된 엔티티를 함께 조회하지 않는다.
단지 데이터베이스 상에서 테이블간의 탐색을 위해 사용되는 것이다.
아래와 같은 코드가 있다고 하자.
@Entity
@Data
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private int age;
@Embedded
private Address address;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
@Embeddable
public class Address {
private String city;
private String street;
private String zipcode;
}
@Data
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
Member와 Team이 다대일로 연결되어 있다.
Team teamA = new Team();
teamA.setName("TEAM A");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("TEAM B");
em.persist(teamB);
Team teamC = new Team();
teamC.setName("TEAM C");
em.persist(teamC);
Member member = new Member("user 1", 1, teamA);
Member member2 = new Member("user 2", 2, teamA);
Member member3 = new Member("uesr 3", 3, teamB);
Member member4 = new Member("user 4", 4, teamC);
em.persist(member);
em.persist(member2);
em.persist(member3);
em.persist(member4);
em.flush();
em.clear();
System.out.println("-----------");
em.createQuery("select m from Member m join m.team", Member.class)
.getResultList().forEach(m -> System.out.println(m.getUsername() +" + "+ m.getTeam().getName()));
위 코드를 실행하면 어떤 일이 발생할까?
N+1 문제 발생
페치 조인을 사용하면 어떻게 될까?
JPQL을 단순히 아래와 같이 바꾼다.
Team teamA = new Team();
teamA.setName("TEAM A");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("TEAM B");
em.persist(teamB);
Team teamC = new Team();
teamC.setName("TEAM C");
em.persist(teamC);
Member member = new Member("user 1", 1, teamA);
Member member2 = new Member("user 2", 2, teamA);
Member member3 = new Member("uesr 3", 3, teamB);
Member member4 = new Member("user 4", 4, teamC);
em.persist(member);
em.persist(member2);
em.persist(member3);
em.persist(member4);
em.flush();
em.clear();
System.out.println("-----------");
em.createQuery("select m from Member m join fetch m.team", Member.class)
.getResultList().forEach(m -> System.out.println(m.getUsername() +" + "+ m.getTeam().getName()));
select가 1번만 나간다.
사용법
[ LEFT [OUTER] | INNER ] JOIN FETCH 조인경로
일반 join 뒤에 fetch를 붙여주면 사용할 수 있다.
페치 조인은 엔티티를 대상으로 할 수 있고, 컬렉션을 대상으로 할 수 있다.
엔티티 페치 조인
페치 조인을 사용해서 회원 엔티티를 조회하면서 연관된 팀 엔티티도 함께 조회해 보자
select m from Member m join fetch m.team
컬렉션 페치 조인
팀에서 회원들의 리스트를 함께 조회해 보자.
select t from Team t join fetch t.members where t.name = 'Team A'
쿼리는 다음과 같이 나온다.
주의점(하이버네이트 6 이전 버전)
데이터베이스는 컬렉션을 조인하면 데이터가 늘어난다.
2023 / 1월 / 7일 추가 - 하이버네이트 6부터는 distinct 명령어를 사용하지 않아도 엔티티의 중복을 제거하도록 변경됨
Team teamA = new Team();
teamA.setName("Team A");
Team teamB = new Team();
teamB.setName("Team B");
Team teamC = new Team();
teamC.setName("Team C");
Member member = new Member("user 1", 1, teamA);
Member member2 = new Member("user 2", 2, teamA);
Member member3 = new Member("user 3", 3, teamB);
Member member4 = new Member("user 4", 4, teamC);
em.persist(teamA);
em.persist(teamB);
em.persist(teamC);
em.persist(member);
em.persist(member2);
em.persist(member3);
em.persist(member4);
em.flush();
em.clear();
em.createQuery("select t from Team t join fetch t.members ", Team.class)
.getResultList().forEach(t -> System.out.println(t.getName()+ " + "+t.getMembers().size()));
아래와 같은 결과가 나온다.
팀 A의 인원이 2명인 것은 알지만, 왜 2번 출력되었지??
팀 A에 소속되어 있는 회원이 2명일 때, TEAM과 MEMBER를 조인해서 가져오면 row가 2줄로 늘어나게 된다.
JPA는 그럼 2줄을 모두 가져오기 때문에, 팀 A를 2개 가지고 오게 된다.
따라서 SELECT t FROM Team t join fetch t.members where t.name = '팀 A'과 같은 쿼리를 수행한 결과의 List의 size는 2개가 되어버리는 것이다.
DISTINCT
JPQL에서는 SQL에서 제공하는 DISTINCT라는 명령어를 제공한다.
SQL의 DISTINCT 명령어는 중복된 결과를 제거하는 명령이지만, JPQL의 DISTINCT는 한 가지 기능이 더 있다.
- SQL에 DISTINCT를 추가합니다.
- 애플리케이션에서 엔티티의 중복을 제거합니다.
SQL의 DISTINCT는 ROW의 모든 속성이 같아야만 제거가 된다.
해당 기능으로는 해결되지 않는 부분이 있다.
ID와 NAME이 다르기 때문에 SQL의 DISTINCT로는 제거가 되지 않는다.
따라서 JPQL의 DISTINCT는 이를 해결하기 위해 엔티티의 중복을 제거하는 기능을 추가로 넣어준 것이다.
JOIN 한 Member의 데이터는 제거되지 않고 1차 캐시에 보관된다.
제거되는 것은 중복된 팀 A라는 이름의 Team 엔티티뿐이다.
페치 조인의 특징과 한계
페치 조인 대상에는 별칭을 줄 수 없다.
JPA표준에서는 페치 조인 대상에 별칭을 주는 것을 지원하지 않으나, 하이버네이트를 포함한 몇몇 구현체들은 별칭을 지원한다.
그러나 별칭을 사용하면 자칫 연관된 데이터 수가 달라져서 데이터 무결성이 깨질 수 있으므로 주의하여야 한다.
특히 2차 캐시와 함께 사용할 때 조심해야 한다.
연관된 데이터 수가 달라진 상태에서 2차 캐시에 저장된다면 다른 곳에서 조회할 때도 연관된 데이터의 수가 달라지는 문제가 발생할 수 있다.
둘 이상의 컬렉션을 페치 할 수 없다
구현체에 따라 되기도 하지만, 컬렉션 * 컬렉션의 카테시안 곱이 만들어지므로 주의해야 한다.
하이버네이트 사용 시에는 오류가 발생한다.
사용하지 않는 것을 권장
컬렉션을 페치 조인하면 페이징 API (setFirstResult, setMaxResult)를 사용할 수 없다.
컬렉션이 아닌 단일 값 연관 필드를 페치 조인하면 사용할 수 있으나, 컬렉션을 페치 조인한 경우에는 사용할 수 없다.
하이버네이트의 경우에는 해주긴 하지만, 경고 로그를 남기고 메모리에서 페이징 처리를 하는데, 이는 데이터가 많을 경우 성능 이슈와 메모리 초과 예외가 발생할 수 있기에 매우 위험하다.
페치 조인 시 별칭을 줄 수 없는 이유
- 페치조인은 한 엔티티와 연관된 다른 엔티티를 모두 조회하겠다는 의미로 사용된다.
- 그러나 패치 조인에 별칭을 사용하여 특정 조건에 부합하는 결과만 가져오고 싶을 수 있다.
- 그러나 페치조인은 절대 그렇게 사용해서는 안된다.
- 필터링이 필요하다면, 페치 조인이 아니라 따로따로 엔티티를 조회해야 한다.
- 그 이유는 데이터 무결성 때문이다.
예시
팀 A가 있고, 팀 A에는 회원이 7명 있다고 하자.
회원 1은 나이가 10살이다.
회원 2는 나이가 20살이다.
회원 3은 나이가 30살이다.
회원 4는 나이가 40살이다.
회원 5는 나이가 50살이다.
회원 6은 나이가 60살이다.
회원 7은 나이가 70살이다.
팀을 조회하면서, 연관된 회원을 같이 조회하려 한다.
그리고 회원의 나이가 30살 이하인 회원만 조회하고 싶다.
그럼 단순히 아래와 같은 쿼리를 작성할 수 도 있다.
"select t from Team t join fetch t.members m where m.age <=30"
결과는 teamA.getMembers()의 size는 3이 된다.
회원 1, 회원 2, 회원 3 이 들어있기 때문이다.
여기서 문제가 발생한다.
데이터베이스에서 TeamA와 연관된 회원은 7명인데, 영속성 컨덱스트에는 TeamA와 연관된 회원이 3명뿐이다.
데이터베이스와 객체의 데이터 개수가 달라져서
데이터의 무결성이 깨져버리는 결과를 초래하고, 이를 방지하고자 페치조인에는 별칭을 줄 수 없게 만든 것이다.
별칭을 준 대상을 where절에서 사용하면 예외를 발생시키지는 않으나, on 절에서 사용 시 예외가 발생한다.
따라서 가급적 페치 조인의 대상에는 별칭을 사용하지 않도록 하고,
별칭을 준다고 하더라도 절대로 where, on 절의 필터링 대상으로는 사용하지 않아야 한다.
별칭을 사용하는 경우
"select t from Team t join fetch t.members m join fetch m.xxx"
페치 조인을 여러 단계로 하는 경우에는 사용해도 괜찮다.
'JPA' 카테고리의 다른 글
OSIV(Open Session In View) (2) | 2023.10.02 |
---|---|
QueryDSL (0) | 2023.01.15 |
JPQL 사용법 (3) | 2023.01.09 |
JPA의 데이터 타입 (3) | 2023.01.07 |
프록시 & 지연로딩 (0) | 2023.01.07 |