[JPA] JPQL - Fetch Join

3Beom's 개발 블로그·2023년 6월 18일
3

SpringJPA

목록 보기
16/21
post-custom-banner

출처

본 글은 인프런의 김영한님 강의 자바 ORM 표준 JPA 프로그래밍 - 기본편 을 수강하며 기록한 필기 내용을 정리한 글입니다.

-> 인프런
-> 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의


⭐ Fetch Join ⭐

  • SQL 조인의 종류는 아님

  • JPQL에서 성능 최적화를 위해 제공하는 기능이다.

  • 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능이다.

  • join fetch 명령어를 사용한다.

  • [LEFT [OUTER] | INNER] JOIN FETCH 조인경로

  • 다음과 같이 동작한다.

    • 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
  • fetch join을 써야하는 이유

    • 예시 설정

    • N + 1 문제

      • 만약 다음과 같이 Member 정보를 조회한 후 출력한다.

        String query = "select m from Member m";
        List<Member> result = em.createQuery(query, Member.class)
                .getResultList();
        
        for (Member m : result) {
            System.out.println("memberinfo : " + m + ", teaminfo : " + m.getTeam());
        }
      • 그럼 처음 Member 엔티티 정보만 불러오는 쿼리가 전송된다. : 지연 로딩으로 설정되어 있으므로

      • 이후 출력 과정에서 getTeam() 메서드로 프록시 객체로 설정되어 있던 Team 객체에 접근되고, 이에 따라 각 Member 데이터마다 각자의 Team 정보를 얻기 위한 쿼리가 전송된다.

      • 여기서 회원1과 회원2는 같은 teamA 이기 때문에 회원1로 인해 조회된 teamA 데이터가 영속성 컨텍스트 1차캐시에 저장되어 있고, 회원2에서 teamA를 조회할 때는 해당 1차캐시에서 가져와 쿼리가 안나간다.

      • 회원3에서는 다시 teamB를 조회하기 위한 쿼리가 나간다.

        ⇒ 이에 따라 최악의 경우, N + 1 개의 쿼리가 나가게 된다.

        ⇒ 처음 Member 테이블 조회 쿼리 1개, 조회 결과 N개에 대한 각각의 서로 다른 Team 정보를 얻기 위한 조회 쿼리 N개

  • fetch join을 쓸 경우

    SELECT m FROM Member m join fetch m.team
    • 다음과 같이 쿼리는 한번만 나가는 것을 확인할 수 있다.
    • 즉, fetch join을 쓸 경우, join fetch 로 설정된 연관관계 엔티티의 정보까지 한 번의 쿼리로 다 가져오게 된다. (프록시 객체로 두지 않는다.)
    • 이에 따라 모든 팀 정보들이 한번에 조회되고, 영속성 컨텍스트 1차 캐시에 저장되어 쿼리 전송 없이 바로 활용될 수 있다.

컬렉션 fetch join

  • 일대다(@OneToMany) 관계, 컬렉션 페치 조인
  • 똑같이 join fetch 키워드로 조회할 경우, 페치 조인이 이루어져 N + 1 문제를 막을 수 있다.
    • 프록시로 두지 않고 실제 값으로 다 채운다.
String query = "select t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
        .getResultList();

for (Team t : result) {
    System.out.println("memberinfo : " + t + ", teaminfo : " + t.getMembers());
}

  • 하지만 컬렉션 페치 조인의 경우, 위와 같이 데이터가 중복되어 조회되는 것을 확인할 수 있다.
    • teamA 에 대한 데이터가 똑같이 두개가 있다.
  • 본 현상은 DB에서 1 : N 관계의 두 테이블을 조인할 때 결과로 출력되는 데이터 형식을 생각해보면 알 수 있다.

  • 1:N 관계의 두 테이블이 조인될 경우, 위 사진과 같이 1에 해당하는 데이터는 중복되어 출력된다.
  • JPA에서는 DB에 조회한 결과 개수만큼 그대로 돌려주는 것이다.
  • 따라서 결과 리스트에는 동일한 Team 데이터가 두개 담기게 된다.

