[JPA] JPQL의 fetch join(페치 조인)

ttaho·2024년 1월 7일
2

JPA

목록 보기
7/7
post-thumbnail

Fetch Join(페치 조인)이란?

JPQl에서 성능 최적화를 위해 제공하는 기능으로, SQL의 조인과는 다르다.
Fetch Join을 사용하면 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회가 가능하다.

즉, 페치 조인은 성능 최적화에 주로 사용되며, N+1 문제를 해결하는 데 효과적이다.

Fetch Join이 필요한 예시를 살펴보자.

1:N 연관관계의 fetch join(페치 조인)

이전에 살펴봤던 Member(회원) 엔티티와 Team(팀) 엔티티가 있다.

해당 엔티티간의 관계는
회원:팀의 관계가 N:1이고, 연관관계는 지연 로딩 방식으로 구현되어있다.

Team teamA = new Team();
teamA.setName("팀A");
em.persist(teamA);

Team teamB = new Team();
teamB.setName("팀B");
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(teamA);
em.persist(member2);

Member member3 = new Member();
member3.setUsername("회원3");
member3.setTeam(teamB);
em.persist(member3);

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

String query = "select m from Member m";
List<Member> result = em.createQuery(query, Member.class)
        .getResultList();

for (Member member : result) {
    System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
}

위의 코드를 살펴 볼 때,
연관관계가 지연 로딩으로 구현되어 있으므로

List<Member> result = em.createQuery(query, Member.class)
        .getResultList();

해당 코드를 실행할 때 1번의 쿼리가 나가게된다.

for (Member member : result) {
   System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
}

그리고 위의 코드에서 member.getTeam.getName() 을 호출 할 때, 해당 Member의 Team을 조회하는 쿼리가 날라가게 된다. 쿼리는 아래와 같다.

Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.MEMBER_ID as member_i1_5_,
            member0_.age as age2_5_,
            member0_.TEAM_ID as team_id4_5_,
            member0_.username as username3_5_ 
        from
            Member member0_
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_11_0_,
        team0_.createdBy as createdb2_11_0_,
        team0_.createdDate as createdd3_11_0_,
        team0_.lastModifiedBy as lastmodi4_11_0_,
        team0_.lastModifiedDate as lastmodi5_11_0_,
        team0_.name as name6_11_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
member = 회원1,A
member = 회원2,A
Hibernate: 
    select
        team0_.TEAM_ID as team_id1_11_0_,
        team0_.createdBy as createdb2_11_0_,
        team0_.createdDate as createdd3_11_0_,
        team0_.lastModifiedBy as lastmodi4_11_0_,
        team0_.lastModifiedDate as lastmodi5_11_0_,
        team0_.name as name6_11_0_ 
    from
        Team team0_ 
    where
        team0_.TEAM_ID=?
member = 회원3,B

회원2의 경우에는 팀A가 회원1에 의해서 이미 영속성 컨텍스트의 1차캐시에 올라갔으므로 조회하는 쿼리를 발생시키지 않는다.

위의 경우에는 처음에 회원을 조회하는 쿼리 1번, N명 회원의 팀을 조회하는 쿼리 1번해서 총 1+N번의 쿼리가 나가게 되어 N+1 문제가 발생하므로, 성능상 매우 좋지 않다.

해당 문제를 해결하기 위해 fetch join(페치 조인)을 사용해보자.

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

String query = "select m from Member m join fetch m.team";
List<Member> result = em.createQuery(query, Member.class)
        .getResultList();

for (Member member : result) {
    System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
}


tx.commit();

위의 코드에서 jpql 페치 조인을 사용하였다.

Hibernate: 
    /* select
        m 
    from
        Member m 
    join
        fetch m.team */ select
            member0_.MEMBER_ID as member_i1_5_0_,
            team1_.TEAM_ID as team_id1_11_1_,
            member0_.age as age2_5_0_,
            member0_.TEAM_ID as team_id4_5_0_,
            member0_.username as username3_5_0_,
            team1_.createdBy as createdb2_11_1_,
            team1_.createdDate as createdd3_11_1_,
            team1_.lastModifiedBy as lastmodi4_11_1_,
            team1_.lastModifiedDate as lastmodi5_11_1_,
            team1_.name as name6_11_1_ 
        from
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.TEAM_ID
member = 회원1,A
member = 회원2,A
member = 회원3,B

페치 조인을 사용하였을 때, 쿼리문이 1번만 나가고, 회원 엔티티와 팀 엔티티를 모두 조회한 것을 확인할 수 있다.
위의 쿼리 결과는 아래와 같다.

1:N 관계, 컬렉션 페치 조인

일대다 관계에서 컬렉션을 페치 조인하면 어떻게 될지 살펴보자.

String query = "select t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
        .getResultList();

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


