개발

JPQL - Fetch join? with 테스트 코드

primayy 2022. 4. 3. 20:20
728x90

Fetch Join?

  • SQL에서 이야기하는 조인의 종류가 아니라 JPQL에서 성능 최적화를 위해 제공하는 기능
  • 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능으로, join fetch 명령어로 사용할 수 있다.

예제를 진행하기에 앞서 사용할 클래스는 다음과 같다.

@Entity
@Table(name = "members")
public class Member{
  @Id
  @Column(name= "member_id")
  private Long id;
​
  @Column
  private String name;
​
  @ManyToOne(fetch = FetchType.Lazy) // 테스트를 위해 지연 로딩을 사용한다.
  @JoinColumn(name="team_id")
  private Team team;
}
​
@Entity
@Table(name = "teams")
public class Team{
  @Id
  @Column(name="team_id")
  private Long id;
​
  @Column
  private String name;
​
  @OneToMany(mappedBy="team")
  private List<Member> members;
}

Member와 Team은 N:1로 다대일 관계에 있다.

1. 엔티티 Fetch join

회원을 조회할 때, 연관된 팀을 함께 조회한다고 가정해보자.

그렇다면 두 테이블을 조회하기 위해서 join을 사용하게 될 것이고, 일반적으로 작성하는 JPQL은 다음과 같을 것이다.

select m from Member as m join m.team t

위처럼 작성한 JPQL은 select m.* from members as m inner join teams as t on m.team_id=t.team_id 로 바뀌어 실행되는데, 이때 조회 대상을 보면 m.*로써 Member 테이블에 해당하는 데이터만 조회하는 것을 볼 수 있다.

따라서 JPQL에서 회원과 연관된 팀을 조인한다고 해서 그 결과로 회원과 팀의 데이터가 조회될 것을 기대하면 안 된다.

(물론 select 문에 지정한 데이터가 m이기 때문에 당연한 결과라고 생각할 수 있다.)

 

실제로 위처럼 동작하는지 테스트 코드를 작성해서 확인해보자.

@Test
public void JPQL_기본_join_테스트() {
    EntityManager em = emf.createEntityManager();
    EntityTransaction transaction = em.getTransaction();
​
    transaction.begin();
    Team team = new Team();
    team.setId(1L);
    team.setName("팀1");
    em.persist(team);
​
    Member member = new Member();
    member.setId(1L);
    member.setName("회원1");
    member.setTeam(team);
    em.persist(member);
    transaction.commit();
    
    em.clear() // 캐시 초기화
​
    String jpql = "select m from Member as m join m.team t";
​
    TypedQuery<Member> query = em.createQuery(jpql, Member.class);
​
    List<Member> resultList = query.getResultList();
​
    Assertions.assertThat(resultList).isNotEmpty();
}
​
// 결과 - insert 문은 제외
... 
Hibernate: 
    select
        member0_.member_id as member_i1_3_,
        member0_.name as name2_3_,
        member0_.team_id as team_id3_3_ 
    from
        members2 member0_ 
    inner join
        teams team1_ 
            on member0_.team_id=team1_.team_id

따라서 위처럼 조회해서 나온 결과로 팀을 조회한다면, ManyToOne에 설정한 fetch 전략(Lazy)에 따라 지연 로딩이 발생하여 추가 쿼리가 발생할 것이다.

// ... 앞서 작성한 테스트 코드 마지막에 다음 코드를 추가해보자
for (Member m : resultList) {
    log.info("m.getTeam().getName()을 호출하면 추가 쿼리가 발생한다.");
    log.info("m.getTeam().getName(): {}", m.getTeam().getName());
}
​
// 결과
2022-04-03 19:24:12.825  INFO 5871 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getTeam().getName()을 호출하면 추가 쿼리가 발생한다.
Hibernate: 
    select
        team0_.team_id as team_id1_6_0_,
        team0_.name as name2_6_0_ 
    from
        teams team0_ 
    where
        team0_.team_id=?
2022-04-03 19:24:12.847  INFO 5871 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getTeam().getName(): 팀1

예상한 대로 팀을 조회할 때, 추가 쿼리가 발생했다.

 

이번에는 fetch join을 사용해서 결과를 확인해보자.

fetch join은 앞서 작성한 jpql 쿼리문의 join 뒤에 fetch만 추가로 작성하기만 하면 된다.

