카테고리 없음

[TIL] JPA 에서 N+1 문제를 해결하는 방법

bluealice 2025. 7. 8. 22:02

참고

더보기

JPA N+1 문제와 해결법 총정리

@EntityGraph fetch join 차이

 

1. N+1 문제

N+1 문제는 다대일 관계에서 지연로딩일 때 발생하는 문제를 말한다.

@Entity
Public class Post {
    @Id
    @GeneratedValue
    Private Long id;

    @OneToMany(mappedBy = “post”, fetch = FetchType.LAZY)
    Private List<Comment> commentList = new ArrayList<>() ;
}

post 객체는 comment 객체와 일대다 관계를 가지고 있고, 지연 로딩으로 설정되어 있다. 따라서 post 를 조회하는 쿼리가 나갈 때, comment 테이블과 outer join 으로 곧바로 가져오지 않고, 프록시 객체를 사용해 처리하고 연관된 comment 객체를 부른다면 그때 comment 에 대한 조회 쿼리가 나가는 방식을 사용한다.

@Service
Public PostService{

    @Transactional
    Public List<PostListResponseDto> findPostList(…){

        List<Post> postList = postRepository.findByAll();

        for (Post post : postList) {
            List<Comment> comments = post.getCommentList();
            ...
        }
    }
}

그리고 service 코드에서, 다음과 같이, post 리스트를 가져오는 조회 로직이 실행되고, 이후 각 post 의 comment 를 가져와야 하는 상황이 발생했다. 

 

이렇게 되면 지연로딩 설정에 따라,

처음 repositoty 쿼리메소드를 실행할 때

Select * from post;

 

실행 이후, for 의해

PostList 길이만큼의

Select * from comment where comment.post_id=?;
Select * from comment where comment.post_id=?;
Select * from comment where comment.post_id=?;
Select * from comment where comment.post_id=?;
Select * from comment where comment.post_id=?;
…

 

이런 별도의 쿼리가 나가게 된다. 이것을 N+1 문제라고 한다.

 

https://goodmockups.com/free-instagram-feed-screen-ui-mockup-2017/

 

인스타 피드와 같은 화면을 만들려고 한다면 POST 와 더불어 COMMENT도 같이 보내줘야 할 것이다. 리스트에서 오브젝트 자체만이 아니라 관련된 정보까지 같이 보내줘야 하상황은 어플리케이션을 만들 때 자주 구현되어야 하는 상황이기 때문에 이를 정리하고자 포스트를 작성하려고 한다.

 

2. 해결책

1) fetch join

fetch join 이란 지연로딩 환경에서 JPQL 을 사용하여 INNER JOIN 으로 프록시가 아닌 진짜 데이터를 한번에 조회하는 기능이다.

Public interface PostRepository extends JpaRepository<Post, Long> {

    @Query(”select distinct p from Post p join fetch p.comments”)
    List<Post> findAllWithComments();

}

 

이렇게 하면 내부적으로,

이러한 쿼리가 나간다.

select distinct p.id, p.title, c.id, c.content, c.post_id from post p 
inner join comment c on p.id = c.post_id;

 

그렇다면, 그냥 inner join 과 무엇이 다른가?

- 한 번에 2가지의 엔티티가 적재 : 그냥 inner join 에서는 post 에 대한 정보만 불러온다. fetch join 을 사용하면, 저렇게 join을 한 결과가 context에 오게 되고, 한 번의 쿼리로 entity manager 1차 캐시에 각 post 뿐만 아니라 comment 들까지 한번에 적재된다.

- 쿼리 1번 : join하여 post  comment 리스트를 한번에 가져오기 때문에 쿼리는 한 번만 나간다. 이후 for  안에서 getCommentList 를 할 때, 프록시가 트리거되더라도, 1차캐시를 확인했을 때 객체들이 있으므로, db 에 쿼리가 flush 되지 않는다.

