728x90

개요

이전 블로그에 지연로딩, 즉시로딩에 대해서 살펴보았다.

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

}

 

 

728x90
728x90

개요 

스프링 JPA를 활용한 프로젝트 리팩토링 과정에서 JPA N+1 문제가 발생하여 정리해볼려고 한다.

 

Fetch 전략

특정 엔티티를 조회할 때, 연관된 다른 엔티티를 [언제] 불러오는지에 대한 옵션이다. 위와 같이 연관관계 매핑 어노테이션의 옵션을 통해 전략을 명시한다. 옵션에는 두 가지의 전략이 있다. 

 

 

1. 즉시로딩

  • 즉시로딩이란 연관된 모든 엔티티를 함께 즉시 로드하는 방식이다.
  • 즉, 부모 엔티티를 조회할 때 연관된 자식 엔티티들도 함께 한번에 가져온다. 
  • 현재 엔티티를 조회한 [직후], 연관된 엔티티까지 조회한다.
@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
    private User user;
}

 

위 Post 엔티티는 User와 다대일(@ManyToOne) 관계를 맺고 있으며, 즉시 로딩(EAGER)으로 설정되었다. 이 경우 Post를 조회할 때 User 정보도 함께 조회된다. 즉, Post 엔티티를 가져오면 바로 User 엔티티도 DB에서 쿼리되어 로드된다.

장점으로는 쿼리가 한 번만 실행되므로 사용 시 추가적인 데이터베이스 접근이 필요 없다. 이 방법으로 인해 N+1 문제를 해결할 수 있지만, 불필요하게 많은 데이터를 한 번에 로드할 수 있어 성능에 영향을 줄 수 있다.  

 

 

 

 

2. 지연로딩

  • 지연로딩이란 연관된 엔티티를 즉시 로드하지 않고 실제로 해당 엔티티에 접근할 때 데이터를 로드하는 방식이다.
  • 즉 부모 엔티티만 먼저 조회하고 자식 엔티티는 실제로 필요할 때 쿼리가 발생하는 식이다.
  • 현재 엔티티를 조회하고, [추후 필요할 때] 연관된 엔티티를 조회하는 쿼리를 날린다.
@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
    private User user;
}

위 Post 엔티티는 User와 지연 로딩(LAZY) 관계를 맺고 있다. 이 경우 Post 엔티티를 조회할 때는 User 엔티티를 바로 로드하지 않고, getUser() 메서드를 호출해서 User 정보를 실제로 사용할 때 DB에서 쿼리가 실행되어 데이터를 로드한다. 장점으로는 연관된 엔티티가 많아도 불필요한 데이터는 로드하지 않아 성능을 향상시킬 수 있다. 하지만 엔티티에 접근할 때마다 추가적인 데이터베이스 쿼리가 발생할 수 있다. 이를 잘못 사용할 경우 N+1 문제를 일으킬 수 있다.

 

 

 

기본 로딩 전략 (디폴트)

  • @OneToMany, @ManyToMany: 기본적으로 지연 로딩(LAZY)으로 설정
  • @ManyToOne, @OneToOne: 기본적으로 즉시 로딩(EAGER)으로 설정
@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne // 기본 즉시 로딩 (EAGER)
    private User user;

    @OneToMany(mappedBy = "post") // 기본 지연 로딩 (LAZY)
    private List<Comment> comments;
}
728x90

+ Recent posts