⇒ 이 점을 유의해서 활용해야 한다.

  • 이렇게 중복되는 데이터를 제거할 수 있는 방법이 있는데, DISTINCT 기능을 활용하는 것이다.
  • JPQL의 DISTINCT 는 다음 2가지 기능을 제공한다.
    • SQL에 DISTINCT 를 추가한다.
    • 애플리케이션 단에서 엔티티의 중복을 제거한다.
  • 따라서 다음과 같이 distinct 키워드를 추가한다.
String query = "select distinct t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
        .getResultList();

for (Team t : result) {
    System.out.println("memberinfo : " + t + ", teaminfo : " + t.getMembers());
}

  • 이렇게 distinct 키워드를 추가해도 DB 단에서는 데이터가 줄어드는건 아님.

    • 각 팀에 소속된 멤버 데이터가 서로 다르기 때문에, 서로 다른 멤버 데이터만큼 팀 데이터는 중복될 수 밖에 없다.
  • 하지만 JPQL이 애플리케이션 단에서 중복 제거를 시도하게 된다.

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

      <distinct 안했을 때>

      <distinct 했을 때>

페치 조인 vs 일반 조인

  • 일반 조인을 실행할 경우, SQL에서도 조인은 하지만, 해당 조인 쿼리의 select 절에 연관된 엔티티가 포함되지 않는다.

    • 일반 조인

      String query = "select t from Team t join t.members m";
      List<Team> result = em.createQuery(query, Team.class)
              .getResultList();
      
      for (Team t : result) {
          System.out.println("memberinfo : " + t + ", teaminfo : " + t.getMembers());
      }

      • 이렇게 MEMBER 테이블과 조인은 되지만, SELECT 절을 보면 TEAM 테이블의 컬럼만 조회하고 있는 것을 확인할 수 있다.

      • 이에 따라 각 Team 엔티티와 연관된 Member 엔티티들은 프록시 객체로 설정되고, 후에 해당 Member 엔티티에 접근하면 해당 데이터를 조회하는 SQL이 또 보내지게 된다.

      • 이렇게 teamA 에 해당하는 Member 데이터를 조회하는 쿼리를 보내고 난 후에 출력되고,

      • 그 다음 teamB에 해당하는 Member 데이터를 조회하는 쿼리를 보내고 출력된다.

      • 다음과 같이 일반 조인의 select 절에 명시적으로 Member 엔티티를 포함시켜도 안된다.
        - 아예 쿼리 자체가 안나간다.

        String query1 = "select t, m from Team t join t.members m";
        String query2 = "select t.id, t.name, t.members from Team t join t.members m";
    • 페치 조인

      • 이에 반해 페치 조인은 다음과 같이 연관된 엔티티도 모두 SELECT 절에 포함되는 것을 확인할 수 있다.

        String query = "select t from Team t join fetch t.members m";
        List<Team> result = em.createQuery(query, Team.class)
                .getResultList();
        
        for (Team t : result) {
            System.out.println("memberinfo : " + t + ", teaminfo : " + t.getMembers());
        }

  • 결국 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회되며, 즉시 로딩이라고 보면 된다.

  • 연관 관계로 이어진 객체 그래프를 SQL 한번에 조회하는 개념이다.

  • ⭐ 대부분의 N + 1 문제는 페치 조인으로 해결될 수 있다.