select m from Member as m join fetch m.team t

 

추가로 테스트 코드를 작성해서 다시 한번 확인해보자

@Test
public void JPQL_fetch_join_테스트() {
  EntityManager em = emf.createEntityManager();
  EntityTransaction transaction = em.getTransaction();
​
  transaction.begin();
  Team team = new Team();
  team.setId(1L);
  team.setName("팀1");
  em.persist(team);
​
  Member member = new Member();
  member.setId(1L);
  member.setName("회원1");
  member.setTeam(team);
  em.persist(member);
  transaction.commit();

  em.clear();
// jpql 쿼리문만 변경해주면 된다.
  String jpql = "select m from Member as m join fetch m.team t";
​
  TypedQuery<Member> query = em.createQuery(jpql, Member.class);
​
  List<Member> resultList = query.getResultList();
​
  Assertions.assertThat(resultList).isNotEmpty();
}
​
// 실행되는 쿼리는 다음과 같다.
Hibernate: 
    select
        member0_.member_id as member_i1_3_0_,
        team1_.team_id as team_id1_6_1_,
        member0_.name as name2_3_0_,
        member0_.team_id as team_id3_3_0_,
        team1_.name as name2_6_1_ 
    from
        members2 member0_ 
    inner join
        teams team1_ 
            on member0_.team_id=team1_.team_id

JPQL에서 select는 m으로 회원의 엔티티만 선택했지만, 실행된 SQL을 보면 이전과는 다르게 연관된 팀까지 함께 조회하는 것을 확인할 수 있다.

또한 연관된 팀을 조회하는 코드를 추가한다고 하더라도 조회 당시에 팀까지 같이 조회가 되었으므로, 추가 쿼리는 발생하지 않는다.

@Test
public void JPQL_fetch_join_확인() {
    EntityManager em = emf.createEntityManager();
    EntityTransaction transaction = em.getTransaction();
​
    transaction.begin();
    Team team = new Team();
    team.setId(1L);
    team.setName("팀1");
    em.persist(team);
​
    Member member = new Member();
    member.setId(1L);
    member.setName("회원1");
    member.setTeam(team);
    em.persist(member);
    transaction.commit();
​
    em.clear();
​
    String jpql = "select m from Member as m join fetch m.team t";
​
    TypedQuery<Member> query = em.createQuery(jpql, Member.class);
​
    List<Member> resultList = query.getResultList();
​
    Assertions.assertThat(resultList).isNotEmpty();
​
    for (Member m : resultList) {
        log.info("m.getTeam().getName()을 호출해도 추가 쿼리가 발생하지 않는다.");
        log.info("m.getTeam().getName(): {}", m.getTeam().getName());
    }
}
​
// 결과
Hibernate: 
    select
        member0_.member_id as member_i1_3_0_,
        team1_.team_id as team_id1_6_1_,
        member0_.name as name2_3_0_,
        member0_.team_id as team_id3_3_0_,
        team1_.name as name2_6_1_ 
    from
        members2 member0_ 
    inner join
        teams team1_ 
            on member0_.team_id=team1_.team_id
2022-04-03 19:27:46.023  INFO 6120 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getTeam().getName()을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:27:46.023  INFO 6120 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getTeam().getName(): 팀1

즉, 회원을 조회할 때 fetch join을 사용하여 연관된 팀도 함께 조회되었으므로, 팀 엔티티는 프록시가 아니라 실제 엔티티이다.

따라서 회원 엔티티가 영속성 콘텍스트에서 detach된다고 하더라도 연관된 팀은 여전히 조회할 수 있다.

 

정말로 그런지 테스트 코드를 작성해서 확인해보자

