페치 조인(Fetch join)

땡글이·2023년 3월 13일
0

JPA

목록 보기
5/10

JPA로 개발할 때 가장 중요한 개념 중 하나이다. 페치 조인(Fetch join)을 알아야 JPA의 성능 최적화를 시도해볼 수 있게 된다. 페치 조인에 대해 간단히 알아보면서 페치 조인을 사용하지 않으면 어떤 문제가 일어나는지 살펴본다.

지연로딩과 N+1 문제

페치 조인에 대해 간단히 정리해보면 다음과 같다.

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

가상 시나리오

회원과 팀 엔티티가 있고, 회원:팀의 관계가 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());
}
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

우선 지연로딩 방식으로 구현되어 있기 때문에, Member 엔티티를 조회하더라도 Team 엔티티는 프록시로 조회하게 된다. 그리고 Team 엔티티의 필드에 접근할 때 실제로 SQL문이 나가서 DB에 접근하게 된다.

회원1 : DB (지연로딩)
회원2 : 1차캐시 (영속성 컨텍스트)
회원3 : DB (지연로딩)
  • 회원1이 속한 팀인 팀A를 조회하면서 영속성 컨텍스트에 팀A를 저장해둔다.
  • 회원2가 속한 팀의 이름에 접근할 때, 영속성 컨텍스트에 있으므로 DB가 아닌 1차 캐시에서 조회된다.
  • 회원3이 속한 팀인 팀B는 영속성 컨텍스트에 존재하지 않아서, DB로 쿼리문을 날리게 된다.

결론적으로, 쿼리가 총 3번 나가게 되었다. 이렇게 되면 관련된 엔티티의 데이터 개수만큼 쿼리가 나가서 의도치 않은 성능 저하를 야기할 수 있다.
문제가 바로 N+1 문제 이다. N+1 문제를 해결하기 위해 페치 조인을 사용해야 한다.

페치 조인을 사용하면?

// ..
// Member, Team 세팅
// ..

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();
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

Member 엔티티들을 모두 조회하면서 한방쿼리로 연관된 엔티티인 Team 엔티티까지 조회하게 됐다. 즉, 페치 조인(Fetch join)을 이용해N+1 문제를 해결했다.

컬렉션 페치 조인

일대다 관계에서 컬렉션 페치 조인을 하게 되면 어떻게 될까?

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번 조회되지??
이것이 컬렉션 페치 조인에서 주의해야할 점이다! 일대다 조인은 뻥튀기(?)되는 문제가 발생할 수 있다. 즉, 같은 데이터가 중복 조회되는 문제가 있으니 주의해야한다.

일대다 조인에서의 중복 조회

아래의 그림처럼 조회되는 것이다. 실제 팀A 데이터는 하나지만, 조회할 때 Member 엔티티와 조인되므로 중복 조회되는 문제가 발생하는 것이다. 실제 DB에서의 조인 실행 결과는 "[TEAM JOIN MEMBER]"와 같다.
즉 Member 의 데이터가 다르게 조회됨으로써 다른 로우가 되지만, JPA에서 전체 필드가 아닌 부분적으로 조회한 결과는 같은 결과를 가지는 로우가 2개가 돼서, 중복 조회되는 것이다.

페치 조인과 DISTINCT

중복조회되는 문제를 해결하려면, DISTINCT 명령어를 활용해서 중복 로우를 제거해주면 된다.

하지만 위의 그림처럼 SQL을 실행했을 때, 서로가 다른 결과라고 하면 DISTINCT 명령어를 써도 제거되지 않는다.
즉, SQL에 DISTINCT 를 추가해도, 데이터가 다르므로 SQL 결과에서 중복 조회를 제거하는 데에 실패한다.

그래서, JPA에서는 DISTINCT가 DB에서 뿐만 아니라, 애플리케이션 레벨에서도 중복 제거를 시도한다.
즉, 같은 식별자를 가진 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

정리하면, JPQL에서의 DISTINCT 명령어는 다음의 2가지 기능을 가진다.

  • SQL에 DISTINCT 추가
  • 애플리케이션에서 엔티티 중복 제거

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

일반 조인을 실행할 때에는, 연관된 엔티티를 함께 조회하지 않는다. 하지만, 페치 조인을 실행하면 연관된 엔티티도 조회하게 된다.

무슨 말이냐하면, 페치 조인을 하게되면 연관된 엔티티의 로우에 대한 정보도 가져오게 되지만, 일반 조인을 하게 되면 select 절에 명시한 테이블 혹은 로우에 대한 정보만 가져오게 된다. 아래의 예시는 페치 조인과 일반 조인의 sql문이다. 차이를 확인해보시기 바랍니다.