페치 조인의 특징과 한계

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

    • Hibernate는 가능하지만, 가급적 사용하면 안된다.

      "select t from Team t join fetch t.members m"
    • 위와 같이 t.members 에게 m 이라는 별칭을 주고, 다음과 같이 where절에서 조건을 부여하거나 그러면 안된다.

      "select t from Team t join fetch t.members m where m.username like '%a%'"
    • JPA 객체 그래프는 데이터를 우선 다 갖고 있어야 맞는 개념인 것이다.

    • 특정 팀과 연관된 모든 멤버 데이터들을 우선 다 갖고 있어야 하는게 맞는 것. 멤버 데이터에 조건을 부여해서 일부 데이터만 갖고 있는 것은 위험할 수 있다.

    • JPA의 객체 그래프 개념 상 팀과 연관된 멤버 데이터를 조회할 때 모든 데이터가 포함되어 있다는 것을 전제 하에 설계되어 있다.

    • 만약 전체 멤버 데이터 중 일부 데이터를 조회하려면 팀 엔티티를 통해서 조회하는 것이 아닌, 멤버 엔티티를 대상으로 따로 조회하는게 맞는 것.

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

    • 페치 조인은 하나의 컬렉션을 대상으로만 이루어져야 한다.

    • 만약 Team 엔티티 내에 Member 엔티티 컬렉션, Order 엔티티 컬렉션이 있을 경우, 두 컬렉션을 하나의 페치 조인 쿼리에 다 넣으면 안된다.

      "select t from Team t join fetch t.members join fetch t.orders"
    • 둘 이상의 컬렉션에 대해 페치 조인 할 경우, 데이터가 엉켜서 엄청 뻥튀기 되어버린다.

    • DB에서 조인하는 경우도 생각해보면, 여러 테이블에 대해 조인을 수행하진 않음. 아마 결과가 조인 대상의 각 테이블마다 모두 추가되어 엄청 뻥튀기 돼서 나올 것이다.

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

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

      • 데이터 뻥튀기가 안되기 때문!
      • 일대일, 다대일 같은 경우, 본인이 다수 측이고, 대상은 어차피 1로 고정되어 있기 때문에 페치 조인을 해도 원하던 데이터 수만큼만 나온다.
    • 결국 대상이 다수일 경우, 데이터 뻥튀기가 되기 때문에 페이징 과정에서 문제가 발생한다.

      • 페이징 처리는 시작점에서부터 n개의 데이터를 가져오도록 설정하는건데, 데이터 뻥튀기가 되어버리면 전체 데이터 수가 달라지기 때문에 원하던 데이터가 안나온다.
    • Hibernate는 동작은 하지만 경고 로그를 남기고 메모리에서 페이징 시킨다. (매우 위험하다.)

      String query = "select t from Team t join fetch t.members m";
      List<Team> result = em.createQuery(query, Team.class)
              .setFirstResult(0)
              .setMaxResults(1)
              .getResultList();
      
      for (Team t : result) {
          System.out.println("memberinfo : " + t + ", teaminfo : " + t.getMembers());
      }
      • 이렇게 돌려보면, 다음과 같은 경고문이 뜬다.

      • 그리고 쿼리도 살펴보면, 페이징 처리하는 구문이 없다.

      • 즉, 전체 데이터를 모두 메모리 상에 올려놓고, 메모리에서 페이징 처리를 하는 것이다.

      • 만약 조회한 데이터가 수백만 건일 경우, 수백만 건의 데이터가 메모리에 올라가서 페이징 처리가 되어버린다.

      • ⭐ 매우 위험하다!! ⭐