tx.commit();
Hibernate: 
    /* select
        t 
    from
        Team t 
    join
        fetch t.members */ select
            team0_.TEAM_ID as team_id1_11_0_,
            members1_.MEMBER_ID as member_i1_5_1_,
            team0_.createdBy as createdb2_11_0_,
            team0_.createdDate as createdd3_11_0_,
            team0_.lastModifiedBy as lastmodi4_11_0_,
            team0_.lastModifiedDate as lastmodi5_11_0_,
            team0_.name as name6_11_0_,
            members1_.age as age2_5_1_,
            members1_.TEAM_ID as team_id4_5_1_,
            members1_.username as username3_5_1_,
            members1_.TEAM_ID as team_id4_5_0__,
            members1_.MEMBER_ID as member_i1_5_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.TEAM_ID=members1_.TEAM_ID
team =A|2
team =A|2
team =B|1

위의 결과를 보면 이상한 점이 있다. 팀A가 2번 출력되었다.
해당 fetch join 결과를 보면 아래와 같다.

팀A의 회원이 2명 이기 때문에, 팀A의 결과가 2 Row가 나온다. 이것을 해결하기위해 DISTINCT를 사용해보자.

SQL과 JPQL의 DISTINCT

  • SQL의 DISTINCT는 중복된 결과를 제거하는 명령어
  • JPQL의 DISTINCT는 2가지 기능 제공
    1. SQL에 DISTINCT를 추가
    2. 애플리케이션에서 엔티티 중복 제거

JPQL에서 DISTINCT를 사용하게 되면 SQL에 DISTINCT를 추가하고, 엔티티의 중복 제거를 동시에 수행한다.
위의 회원과 팀 예시에서 같은 식별자를 가진 Team 엔티티를 삭제한다고 보면 된다.

String query = "select distinct t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
        .getResultList();

for (Team team : result) {
    System.out.println("team = " + team.getName() + "|" + team.getMembers().size());
}
Hibernate: 
    /* select
        distinct t 
    from
        Team t 
    join
        fetch t.members */ select
            distinct team0_.TEAM_ID as team_id1_11_0_,
            members1_.MEMBER_ID as member_i1_5_1_,
            team0_.createdBy as createdb2_11_0_,
            team0_.createdDate as createdd3_11_0_,
            team0_.lastModifiedBy as lastmodi4_11_0_,
            team0_.lastModifiedDate as lastmodi5_11_0_,
            team0_.name as name6_11_0_,
            members1_.age as age2_5_1_,
            members1_.TEAM_ID as team_id4_5_1_,
            members1_.username as username3_5_1_,
            members1_.TEAM_ID as team_id4_5_0__,
            members1_.MEMBER_ID as member_i1_5_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.TEAM_ID=members1_.TEAM_ID
team =A|2
team =B|1

해당 결과를 보면 중복이 제거된 것을 확인할 수 있다.

하지만 컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버린다.

이 것을 해결하기 위해선 어떻게 할까?

페이징 + 컬렉션 조회

대부분의 페이징 + 컬렉션 엔티티 조회 문제를 해결할 방법을 알아보자.

  1. 먼저 ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인한다.
    ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지
    않는다.
  2. 컬렉션은 지연 로딩으로 조회한다.
  3. 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.
    이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.

1. ToOne 관계 페치조인 하기

Order 엔티티를 보면 Member와 N:1, OrderItem 리스트와 1:N, Delivery와 1:1 관계가 있다.


Order 엔티티 기준 Member 엔티티와 N:1 관계, Delivery 엔티티와 1:1 관계 이므로 페치조인을 했다.


위는 쿼리이다. offset과 limit가 걸려있어 페이징이 된 쿼리가 날라가는 것을 볼 수 있다.

2. 컬렉션은 지연 로딩으로 조회


위의 컨트롤러의 메소드를 보면 Order 엔티티를 OrderDto로 변환하는데,

OrderDto는 OrderItem 리스트를 호출해서 지연로딩을 하게 된다. 이때 쿼리가 발생한다.

하지만 기존 쿼리와는 다르게 where문에 IN 쿼리에 총 두건의 Order에 대한 orderItems를 다 끌고온다.

또한 아래를 보면 OrderItemDto에서도 orderItem의 getItem을 호출하므로 Item 엔티티의 지연로딩도 발생한다.


2개의 OrderItems에 2개씩 Item이 있기때문에 총 4개의 IN 쿼리문이 발생한다.

3. Batch_SIZE

위의 방식은 application.yml의 hibernate.default_batch_fetch_size를 통해 가능하다.

정리

페치 조인에 대해 알아보았다.
페치 조인을 사용하여 조회 쿼리의 갯수를 줄여서 N+1 같은 문제의 성능이슈를 해결 할 수 있을 것 같다.

profile
백엔드 꿈나무

0개의 댓글