Fetch Join vs 일반 Join(feat.DTO)

💡 Spring Data JPA를 사용하다보면 연관관계를 갖고 있는 두 엔티티에 대해 조회를 할 때 N+1 문제가 발생합니다. 이전 프로젝트를 진행하면서 N+1 문제가 발생할 수 있는 상황에서 직접 @Query에 Join query를 작성하면서 N+1 문제를 직접적으로 확인하고 체감하진 못하였는데요, 나중에 N+1 문제의 해결방법을 찾아보았을 때, @EntityGraph 를 사용하는 방법과 fetch join을 사용하는 방법등이 있었습니다. 그래서 단순히 이거 그냥 Join Query 사용하면 되는거 아니야? 라고 생각하고 넘어갔었습니다 🤭 하지만 일반 Join과 Fetch Join 간의 차이점을 모르고 사용했다는게 계속 마음이 걸렸습니다. 🥲 그래서 테스트를 통해 둘 간의 차이점을 알아보고자 해당 포스팅을 준비하게 되었습니다. 🔥

테스트 환경 세팅


User entity


💡 사용자(User)는 name을 갖는다.

public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  private Long id;

  @Column(name = "name")
  private String name;

}

Post entity


💡 게시물(Post)는 title, description을 갖고, 사용자(User) 한명이 작성할 수 있다.

public class Post {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  private Long id;

  @ManyToOne(fetch = FetchType.EAGER)
  @JoinColumn(name = "user_id", referencedColumnName = "id")
  private User user;

  @Column
  private String title;

  @Column
  private String description;

}

@BeforeEach setUp Method


💡 사용자(user) - 게시물(post) 관계를 5개 생성한다.

@BeforeEach
voidsetUp() {
  userRepository.deleteAll();
  postRepository.deleteAll();

  System.out.println("==== setUp start ====");

for(int i = 0; i < 5; i++) {
    User user = User.builder()
        .name("user" + i)
        .build();

    userRepository.save(user);

    Post post = Post.builder()
        .title("title" + i)
        .description("description" + i)
        .user(user)
        .build();

    postRepository.save(post);
  }
  System.out.println("==== setUp end ====");

}

1. 테스트


💡 테스트 요구사항은 다음과 같습니다. "모든 게시물을 조회하고, 조회한 게시물의 작성자 이름을 출력한다."

A. N+1 문제를 확인한다.


@Test
@DisplayName("N+1문제를 확인한다.")
void NplusOneTest() {
  List<Post> posts = postRepository.findAll();

  posts.stream()
      .map(post -> post.getUser().getName())
      .forEach(System.out::println);
}

조회하고자 하는 엔티티(Post)의 갯수(5)만큼 매핑된 엔티티(User)에 대해 조회 query를 날립니다.

→ N(User 조회) + 1(Post 조회)

→ 1번 조회할려고 하는데 N번 조회를 더하게 되네.

→ 이게 바로 N+1 문제

일반적으로 SQL을 공부하였다면 연관 관계가 있는 테이블의 데이터를 조회할 때 Join 쿼리를 사용하여 한 번에 조회를 하면 되겠네 라는 생각을 할 수 있습니다.

그러면 B(Fetch Join), C(일반 Join) 테스트를 통해 N+1 문제를 해결해보도록 하겠습니다.

B. Fetch Join 쿼리 사용하여 N+1 문제를 해결한다. (성공)


Fetch 조인으로 Post 데이터를 받아오는 쿼리

@Query("SELECT p FROM Post p "
      + "JOIN FETCH p.user")
List<Post> findAllByFetchJoin();

테스트 코드

@Test
  @DisplayName("Fetch Join 쿼리 사용하여 N+1 문제를 해결한다.")
  void fetchJoinTest() {
    List<Post> posts = postRepository.findAllByFetchJoin();

    posts.stream()
        .map(post -> post.getUser().getName())
        .forEach(System.out::println);
  }

실행 결과

Hibernate: 
select post0_.id as id1_0_0_, user1_.id as id1_1_1_, post0_.description as descript2_0_0_, post0_.title as title3_0_0_, post0_.user_id as user_id4_0_0_, user1_.name as name2_1_1_ 
from post post0_ 
inner join user user1_ 
on post0_.user_id=user1_.id

// 출력 결과 
user0
user1
user2
user3
user4

결과 분석

fetch join을 사용하니 게시물을 조회하는 쿼리에서 단일 게시물이 갖고 있는 작성자(User)의 모든 데이터까지 하나의 쿼리문으로 조회하는 것을 확인할 수 있습니다.

C. 일반 Join 쿼리 사용하여 N+1 문제를 해결한다. (실패)


일반 조인으로 Post 데이터를 받아오는 쿼리

@Query("SELECT p FROM Post p "
      + "LEFT JOIN User u "
      + "ON p.user.id = u.id")
List<Post> findAllByJoin();

테스트 코드

@Test
@DisplayName("일반 Join 쿼리 사용하여 N+1 문제를 해결한다.")
void joinTest() {
  List<Post> posts = postRepository.findAllByJoin();

  posts.stream()
      .map(post -> post.getUser().getName())
      .forEach(System.out::println);
}

실행 결과

