11. 객체지향 쿼리 언어 - 중급 문법 (fetch join) [매우 중요]

HotFried·2023년 10월 4일
0

페치 조인(fetch join)

  • SQL 조인 종류가 아니다.
  • 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

회원을 조회하면서 연관된 팀도 SQL에서 한 번에 조회한다.
SQL을 보면 Member와 팀 Team을 함께 select 한다.

즉시 로딩으로 가져오는 방법과 똑같다.
단지 join fetch라고 명시적으로 선언해서 원하는 객체 그래프를 한 번에 조회하는 것


예제

  • 회원1, 회원2 -> 팀A소속
  • 회원3 -> 팀 B소속
  • 회원4 -> 소속 팀 없음

소속 팀이 없는 회원은 제외 되므로 inner join 이용

String jpql = "select m from Member m join fetch m.team";
List<Member> members = em.createQuery(jpql, Member.class)
 .getResultList();
 
for (Member member : members) {
 //페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩X
 System.out.println("username = " + member.getUsername() + ", " +
 "teamName = " + member.getTeam().name()); 
}

우리가 만약 fetch join을 이용하지 않고 select m from Member m 쿼리를 날리면 무슨 일이 발생할까?


쿼리가 총 3번 나간다.
어떻게 쿼리가 3번이 나가는지 확인해보자.

  1. 연관 관계에 있는 team을 프록시로 가져온다.
  2. getTeam().getName() 을 호출할 때 지연 로딩으로 select 쿼리가 나간다.
    • 회원 1이 팀 A를 불러 올 때 최초 SQL을 날린다.
    • 회원 2는 같은 팀 A이므로 1차 캐시에서 가져온다.
  3. 회원 3의 팀 B는 영속성 컨텍스트에 존재하지 않으므로 새로운 쿼리를 날린다.

=> N + 1 문제가 발생할 수 있다.

이럴 때 fetch join을 이용해서 문제를 해결한다.


컬렉션 페치 조인

  • 일대다 관계에서 컬렉션 페치 조인을 이용한다.

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 jpql = "select t from Team t join fetch t.members where t.name = '팀A'"
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();