@Test
public void JPQL_fetch_join_detach후_조회_테스트() {
    EntityManager em = emf.createEntityManager();
    EntityTransaction transaction = em.getTransaction();
​
    transaction.begin();
    Team team = new Team();
    team.setId(1L);
    team.setName("팀1");
    em.persist(team);
​
    Member member = new Member();
    member.setId(1L);
    member.setName("회원1");
    member.setTeam(team);
    em.persist(member);
    transaction.commit();
​
    em.clear();
​
    String jpql = "select m from Member as m join fetch m.team t";
​
    TypedQuery<Member> query = em.createQuery(jpql, Member.class);
​
    List<Member> resultList = query.getResultList();
​
    assertThat(resultList).isNotEmpty();
​
    for (Member m : resultList) {
        log.info("멤버를 준영속 상태로 만든다.");
        em.detach(m);
​
        log.info("준영속 상태로 만든 멤버를 사용하여 m.getTeam().getName()을 호출해도 추가 쿼리가 발생하지 않고 오류 또한 발생하지 않는다.");
        org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> {
            log.info("m.getTeam().getName(): {}", m.getTeam().getName());
        });
    }
}
​
// 준영속 이후 조회 결과
Hibernate: 
    select
        member0_.member_id as member_i1_3_0_,
        team1_.team_id as team_id1_6_1_,
        member0_.name as name2_3_0_,
        member0_.team_id as team_id3_3_0_,
        team1_.name as name2_6_1_ 
    from
        members2 member0_ 
    inner join
        teams team1_ 
            on member0_.team_id=team1_.team_id
2022-04-03 19:34:31.021  INFO 6564 --- [           main] com.example.demo.jpaTests.JpaTests       : 멤버를 준영속 상태로 만든다.
2022-04-03 19:34:31.025  INFO 6564 --- [           main] com.example.demo.jpaTests.JpaTests       : 준영속 상태로 만든 멤버를 사용하여 m.getTeam().getName()을 호출해도 추가 쿼리가 발생하지 않고 오류 또한 발생하지 않는다.
2022-04-03 19:34:31.035  INFO 6564 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getTeam().getName(): 팀1

준영속 상태에서 연관된 팀을 조회하더라도 오류와 추가 쿼리 없이 팀을 조회하는 모습을 확인할 수 있다.

2. 컬렉션 Fetch join

이번에는 반대로 팀에서 멤버를 조회하는 일대다 관계에서 Fetch join을 해보자

jpql은 다음과 같이 작성하면 된다.

select t from Team as t join fetch t.members m

@Test
public void OneToMany_fetch_join_테스트() {
    EntityManager em = emf.createEntityManager();
    EntityTransaction transaction = em.getTransaction();
​
    transaction.begin();
    Team team = new Team();
    team.setId(1L);
    team.setName("팀1");
    em.persist(team);
​
    for (int i = 1; i <= 10; i++) {
        Member member = new Member();
        member.setId((long) i);
        member.setName(String.format("멤버%s", i));
        member.setTeam(team);
        em.persist(member);
    }
​
    transaction.commit();
​
    em.clear();
​
    String jpql = "select t from Team as t join fetch t.members m";
    TypedQuery<Team> query = em.createQuery(jpql, Team.class);
​
    List<Team> resultList = query.getResultList();
​
    for (Team t : resultList) {
        List<Member> members = t.getMembers();
        for (Member m : members) {
            log.info("m.getName을 호출해도 추가 쿼리가 발생하지 않는다.");
            log.info("m.getName: {}", m.getName());
        }
    }
}

// 결과
Hibernate: 
    select
        team0_.team_id as team_id1_6_0_,
        members1_.member_id as member_i1_3_1_,
        team0_.name as name2_6_0_,
        members1_.name as name2_3_1_,
        members1_.team_id as team_id3_3_1_,
        members1_.team_id as team_id3_3_0__,
        members1_.member_id as member_i1_3_0__ 
    from
        teams team0_ 
    inner join
        members2 members1_ 
            on team0_.team_id=members1_.team_id
2022-04-03 19:42:47.182  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.182  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버1
2022-04-03 19:42:47.182  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.182  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버2
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버3
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버4
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버5
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버6
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버7
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버8
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버9
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 19:42:47.183  INFO 7103 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버10
...
// 수많은 로그들이 출력되었다. 왤까?

SQL 실행 결과를 보면 팀과 연관된 회원을 같이 select 하는 모습을 확인할 수 있다.

그러나 조회된 팀을 순회하면서 멤버를 조회하면, 생각했던 것보다 많은 양의 로그가 출력되는 것을 볼 수 있다.

 

그 이유는, 일대다 조인으로 인해 row의 수가 증가했기 때문이다.

팀1에 소속되어있는 회원들은 10명으로, 팀 join 회원을 하게 되면 아마 다음과 같은 결과가 나올 것이다.