컬렉션 페치 조인에서 페이징 처리하기

  • 방법 1 : 페치 조인 방향을 뒤집으면 된다.

    • 기존에 1 → N 방향이던 페치 조인 방향을 N → 1 로 뒤집으면 된다.

    • 즉, Team → Member 이던 방향을 Member → Team 으로 뒤집으면 됨.

      String query = "select m from Member m join fetch m.team";
      List<Member> result = em.createQuery(query, Member.class)
              .setFirstResult(0)
              .setMaxResults(1)
              .getResultList();
      
      for (Member m : result) {
          System.out.println("memberinfo : " + m + ", teaminfo : " + m.getTeam());
      }
    • 이렇게 하면 다음과 같이 쿼리도 페이징 처리 돼서 나가는 것을 확인할 수 있다.

  • 방법 2 : 컬렉션 필드에 @BatchSize(size = ?) 어노테이션을 부여한다.

    • 예를 들어 다음과 같이 Team 엔티티 내 Member 컬렉션 필드에 @BatchSize(size = 2) 어노테이션을 부여한다.

      ...
      
      @BatchSize(size = 100)
      @OneToMany(mappedBy = "team")
      private List<Member> members = new ArrayList<>();
      
      ...
    • 그리고 다음과 같이 Team 정보만 조회하도록 JPQL 을 구성하고, 페이징 처리를 한다.
      - 페치 조인을 안쓰고 그냥 Team 엔티티만 조회하는 것

      String query = "select t from Team t";
                  List<Team> result = em.createQuery(query, Team.class)
              .setFirstResult(0)
              .setMaxResults(2)
              .getResultList();
      
      for (Team t : result) {
          System.out.println("teaminfo : " + t + ", memberinfo : " + t.getMembers());
      }
    • 이 때 처음 Team 데이터 조회하는 쿼리 1번, 그리고 기존의 경우, 각 Team 데이터의 Member 컬렉션에 접근할 때마다 각각 쿼리가 나갔을 것이다.

      • 해당 문제가 N + 1 문제였다.
    • 하지만 위 코드를 실행해보면 다음과 같이 Member 데이터가 한번에 조회되는 것을 확인할 수 있다.

    • 즉, @BatchSize 어노테이션의 경우, 해당 어노테이션이 부여된 컬렉션 필드 데이터를 조회하는 쿼리를 보낼 때, 각각 따로 보내는게 아니라 설정된 size 크기만큼 묶어서 한번에 조회하도록 한다.

      • SQL을 보면 TEAM_ID in (?, ?) 을 통해 묶어서 한번에 조회하는 것을 알 수 있다.
      • 페이징 처리를 통해 2개만 가져오도록 설정했고, batch size가 100으로 설정되었기 때문에 두 팀에 대한 멤버 데이터를 한번에 조회하는 것이다.
      • 만약 페이징 처리로 150개 가져오도록 하면, batch size가 100으로 설정되었기 때문에 상위 100개팀에 대한 멤버 데이터가 한번에 조회된다.
    • 해당 방법을 통해서도 N + 1 문제를 해결할 수 있다.

      • 원래 N개 결과 데이터에 대해 각각 조회 쿼리가 보내지던걸 in 으로 묶어서 한번에 조회하도록 설정하는 것이기 때문!
    • 이렇게 @BatchSize 어노테이션을 부여할 수도 있지만, Global Setting으로 설정할 수도 있다.

      • persistence.xml 파일에 다음 property를 설정한다.

        <property name="hibernate.default_batch_fetch_size" value="100"/>
      • 보통 실무에서는 이렇게 batch size를 글로벌로 세팅해놓고 개발한다.

페치 조인 추가 내용

  • 연관된 엔티티들을 SQL 한번으로 조회하는 기능이다. : 성능 최적화에 활용될 수 있다.
  • 지연 로딩 설정과 같이 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선 순위가 높다.
    • 글로벌 로딩 전략 예 : OneToMany(fetch = FetchType.LAZY)
    • 이렇게 지연 로딩으로 설정되어 있어도 페치 조인하면 즉시로딩 된다.
  • 실무에서는 글로벌 로딩 전략은 모두 지연 로딩으로 해두고, 최적화가 필요한 곳마다 페치 조인을 적용하는 방식으로 개발한다.
    • 보통은 N + 1 문제로 인해 성능이 낮아지며, 페치 조인을 통해 해결할 수 있다.
  • 모든 것을 페치 조인으로 해결할 수는 없지만 성능 최적화에 많이 활용된다.
  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.
  • 여러 테이블들을 조인해서 특정 엔티티 모양이 아닌 전혀 다른 결과를 내야할 경우, 페치 조인보다는 일반 조인으로 필요한 데이터들만 모아서 DTO로 반환하는 것이 효과적이다.
profile
경험과 기록으로 성장하기
post-custom-banner

0개의 댓글