Hibernate: select post0_.id as id1_0_, post0_.description as descript2_0_, post0_.title as title3_0_, post0_.user_id as user_id4_0_ from post post0_ left outer join user user1_ on (post0_.user_id=user1_.id)
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?
Hibernate: select user0_.id as id1_1_0_, user0_.name as name2_1_0_ from user user0_ where user0_.id=?

// 출력 결과 
user0
user1
user2
user3
user4

결과 분석

일반 join을 사용하니 게시물을 조회하는 쿼리에서 단일 게시물이 갖고 있는 작성자(User)의 id(PK)값만 조회하는 것을 확인할 수 있습니다.

그 후 조회한 user_id를 통해 N번의 User 조회 쿼리를 날리는 것을 확인할 수 있습니다.

어라?!

분명 이전 프로젝트에서 일반 join을 사용하더라도 N+1 문제를 확인했는데, 테스트를 진행해보니 N+1 문제가 발생하는 것을 확인할 수 있습니다. 🤦🏻

슉 슈슈슉 슉슉! (프로젝트 코드 확인하러 가는중,,,) 💨

이전 프로젝트의 일반 join 쿼리를 확인해보니 두 엔티티의 데이터를 가져올 때 DTO 객체를 반환값으로 설정한 것을 확인할 수 있었습니다.

그러면 D(일반 Join with DTO) 테스트를 통해 N+1 문제가 해결되었는지 확인해보겠습니다.

D. 일반 join을 사용하고 DTO 를 만들어 N+1 문제를 해결한다 (성공)


PostUserDTO : user 이름과 post 제목을 갖는다.

public class PostUserDTO {

  private String userName;

  private String postTitle;

}

일반 조인으로 DTO 객체로 데이터를 받아오는 쿼리

@Query("SELECT new com.example.springbootstudy.domain.PostUserDTO(u.name, p.title) FROM Post p "
      + "LEFT JOIN User u "
      + "ON p.user.id = u.id")
List<PostUserDTO> findAllPostUserByFetchJoin();

테스트 코드

@Test
@DisplayName("일반 Join 쿼리 와 DTO 를 사용하여 N+1 문제를 해결한다.")
void joinWithDTOTest() {
  List<PostUserDTO> postUserDTOS = postRepository.findAllPostUserByFetchJoin();

  postUserDTOS.stream()
      .map(postUser -> postUser.getUserName())
      .forEach(System.out::println);
}

실행 결과

Hibernate: select user1_.name as col_0_0_, post0_.title as col_1_0_ from post post0_ left outer join user user1_ on (post0_.user_id=user1_.id)

// 출력 결과 
user0
user1
user2
user3
user4

결과 분석

JPQL에선 조회 쿼리문에서 DTO를 반환하는 기능을 제공합니다.

해당 기능을 활용하여 PostUserDTO 를 통해 조회 결과를 반환받으니 N+1 문제가 발생하지 않는 것을 확인할 수 있었습니다.

2. Fetch Join 과 일반 Join


위 테스트를 통해 Fetch Join과 일반 Join을 결과로써 차이점을 확인해볼 수 있었는데요,

우리가 확인했던 결과는 다음과 같습니다.

Fetch Join

  • 게시물을 조회하는 쿼리에서 단일 게시물이 갖고 있는 작성자(User)의 모든 데이터를 하나의 쿼리문으로 조회

일반 Join

  • 게시물을 조회하는 쿼리에서 단일 게시물이 갖고 있는 작성자(User)의 id(PK)값만 조회

  • 그 후, 획득한 user_id를 통해 User 조회 쿼리 N번 수행 (FetchType.EAGER)

그러면 Fetch Join과 일반 Join의 차이점은 무엇일까요?

Fetch Join

  • 조회 주체가 되는 엔티티와 연관 관계의 엔티티(JOIN) 까지 모두 조회하여 영속화한다.

  • 즉, 2개의 엔티티 모두 영속성 컨텍스트로 관리되어진다.

일반 Join

  • 조회 주체가 되는 엔티티만 조회하고 영속화한다.

  • 만약 연관 관계의 엔티티 데이터를 사용해야 할 경우 별도의 조회 쿼리문을 실행 해야 함.

    • FetchType.EAGER 일 경우, 연관 관계의 엔티티를 영속화하기 위해 N번의 쿼리를 발생시킴.

    • FetchType.LAZY 일 경우, 최초 조회시 획득한 id 로 조회를 N번해야함.

3. 일반 Join with DTO


Fetch Join을 사용하여 N+1문제를 해결하는 것이 나이스한 방법인 것은 알게된 것 같습니다.

하지만 위 테스트에서 확인할 수 있듯이 주어진 요구사항이 단순히 데이터 조회만을 수행하는 것이면 D(일반 Join with DTO) 와 같이 코드를 작성하더라도 N+1 문제 걱정 없이 요구사항을 해결할 수 있습니다.

4. Fetch Join vs 일반 Join with DTO


그렇다면 Fetch Join 과 일반 Join with DTO 방법은 각각 언제 사용해야 좋은 것일까요?

Fetch Join


  • 두 엔티티의 영속화가 필요로 할 때

Join with DTO


  • 영속화 없이 데이터 조회만 할 수 있을 경우

  • 두 엔티티의 컬럼이 무수히 많은 경우(필요한 컬럼만 조회할 수 있음)

  • 검색조건으로 연관관계 엔티티를 활용할 경우

Last updated