[일반 조인]
select m from Member m join m.team

[SQL]
select m.id, m.name, m.email from Member m
inner join Team t on m.team_id = t.id
[페치조인]
select m from Member m join fetch m.team

[SQL]
select m.id, m.name, m.email, t.id, t.name from Member m
inner join Team t on m.team_id = t.id

select 절에서 가져오는 데이터가 다르다! 그렇기에 페치 조인을 활용하게 되면 지연 로딩으로 연관관계가 설정되어 있어도, 일반 조인과는 달리 다대일 관계 혹은 일대일 관계의 객체가 초기화될 수 있는 것이다!

페치 조인과 JPQL

JPQL은 결과를 반환할 때 연관관계를 고려하지 않는다. 단지 Select 절에 지정된 엔티티만 조회할 뿐이다. 바로 위의 예제에서 일반 조인으로 실행하게 되면 Team 엔티티만 조회하게 되고, Member 엔티티는 조회하지 않는다.

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


페치조인의 특징과 한계

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

하이버네이트는 가능하지만, 가급적 사용하지 않는 것이 좋다. 페치 조인은 조회하려는 테이블과 연관된 객체를 모두 다 가져오는 것이다.
만약 팀A인 member들 중 10살 이상인 member들을 조회한다고 할 경우에 문제가 생길 수 있다. Cascade 옵션이나 orphanRemoval 옵션 설정을 통해 컬렉션에 들어있는 값들만으로 어플리케이션의 객체와 DB의 상태 일관성이 깨질 수 있다.

상태 일관성이 깨질 수 있다??

이 말의 의미는, 객체에서의 상태와 DB의 상태가 다르다는 것을 의미한다. 즉, fetch join을 통해 Team에 속한 member 리스트를 가져올 때 어플리케이션에서는 member 가 1명이라고 나오지만, 실제 DB에서는 해당 팀에 속하는 member가 2명이면 상태 일관성이 깨진 것이다.
JPA에서 Fetch join은 연관된 모든 엔티티를 가져온다고 가정하고 설계되어 있고 그것을 권장한다.

하지만, 페치 조인 대상이 아닌 Team 에 대한 where 절 사용은 아무런 문제가 없다!

결론적으로, fetch join의 대상은 on, where 등에서 필터링 조건으로 사용하면 안된다.

왜 사용하면 안되는지 예시를 들며 살펴보자.

    @Test
    public void contextLoads() {

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

        Member member1 = new Member();
        member1.setUsername("m1");
        member1.setTeam(team);
        em.persist(member1);

        Member member2 = new Member();
        member2.setUsername("m2");
        member2.setTeam(team);
        em.persist(member2);

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

        List<Team> result = em.createQuery("select t from Team t join fetch t.members m where m.username = 'm1'", Team.class)
                .getResultList();

        for (Team team1 : result) {
            System.out.println("team1 = " + team1.getName());
            List<Member> members = team1.getMembers();
            for (Member member : members) {
                System.out.println("member = " + member.getUsername());
            }
        }

    }
// 실행결과
team1 = teamA
member = m1

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

Team 엔티티에 컬렉션 타입의 변수가 하나 더 있다고 가정할 때, 둘 이상의 컬렉션에 대해서도 페치 조인을 하게 되면 일대다대다 관계가 되므로 문제가 생길 수 있다.
컬렉션에 대해 페치 조인은 딱 하나만 지정할 수 있다.

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

일대일, 다대일과 같은 단일 값 연관관계에서는 페치 조인해도 페이징이 가능하다.
일대다 관계에서는 중복 조회되는 문제(aka 뻥튀기?)가 있어서 이걸 페이징 처리하게 되면 의도한 결과가 나오지 않을 수 있다.

  • 중복 조회되는 문제를 DB단에서만 처리하는 것이 아니라, 어플리케이션 단에서도 처리하기 때문이다.
  • 중복 조회된 결과가 2개일 때 페이지 사이즈가 1이라면, 중복 조회되는 문제가 발생한다.

Reference

자바 ORM 표준 JPA 프로그래밍
https://www.inflearn.com/questions/15876
https://www.inflearn.com/questions/59632
https://yjksw.github.io/jpa-fetch-join-nickname/

profile
꾸벅 🙇‍♂️ 매일매일 한발씩 나아가자잇!

1개의 댓글

comment-user-thumbnail
2024년 2월 5일

아니 강의에 있는 내용을 소스 코드랑 같이 올리면 저작권 걸리는 거 아닌가요? ㄷㄷ

답글 달기