개요
이전 블로그에 지연로딩, 즉시로딩에 대해서 살펴보았다.
JPA를 사용하다 보면 바로 N+1의 문제에 마주치고 바로 Fetch Join을 접하게 된다.
처음 Fetch Join을 접했을 때 왜 일반 Join으로 해결하면 안되는지에 대해 명확히 정리가 안된 채로 Fetch Join을 사용했다.
어떤 문제 때문에 일반 join으로 N+1을 해결하지 못하는지를 시작으로 해서 Fetch Join, Join의 차이점을 정리해보겠다.
Join, Fetch Join 차이점 요약
- 일반 Join
- Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는 오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화
- 조회의 주체가 되는 Entity만 SELECT 해서 영속화하기 때문에 데이터는 필요하지 않지만 연관 Entity가 검색조건에는 필요한 경우에 주로 사용됨
- Fetch Join
- 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화
- Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도 이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1문제가 해결됨
N+1 문제
단순 테이블 조회 쿼리를 하나 생성하였는데, 연관관계에 있는 데이터를 모두 불러오기 위해서 select 쿼리가 테이블의 레코드 수(혹은 호출한 데이터 수) 만큼 더 생성되는 문제를 의미한다.
즉시로딩은 최초 테이블 데이터를 모두 불러온 후 바로 연관관계가 있는 엔티티를 조회하는 방식을,
지연로딩은 최초 테이블 데이터를 모두 불러온 후 필요한 시점에 연관관계가 있는 엔티티를 조회하는 방식을 채택한다.
즉시로딩은 워딩 그대로 조회 시점에 연관관계에 있는 엔티티가 모두 조회되어 N+1 문제가 생긴다는 것을 직관적으로 알 수 있지만 지연로딩은 얼핏보면 로딩 시점에 쿼리가 더 나가지 않아 N+1 문제가 발생하지 않을 것으로 보인다. 하지만 지연로딩 N+1 문제를 피해갈 수는 없는데, 그 이유는 연관관계에 있는 엔티티가 조회 시점에 영속화되지 않았기 때문이다.
일반적으로 JPA는 JPQL 로 생성된 쿼리문을 데이터베이스와 connection 을 만든 후에 전송하고, 이에 대한 결과 데이터를 전달받아 실제 엔티티 객체를 생성하고 이를 영속화 한다.
예시
@Entity
class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String title;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
List<Comment> cpmments = ArrayList()
}
@Entity
class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
String content;
@ManyToOne(fetch = FetchType.LAZY)
Post post;
}
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
@Transactional(readOnly = true)
public void getMockposts() {
postRepository.findAll().forEach(post -> {
post.getpPostComments().forEach(cpmments ->
System.out.println(cpmments.getContent())
);
});
}
}
위 코드에서 N+1 문제가 발생할 수 있다. 예를 들어, 100개의 게시글이 있다고 가정하면, PostRepository의 findAll() 메서드가 호출될 때 모든 게시글을 조회한다. 이때, 각 게시글의 댓글을 조회하기 위해 추가로 100번의 쿼리가 실행다. 이는 총 101번의 쿼리 실행으로 이어지며, 매우 비효율적인 방법이다. 즉, 다음과 같이 SQL이 실행되면서 N+1 문제가 발생한 것을 알 수 있다.
SELECT * FROM post;
SELECT * FROM comment WHERE post_id = 1;
SELECT * FROM comment WHERE post_id = 2;
SELECT * FROM comment WHERE post_id = 3;
...
SELECT * FROM comment WHERE post_id = 99;
SELECT * FROM comment WHERE post_id = 100;
해결 방법
1. Fetch Join (@Query 어노테이션 사용)
- Fetch Join은 N+1 문제를 방지하고 성능을 최적화할 수 있는 수단으로서 데이터베이스에서 연관된 엔터티를 함께 로딩하는 방법이다.
- Fetch Join을 사용하면 조회 성능을 최적화 할 수 있다.
- 연관된 엔티티를 쿼리 시 함께 조회하는 방법이다.
- SQL 쿼리에 직접 Join fetch를 명시한다.
- 조회의 주체가 되는 엔티티 외에 Fetch Join이 걸린 연관 엔티티도 같이 영속화 시켜준다.
@Repository
public interface PostRepository extends JpaRepository<Post, String> {
@Query("SELECT p from Post p JOIN FETCH p.comments")
List<Post> findAllPostsWithComments();
}
- "JOIN FETCH p.comments" 구문을 통해서 게시글을 조회할 때 댓글 엔티티도 함께 조회한다.
- join fetch는 Fetch Join을 사용하여 연관된 데이터를 조회하겠다는 뜻이다.
2. Entity Graph
- EntityGraph를 사용하면 Fetch Join를 사용한 경우와 마찬가지로 연관된 엔티티나 컬렉션을 같이 조회 할 수 있다.
- JPQL과 함께 사용하며 attributePaths에 쿼리 수행 시 조회할 필드를 지정해준다.
- 지정된 필드는 지연 로딩이 아닌 즉시 로딩으로 조회된다.
- EntityGraph는 Fetch Join과는 다르게 Outer Join이 사용되어 동작한다
@Repository
public interface PostRepository extends JpaRepository<Post, String> {
@EntityGraph(
attributePaths = {"comments"},
type = EntityGraph.EntityGraphType.LOAD
)
@Query("SELECT p FROM post p")
List<Post> findAllPostsWithComments();
}
Fetch Join 및 EntityGraph 사용 시 발생하는 카테시안 곱 문제
Fetch Join 또는 EntityGraph를 사용한 경우엔 카테시안 곱(Cartesian Product)으로 인하여 중복 문제가 발생할 수 있습니다. 카테시안 곱은 연관관계의 엔티티에 Join 규칙을 사용하지 않아 결합 조건이 주어지지 않았을 때 모든 데이터의 경우의 수를 결합(M * N)하여 반환하는 문제입니다. 즉, 조인 조건이 없는 경우에 대한 두 테이블의 결합 결과를 반환해야 하기 때문에 존재하는 모든 행의 개수를 곱한 결과를 반환하게 됩니다. 카테시안 곱 문제를 해결하기 위해서는 다음과 같은 방법을 사용합니다.
1. JPQL에 DISTINCT 사용하기
JPQL을 이용한 쿼리를 사용할 때 다음과 같이 DISTINCT를 사용하여 중복을 제거합니다.
@Repository
public interface PostRepository extends JpaRepository<Post, String> {
@Query("SELECT DISTINCT p from Post p JOIN FETCH p.comments")
List<Post> findAllPostsWithComments();
@EntityGraph(
attributePaths = {"comments"},
type = EntityGraph.EntityGraphType.LOAD
)
@Query("SELECT DISTINCT p FROM post p")
List<Post> findAllPostsWithComments();
}
2. 연관관계의 필드 타입에 Set 사용하기
- Set은 중복을 허용하지 않기 때문에 중복 데이터가 들어가지 않지만 순서가 보장되지 않는다.
- 이러한 경우 다음과 같이 연관관계의 필드에 LinkedHashSet을 사용하여 중복을 제거하고 순서를 보장 해줄 수 있다.
@Getter
@NoArgsConstructor
@Entity(name = "posts")
public class post {
@OneToMany(mappedBy = "post")
private Set<Comment> comments = new LinkedHashSet<>();
}
'Spring > Spring Boot' 카테고리의 다른 글
스프링 배치(Spring Batch) 사용하기 - 2편 (0) | 2024.09.11 |
---|---|
스프링 배치(Spring Batch) 사용하기 - 1편 (0) | 2024.09.10 |
Spring : N+1 문제 해결 1편 - 즉시 로딩(Eager Loading)과 지연 로딩(Lazy Loading) (1) | 2024.09.09 |
Spring : @Builder 사용 하여 객체의 정보를 수정할 때 값이..?? (0) | 2024.08.30 |
Spring : @ModelAttribute & @RequestParam 차이 (0) | 2024.08.30 |