JPA(Java Persistence API)

[JPA] Part 16. Fetch JOIN (JPQL)

cwchoiit 2023. 10. 30. 09:51
728x90
반응형
SMALL
728x90
반응형
SMALL

페치 조인은 JPQL에서 엄청 엄청 중요한 내용 중 하나라는 생각이 든다. 왜 그러냐면 이 페치 조인이 연관된 엔티티를 SQL 한 번에 다 가져올 수 있는 방법인데 이게 필요한 경우가 상당수 존재하며, 이 방식을 사용하지 않은 상태에서 지연 로딩을 걸고 필요할 때마다 가져오는 방식만을 사용한다면 가져와야 하는 연관 엔티티가 필요할 때마다 SQL이 계속 나가게 된다. 가장 쉬운 예시로 멤버를 조회할 때 멤버가 속한 팀까지 알아와야 하는 경우에 페치 조인을 사용하지 않고 지연 로딩인 상태에서 멤버 100명을 조회하면 멤버 100명을 조회하는 쿼리 한 번이 우선 나가게 된다. 여기서 팀은 같이 조회되지 않는다 지연 로딩이기 때문에. 그리고 실제 멤버의 팀을 호출하는 순간에 팀을 알아오는 SQL이 나가는데 멤버가 100명이니까 100번이 나가게 된다. (만약, 멤버 100명 모두가 다른 팀이라면, 같은 팀이 하나라도 있다면 사전에 먼저 조회된 팀은 1차 캐시에 남아있으니 SQL이 또 나가지는 않는다.) 즉, N + 1 문제가 지연로딩이어도 발생할 수 있다는 얘기다. 그 문제를 해결해 주는 방법이 이 페치 조인이다. 

 

사용법도 너무 간단하다. 아래처럼 JOIN FETCH로 조인할 연관 엔티티만 호출하면 된다.

SELECT m FROM Member m JOIN FETCH m.team

이 JPQL은 다음과 같은 SQL로 변환되어 나간다.

SELECT m.*, t.* FROM Member m INNER JOIN TEAM t ON m.team_id = t.id

m.*, t.*은 멤버와 팀의 모든 필드를 축약해서 작성한 것 뿐이고 그냥 내부 조인이 발생한다. 내부 조인이 발생하면 멤버와 팀의 데이터를 다 가져오기 때문에 한 번에 모든 데이터를 가져올 수 있는데 이게 사실 즉시 로딩이다. 그러나 즉시 로딩은 사용하면 안된다고 했었는데 즉시 로딩은 내가 멤버를 조회할 때 팀을 같이 조회할 필요가 없음에도 조인으로 가져오기 때문이었다. 그래서 지연 로딩이 기본으로 적용되어야 한다고 했는데 지연 로딩일 때 멤버를 조회하면 팀을 같이 조회해야 하는 경우 이 한 번의 쿼리로 모든 걸 다 해결할 수 있게 하는 것.

 

 

 

실제 사용 코드를 예시로 보자.

FETCH JOIN을 사용하지 않았을 때

package org.example;