for(Team team : teams) {
	System.out.println("teamname = " + team.getName() + ", team = " + team);
	for (Member member : team.getMembers()) {
 	
    	//페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안함
		System.out.println(-> username = " + member.getUsername()+ ", member = " + member);  
	}
    System.out.println()
}
teamname =A, team = Team@0x100
username = 회원1, member = Member@0x200
username = 회원2, member = Member@0x300

teamname =A, team = Team@0x100
username = 회원1, member = Member@0x200
username = 회원2, member = Member@0x300

TeamA에 Member가 2명이 있다. 그래서 TeamA 정보가 두번 출력되는 것을 확인할 수 있다.

  • DB입장에서는 일대다 join시 데이터가 뻥튀기된다.

페치 조인과 DISTINCT

(하이버네이트6 부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용된다.)

  • SQL의 DISTINCT는 중복된 결과를 제거하는 명령

  • JPQL의 DISTINCT는 2가지 기능을 제공한다.

    1. SQL에 DISTINCT를 추가
    2. 애플리케이션에서 엔티티 중복 제거
select distinct t
from Team t join fetch t.members
where t.name = '팀A'

SQL에 DISTINCT를 추가하지만 ID(PK)정보가 다르므로 SQL결과에서 중복제거가 실패한다.
-> DISTINCT가 추가로 애플리케이션에서 중복 제거를 시도한다.

∴ 같은 식별자를 가진 Team 엔티티가 제거된다.

실제로 db에 distinct가 적용된 쿼리가 나간다.
하지만, db에 한 속성값이라도 다르다면 distinct가 되지 않는다.
그렇기에 실제로는 JPA에서 자체적으로 ID가 같다면 중복 제거를 해주는 것이다.


String jpql = "select distinct t from Team t join fetch t.members where t.name = '팀A'"
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();

for(Team team : teams) {
	System.out.println("teamname = " + team.getName() + ", team = " + team);
	for (Member member : team.getMembers()) {
 	
    	//페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안함
		System.out.println(-> username = " + member.getUsername()+ ", member = " + member);  
	}
    System.out.println()
}
teamname =A, team = Team@0x100
username = 회원1, member = Member@0x200
username = 회원2, member = Member@0x300

중복된 Team엔티티가 제거되어 출력되는 것을 확인할 수 있다.


페치 조인과 일반 조인의 차이

일반 조인

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'
  • 연관된 엔티티를 함께 조회하지 않는다.

  • JPQL은 결과를 반환할 때 연관관계를 고려하지 않는다.
    -> SELECT 절에 지정한 엔티티만 조회한다.

  • jpql에 일반 join을 쓰면 sql에서 team 엔티티만 조회한다.
    -> Member Entity는 조회하지 않는다.
    Member Entity를 탐색하는 시점에 쿼리가 나간다.

페치 조인

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'
  • 연관된 엔티티를 함께 조회한다.

차이점

  • 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회(즉시 로딩)
  • 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념

페치 조인의 특징과 한계

페치 조인 대상에는 별칭을 줄 수 없다.

select t From Team t
//-- as 사용 불가
join fetch t.members as m where ...
  • 하이버네이트는 가능하지만, 가급적 사용하지 않는다.

Why?

  • fetch join은 연관된 모든 것을 가져오는 용도이다.
    -> 즉시로딩으로 연관된 데이터를 모두 가져오는 것을 확인할 수 있다.

  • 모든 데이터가 아닌 특정 데이터를 가져오고 싶다면 fetch join을 쓰면 안된다.
    -> 원하는 회원 정보만 가져오고 싶다면 처음부터 회원에 대한 쿼리를 날리는 것이 맞다.

  • JPA는 객체 그래프 탐색으로 연관된 데이터를 모두 가져올 수 있는 상황을 의도한다.
    -> 별칭을 이용해 특정 데이터만 가져올 수 있다면 예상치 못한 치명적 오류가 발생할 수 있다.

∴ 데이터의 정합성 & JPA의 객체 그래프 탐색 의도와 맞지 않으므로 가급적 사용하지 않는다.


둘 이상의 컬렉션은 페치 조인 할 수 없다.

  • 즉시로딩 할 때 모든 데이터를 불러오는 특성이 있다.
    -> 데이터가 곱하기에 곱하기가 되면서 기하급수적으로 늘어난다.
    ex. team이 members와 orders를 가진다면 이 둘을 한번에 fetch join할 수 없다.

컬렉션을 페치 조인하면 페이징API를 사용할 수 없다.

  • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징이 가능하다.

  • 하지만 컬렉션을 페치 조인하면 하이버네이트는 경고 로그를 남기고 메모리에서 페이징한다.
    (매우위험)

String query = "select t From Team t join fetch t.members m"

List<Team> result = em.createQuery(query, Team.class)
		.setFirstResult(0)
        .setMaxResult(1)
        .getResultList();
        
System.out.println("result = " + result.size());

코드 실행 시 아래와 같은 경고가 발생한다.
메모리에서 페이징을 한다는 경고이다.
-> List가 100만건과 같이 엄청 큰 size인데, 페이징을 메모리에서 한다...?
장애발생이 유력하다...

WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

실행 결과

Hibernate:
	/*select
    	t
    From
    	Team t
    join
    	fetch t.members m */ 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
result = 1

쿼리를 살펴보면 페이징 쿼리가 존재하지 않는다. -> 메모리에서 실행한다는 것을 확인할 수 있다.

  • page size = 1이면 팀 A의 회원 2명이 출력되는 것이 아니라 1명만 출력된다.
    - JPA는 팀 A의 결과가 2명이지만 1명밖에 없다고 출력한다.
    - 회원 2는 2 페이지에 있다.

해결책

  • @BatchSize
String jpql = "select ";

List<Team> result = em.createQuery(query, Team.class)
		.setFirstResult(0)
        .setMaxResult(2)
        .getResultList();
 
for (Team t : result) {
	System.out.println("team = " + team.getName() + "|members=" + team.getMembers()
 	for (Member member : team.getMembers()) {
    	System.out.println("-> member = " + member);
}

아래 코드를 실행할 경우

  1. 연관 관계에 있는 team을 페이징해서 프록시로 가져온다.
  2. team.getName() 을 호출할 때 팀 A의 회원들을 불러오는 SQL이 나간다.
  3. 팀 B의 회원들을 불러오는 SQL이 나간다.

이 경우 성능이 매우 좋지 않다. 팀이 여러개라면 그에 관련된 회원들을 불러올 때마다 새로운 SQL이 발생할 것을 예상할 수 있다.


따라서 페이징 때문에 fetch join을 사용하지 않을 때 @BatchSize를 이용한다.

@Entity
public class Team {
  
  ...

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
  
  ...
}

Team 엔티티에 @BatchSize를 지정하고 똑같은 코드를 실행하면 아래와 같은 결과가 발생한다.

select
	members...
from
	Member members0_
where
	members0_.TEAM_ID in (
    	?, ?
    )

팀을 가져올 때 지연 로딩으로 되어있는 회원에 대해 where team_id in (A, B)로 가져온다.

각 팀마다 회원을 조회하는 쿼리가 발생했던 상황에 비해 매우 간결한 것을 확인할 수 있다.

  • N + 1이 발생할 수 있는 상황에 fetch join을 사용하지만
    연관 관계가 컬렉션인 경우는 @BatchSize를 사용하면 된다.

엔티티에 @BatchSize어노테이션을 일일히 달아주지 않고 글로벌 옵션으로 사용할 수 있다.

resources/META-INF/persistence.xml

<property name="hibernate.default_batch_fetch_size" value="100" />

persistence.xml에 직접 해당 코드를 추가해준다.


페치 조인의 특징

  • 연관된 엔티티들을 SQL 한 번으로 조회 가능하다. -> 성능 최적화

  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선시된다.
    -> 글로벌 로딩 전략이
    @OneToMany(fetch = FetchType.LAZY) 지연로딩이더라도 페치 조인이 우선시 된다.

  • 실무에서 글로벌 로딩 전략은 모두 지연 로딩이다.
    -> 최적화가 필요할 때 페치 조인을 적용한다.


페치조인의 한계

  • 모든 것을 fetch join으로 해결할 수 없다.

  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.

  • 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야하면,
    페치 조인 보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적이다.


참고 :

김영한. 『자바 ORM 표준 JPA 프로그래밍』. 에이콘, 2015.

자바 ORM 표준 JPA 프로그래밍 - 기본편

profile
꾸준하게

0개의 댓글