id team_name member_id team_id(fk) member_name
1 팀1 1 1 멤버1
1 팀1 2 1 멤버2
1 팀1 3 1 멤버3
...        
1 팀1 10 1 멤버10

즉, 팀1이 중복되어 10개가 나온 것이다. 그렇다면 중복된 데이터는 어떻게 제거할 수 있을까?

fetch join과 distinct

SQL에서 distinct는 중복된 결과를 제거한다. 그러나 위의 표에서 조회된 row들은 완전히 동일하지 않기 때문에 효과가 없다.

하지만 JPQL에서 distinct를 사용하면 SQL에 distinct를 추가하는 것은 물론이고 애플리케이션에서 한번 더 중복을 제거한다.

 

정말로 중복된 데이터를 제거하는지 테스트 코드를 작성해서 확인해보자

jpql은 다음과 같이 작성하면 된다. select distinct t from Team as t join fetch t.members m

@Test
public void OneToMany_fetch_join_distinct_테스트() {
    EntityManager em = emf.createEntityManager();
    EntityTransaction transaction = em.getTransaction();
​
    transaction.begin();
    Team team = new Team();
    team.setId(1L);
    team.setName("팀1");
    em.persist(team);
​
    for (int i = 1; i <= 10; i++) {
        Member member = new Member();
        member.setId((long) i);
        member.setName(String.format("멤버%s", i));
        member.setTeam(team);
        em.persist(member);
    }
​
    transaction.commit();
​
    em.clear();
​
    String jpql = "select distinct t from Team as t join fetch t.members m";
    TypedQuery<Team> query = em.createQuery(jpql, Team.class);
​
    List<Team> resultList = query.getResultList();
​
// 중복 데이터가 제거될 것을 기대
    assertThat(resultList.size()).isEqualTo(1);
    for (Team t : resultList) {
        List<Member> members = t.getMembers();
        for (Member m : members) {
            log.info("m.getName을 호출해도 추가 쿼리가 발생하지 않는다.");
            log.info("m.getName: {}", m.getName());
        }
    }
}
​
// 결과
Hibernate: 
    select
        distinct team0_.team_id as team_id1_6_0_,
        members1_.member_id as member_i1_3_1_,
        team0_.name as name2_6_0_,
        members1_.name as name2_3_1_,
        members1_.team_id as team_id3_3_1_,
        members1_.team_id as team_id3_3_0__,
        members1_.member_id as member_i1_3_0__ 
    from
        teams team0_ 
    inner join
        members2 members1_ 
            on team0_.team_id=members1_.team_id
2022-04-03 20:00:17.366  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.366  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버1
2022-04-03 20:00:17.367  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.367  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버2
2022-04-03 20:00:17.367  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.367  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버3
2022-04-03 20:00:17.367  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.367  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버4
2022-04-03 20:00:17.367  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.367  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버5
2022-04-03 20:00:17.368  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.368  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버6
2022-04-03 20:00:17.368  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.368  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버7
2022-04-03 20:00:17.368  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.368  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버8
2022-04-03 20:00:17.368  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.368  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버9
2022-04-03 20:00:17.368  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName을 호출해도 추가 쿼리가 발생하지 않는다.
2022-04-03 20:00:17.370  INFO 8215 --- [           main] com.example.demo.jpaTests.JpaTests       : m.getName: 멤버10

실제로 실행되는 SQL 쿼리에 distinct가 추가되었고, 조회된 팀의 개수도 10개가 아니라 1개인 것을 확인할 수 있다.

Fetch Join의 한계

Fetch join을 사용하면 연관된 데이터를 함께 조회할 수 있기 때문에, SQL 호출 횟수를 줄여 성능을 최적화할 수 있다.

그러나 다음과 같은 한계가 존재한다.

  • 별칭을 줄 수 없다.
  • 둘 이상의 컬렉션을 fetch 할 수 없다.
  • 컬렉션을 fetch join 하면 페이징을 사용할 수 없다.

컬렉션을 fetch join 하면 페이징을 사용할 수 없다. 이 부분은 다음에 따로 정리해보려고 한다.

오늘은 여기까지~ 그럼 모두들 안뇽~

 

참고

  • 자바 ORM 표준 JPA 프로그래밍
  • http://www.kyobobook.co.kr/product/detailViewKor.laf?mallGb=KOR&ejkGb=KOR&barcode=9788960777330
728x90
반응형