import org.example.entity.fetchjoin.Member;
import org.example.entity.fetchjoin.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.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();

        // ! Transaction 시작
        tx.begin();

        try {

            for (int i = 0; i < 3; i++) {
                Team team = new Team("TEAM" + i);

                Member member = new Member("MEMBER" + i, i, team);
                em.persist(member);
            }

            em.flush();
            em.clear();

            List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
                    .getResultList();

            for (Member member : members) {
                System.out.println("member username = " + member.getUsername());
                System.out.println("member team = " + member.getTeam().getName());
            }

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

멤버와 그 멤버가 속할 팀을 루프안에서 만든다. 3명의 멤버가 각각 다른 팀에 속해있다. 멤버를 영속 컨텍스트에 영속시킨 후 루프가 끝난 후 영속 컨텍스트를 플러시 한다. 그 후에 모든 멤버를 가져와 각 멤버의 정보를 출력한다. 

 

이때 발생되는 결과 SQL쿼리를 확인해 보자.

Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_0_,
        member0_.age as age2_0_,
        member0_.TEAM_ID as team_id4_0_,
        member0_.username as username3_0_ 
    from
        Member member0_
member username = MEMBER0
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
member team = TEAM0
member username = MEMBER1
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
member team = TEAM1
member username = MEMBER2
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_1_0_,
        team0_.name as name2_1_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
member team = TEAM2

나가는 SQL문에 집중해 보자. 최초에 멤버를 전부 조회하는 쿼리가 한 번 나간다. 합리적이다. 그런데 멤버의 팀을 찍으려고 할 때마다 팀을 조회하는 쿼리가 매번 나간다. 왜냐하면 멤버를 가져왔을 때 멤버가 가지고 있는 팀은 엔티티가 아닌 프록시이기 때문에 실제 사용하는 시점에 프록시에 엔티티 값을 집어넣어 줘야 하므로. 보면 팀 3개를 출력하기 위해 3번의 SQL문이 나간 게 보이는가? 이게 N+1 문제다. 지연로딩이든 즉시로딩이든 어떻게 사용하느냐에 따라 N+1 문제는 발생할 수 있게 된다.

 

그럼 지연로딩이었던 것을 즉시로딩으로 바꿀까? 절대 안 된다. 이 경우는 멤버를 조회할 때 팀도 조회하는 비즈니스 로직이기 때문에 이렇게 팀을 모두 조회하지만 그렇지 않은 로직을 타는 경우에는 불 필요한 조인이 발생하면 안된다. 이걸 해결해 주는 것이 페치조인이다.

 

FETCH JOIN을 사용할 때

package org.example;

import org.example.entity.fetchjoin.Member;
import org.example.entity.fetchjoin.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.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();

        // ! Transaction 시작
        tx.begin();

        try {

            for (int i = 0; i < 3; i++) {
                Team team = new Team("TEAM" + i);

                Member member = new Member("MEMBER" + i, i, team);
                em.persist(member);
            }

            em.flush();
            em.clear();

            List<Member> members = em.createQuery("SELECT m FROM Member m JOIN FETCH m.team",
            Member.class).getResultList();

            for (Member member : members) {
                System.out.println("member username = " + member.getUsername());
                System.out.println("member team = " + member.getTeam().getName());
            }

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

코드에서 변경된 부분은 딱 하나. 쿼리문에 JOIN FETCH가 추가된 것뿐이다.

그러나 결과는 엄청난 차이가 있다. 결과문을 보자.

Hibernate: 
    select
        member0_.MEMBER_ID as member_i1_0_0_,
        team1_.TEAM_ID as team_id1_1_1_,
        member0_.age as age2_0_0_,
        member0_.TEAM_ID as team_id4_0_0_,
        member0_.username as username3_0_0_,
        team1_.name as name2_1_1_ 
    from
        Member member0_ 
    inner join
        Team team1_ 
            on member0_.TEAM_ID=team1_.TEAM_ID
member username = MEMBER0
member team = TEAM0
member username = MEMBER1
member team = TEAM1
member username = MEMBER2
member team = TEAM2

SQL문 딱 하나에 모든 데이터가 들어오고 한 번에 모두 조회가 가능해졌다. 위에서 고작 멤버 3명을 조회하기 위해 쿼리 4번이 나간 반면, 지금은 단 한 번의 쿼리로 모든 걸 만족시켰다. 이게 페치 조인이다. 

 

 

엔티티 타입을 페치 조인해봤으니 컬렉션 값 타입도 페치 조인해보자. 살짝 다르고 알아야 할 부분이 있다. 

 

 

컬렉션 값 타입 페치 조인

당연히 ManyToOne에서 페치 조인이 가능하면 OneToMany에서도 페치 조인이 가능할건데 이 일대다에서 조인을 했을 때 조심할 부분이 있다. 우선 페치 조인으로 데이터를 가져와보자. 이건 페치 조인을 떠나서 그냥 일대다 입장에서 조인을 할 때 항상 조심할 부분이다.

 

package org.example;

import org.example.entity.fetchjoin.Member;
import org.example.entity.fetchjoin.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.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();

        // ! Transaction 시작
        tx.begin();

        try {
            Team teamA = new Team("TeamA");
            Team teamB = new Team("TeamB");
            for (int i = 0; i < 3; i++) {
                Member member;
                if (i % 2 == 0) {
                    member = new Member("MEMBER" + i, i, teamA);
                } else {
                    member = new Member("MEMBER" + i, i, teamB);
                }
                em.persist(member);
            }

            em.flush();
            em.clear();

            List<Team> teams = em.createQuery("SELECT t FROM Team t JOIN FETCH t.members", Team.class)
                    .getResultList();

            for (Team team : teams) {
                System.out.println("team = " + team.getName() + "||members size: " + team.getMembers().size());
            }

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

유심히 볼 부분은 팀은 'TeamA', 'TeamB' 두개만 존재하고 멤버가 3명인 점이다. 그리고 조회한 팀별로 팀의 이름과 멤버수를 출력한다. 결과를 보자.

Hibernate: 
    select
        team0_.TEAM_ID as team_id1_1_0_,
        members1_.MEMBER_ID as member_i1_0_1_,
        team0_.name as name2_1_0_,
        members1_.age as age2_0_1_,
        members1_.TEAM_ID as team_id4_0_1_,
        members1_.username as username3_0_1_,
        members1_.TEAM_ID as team_id4_0_0__,
        members1_.MEMBER_ID as member_i1_0_0__ 
    from
        Team team0_ 
    inner join
        Member members1_ 
            on team0_.TEAM_ID=members1_.TEAM_ID
team = TeamA||members size: 2
team = TeamB||members size: 1
team = TeamA||members size: 2

역시, 단 한번의 쿼리로 모든걸 해결했지만 이상한 점이 있다. 팀A가 두번 찍힌다는 점이다. 즉, 가져온 teams에 3개의 레코드가 있다는 의미다. 왜 그럴까? 일대다 입장에서의 조인이기 때문이다.

 

아래 그림을 보면 이해가 된다.

팀을 기준으로 멤버와 조인하게 되면 팀A에 소속된 멤버가 두명이니까 두개의 레코드를 가진다. 이렇기에 팀A가 위처럼 두번 출력되는 것이다. 이렇게 일대다 조인은 데이터가 불어나게 되는데 이를 해결하기 위해서는 JPA에서는 해줄 수 있는게 없다. JPA입장에서는 이 데이터가 어떻게 만들어질지 전혀 알 지 못하는 상태이기 때문에.

 

그래서 DB로부터 조인한 쿼리로 받은 데이터는 이렇게 두 개가 들어오고 JPA는 그것을 객체로 표현해주기 때문에 다음과 같은 그림이 된다.

그래서 우선은 받은 객체가 두개니까 메모리에 주소값이 두개가 할당된다. 그러나, 그 두개의 객체는 같은 객체이므로 바라보는 주소는 같다. 영속 컨텍스트에는 같은 객체라면 하나만을 사용하면 되므로 영속 컨텍스트에는 하나의 객체만 있지만 조회한 "teams" 컬렉션에는 두개가 담겨있는 것. 그럼 중복을 제거하고 싶을 땐 어떻게 할까? DISTINCT를 사용하면 된다.

 

허나, SQL의 DISTINCT는 위 문제를 해결해주지 않는다. SQL에서 DISTINCT는 중복된 결과(완전히 똑같은)를 제거하는 명령이기 때문에 두 레코드를 중복으로 바라보지 않는다. 중복이 아니잖아. 레코드 기준으로 회원아이디도 회원이름도 다르니까. 그러나 JPQL의 DISTINCT는 2가지의 기능을 제공해 주는데 하나는 SQL에 DISTINCT를 추가해주는 작업을 해주고, 하나는 애플리케이션에서 엔티티 중복을 제거해준다. 이 엔티티 중복을 제거해줄 때 위 상황 같은 중복이 제거가 된다.

 

그래서 결론은 DISTINCT 예약어를 추가해보자. 

List<Team> teams = em.createQuery("SELECT DISTINCT t FROM Team t JOIN FETCH t.members", Team.class)
                    .getResultList();

결과도 중복이 제거되어 출력된다.

Hibernate: 
    select
        distinct team0_.TEAM_ID as team_id1_1_0_,
        members1_.MEMBER_ID as member_i1_0_1_,
        team0_.name as name2_1_0_,
        members1_.age as age2_0_1_,
        members1_.TEAM_ID as team_id4_0_1_,
        members1_.username as username3_0_1_,
        members1_.TEAM_ID as team_id4_0_0__,
        members1_.MEMBER_ID as member_i1_0_0__ 
    from
        Team team0_ 
    inner join
        Member members1_ 
            on team0_.TEAM_ID=members1_.TEAM_ID
team = TeamA||members size: 2
team = TeamB||members size: 1

 

 

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

일반 조인과 페치 조인은 어떤점이 다를까? 일반 조인은 조인을 하더라도 연관된 엔티티를 바로 조회하지 않는다.

코드로 보는게 제일 빠르니 코드로 봐보자.

package org.example;

import org.example.entity.fetchjoin.Member;
import org.example.entity.fetchjoin.Team;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.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();

        // ! Transaction 시작
        tx.begin();

        try {
            Team teamA = new Team("TeamA");
            Team teamB = new Team("TeamB");
            for (int i = 0; i < 3; i++) {
                Member member;
                if (i % 2 == 0) {
                    member = new Member("MEMBER" + i, i, teamA);
                } else {
                    member = new Member("MEMBER" + i, i, teamB);
                }
                em.persist(member);
            }

            em.flush();
            em.clear();

            List<Team> teams = em.createQuery("SELECT t FROM Team t JOIN t.members m", Team.class)
                    .getResultList();

            for (Team team : teams) {
                System.out.println("team = " + team.getName() + "||members size: " + team.getMembers().size());
            }

            tx.commit();
        } catch (Exception e) {
            e.printStackTrace();
            tx.rollback();
        } finally {
            em.close();
            emf.close();
        }
    }
}

보면 일반 조인으로 쿼리를 날렸을 때 실행 결과는 어떻게 될까?

Hibernate: 
    select
        team0_.TEAM_ID as team_id1_1_,
        team0_.name as name2_1_ 
    from
        Team team0_ 
    inner join
        Member members1_ 
            on team0_.TEAM_ID=members1_.TEAM_ID
Hibernate: 
    select
        members0_.TEAM_ID as team_id4_0_0_,
        members0_.MEMBER_ID as member_i1_0_0_,
        members0_.MEMBER_ID as member_i1_0_1_,
        members0_.age as age2_0_1_,
        members0_.TEAM_ID as team_id4_0_1_,
        members0_.username as username3_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
team = TeamA||members size: 2
team = TeamA||members size: 2
Hibernate: 
    select
        members0_.TEAM_ID as team_id4_0_0_,
        members0_.MEMBER_ID as member_i1_0_0_,
        members0_.MEMBER_ID as member_i1_0_1_,
        members0_.age as age2_0_1_,
        members0_.TEAM_ID as team_id4_0_1_,
        members0_.username as username3_0_1_ 
    from
        Member members0_ 
    where
        members0_.TEAM_ID=?
team = TeamB||members size: 1

최초에 조인으로 멤버까지 가져오지만 SELECT절엔 팀 관련 데이터밖에 없다. 그렇기 때문에 내가 가져온 팀들에서 멤버를 조회하려고 할 때 다시 멤버관련 SQL문이 나가게 된다. 즉, 일반 조인은 일반 조인 과정에서 관련 엔티티 데이터를 퍼올리지 않는다. 프록시와 비슷한 상태인거지. 그래서 결국 멤버까지 가져오려면 다시 쿼리를 날리게 된다. 이런 차이가 있다.

 

 

페치 조인 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다.(하이버네이트는 가능하나 가급적 사용하지 않아야 한다). 

진짜로 페치 조인을 몇단계로 가져가야 할 경우 아닌 이상 절대 쓰지 않기로 하자. 별칭을 가져가야 할 경우가 있다고 생각이 들면 그땐 다른 쿼리로 해결할 수 있는 경우가 무조건 있다고 생각해라.

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

예를 들어, 팀이 '멤버'라는 일대다 관계를 가지고 있고, 또 다른 뭐 '업무분야'라는 일대다 관계를 가지고 있을 때 이 두개를 같이 페치 조인하면 안된다. 그 이유는 위에서 봤지만 일대다만 해도 데이터가 부풀려 나오는데 이 경우는 일대다대다이다. 더 심한 데이터 부풀림 현상이 일어날 가능성이 농후하고 데이터 정합성에 문제가 발생할 수 있기 때문에 안된다.

  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.

위 내용과 비슷한데 일대다 페치 조인일 때 데이터 부풀림 현상이 일어나는데 이것을 페이징한다? 데이터 정합성에 문제가 생길 가능성 99%. 그냥 하면 안된다. 근데 문제는 하이버네이트는 경고 로그를 남기고 메모리에서 페이징을 결국은 해준다는 것이다. 절대 절대 사용하면 안된다. 그냥 일대다 페치 조인을 할거면 다대일로 페이징을 하면 된다.

 

 

마무리

  • 모든 것을 페치 조인으로 해결할 수는 없다.
  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. (멤버와 팀이 있을 때 멤버가 가진 필드들, 멤버의 팀이 가진 필드들 그대로를 반환하는 데이터를 받는 그런 경우를 말함. 객체 그래프가 member.username, member.age, member.team 이런거니까)
  • 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면 페치 조인보다는 일반 조인을 사용해서 필요한 데이터들만 조회해서 DTO(new로 패키지명까지 쭉 작성해서 반환하는 방법을 말하는 것)로 반환하는 것이 효과적

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] Part 18. 벌크 연산  (0) 2023.10.30
[JPA] Part 17. Named Query  (2) 2023.10.30
[JPA] Part 15. JPQL  (0) 2023.10.28
[JPA] Part 14. 컬렉션 값 타입  (0) 2023.10.25
[JPA] Part 13. 임베디드 타입  (0) 2023.10.23