- 중복 이슈로 distinct 사용 : 그런데, inner join을 하면 결과 테이블에 post 가 중복되게 된다. 따라서 1개만 적재되도록 distinct 를 쓰는데, 이 때문에 comment 1개밖에 제한되어 찾지 못하게 된다.

- 메모리 문제 : 또한, join 결과를 context 에 그대로 가져오기 때문에 메모리 문제가 발생할 수 있.

이러한 이유로 fetch join 보다는 다음과 같은 방법들을 많이 사용한다.

 

2. @BatchSize

@BatchSize 란 지연로딩 환경에서, SQL 의 in 절을 활용해 연관된 엔티티를 한 번에 조회하는 기능을 제공하는 어노테이션이다.

@Entity
Public class Post{
	…
    @OneToMany(mappedBy = “post”, fetch=FetchType.LAZY)
    @BatchSize(size=10) // batch size 10 으로 설정
    Private List<Comment> commentList;

}

@BatchSize 를 다음과 같이 comment 에 설정한다면,

// Service 코드
List<Post> posts = postRepository.findByAll();
For (Post post : posts) {
	List<Comment> comment = post.getComments(); // LAZY 트리거
}

getComment를 부르는 다음과 같은 상황에서,

SELECT * FROM post;

SELECT * FROM comment WHERE post_id IN (?,?,?,?,?,?,?,?,?,?);

 

이렇게 in’ 을 사용한 쿼리가 나가 여러 post 식별자에 대해 comment 리스트를 한번에 가져오게 된다.

 

- in 절 사용 : EntityManager, 정확히는 Hibernate Session에서 @BatchSize 설정이 확인되면, post_id를 size 개수만큼 묶어서 comment 테이블에 조회 커리를 in 쿼리로 최적화하여 보내게 된다. 따라서, 쿼리 수가 훨씬 줄어들게 되는 것이다.

 

3. @EntityGraph

@EntityGraph 는 JPA 에서 JoinJPQL 작성하는 게 아니라 어노테이션을 사용하여 left outer join 을 지원하는 것으로, 지연로딩으로 설정되어 있었지만 즉시로딩 쿼리를 생성하여 조인된 결과를 얻는 것이다. FetchType.EAGER 을 설정하는 것과의 차이점은, 쿼리메소드에서 즉시로딩 여부를 설정할 수 있다는 것이다.

 

// Repositoy 코드
@EntityGraph(attributePaths = {”comments”})
List<Post> findAll();

 

이렇게 Repository의 쿼리메소드에 @EntityGraph를 사용하여 Post 엔티티의 어떤 필드에 즉시로딩을 적용할 것인지 설정하면 내부적으로

Select distinct p.id, p.title, c.id, c.content, c.post_id
From post p
Left outer join comment c on p.id = c.post_id;

 

이런 쿼리가 나간다.

Left outer join 이기 때문에 post comment 가 없어도 조회가 가능하.

 

@EntityGraph 가 있는 경우 엔티티 매니저가 인식해서 쿼리 작성해주고, 여러 post  중복해서 올라오는 것도, 중복을 제거해주기 때문에 distinct사용하지 않고 여러 comment 를 가져올 수 있다는 장점도 있다.

 

FetchType.EAGER 와 같다. 그러나 이것을 쿼리메소드마다 선택적으로 어노테이션을 붙일 수 있기 때문에, 엔티티 자체적으로 즉시로딩을 설정하여 매번 조인결과를 부르는 것보다 쉽게 최적화할 수 있는 기능이다.

그러나 이것 또한 조인 쿼리이기 때문에 메모리를 고려하여 자주 사용되는 엔티티에 적절히 적용하는 것이 적합하다. 

 

결론

Fetch join distinct inner join 문제가 존재하기 때문에, @BatchSize @EntityGraph 를 사용하는 게 좋은 선택듯 하다. 또한, 조인된 결과를 불러오기 때문에 메모리 문제가 있을 수 있어 상황에 따라 다르게 사용해야겠지만 안전하게 하기 위해 @BatchSize가 개인적으로는 최적화에 가장 적합해보인다.