Post

[JPA] 예제를 통한 N+1 문제 해결 방법

[JPA] 예제를 통한 N+1 문제 해결 방법

📌 N+1 문제란?

하나의 쿼리를 기대했으나 의도치 않게 N개의 쿼리가 추가로 발생하는 문제를 말한다.

이를 해결하는 대표적인 세 가지 방법이 있는데, Fetch Join, @EntityGraph , Batch Size 이다. 각 방법으로 N+1 문제를 해결하는 과정을 예제를 통해 살펴보자.

np1.drawio.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Post {

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

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String content;

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> comments = new ArrayList<>();

    public Post (String title, String content) {
        this.title = title;
        this.content = content;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Comment {

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

    @Column(nullable = false)
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    private Post post;

    public Comment (Post post, String content) {
        this.post = post;
        this.content = content;
    }
}

엔티티 간 연관관계는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
@Transactional(readOnly = true)
public PostListResponse getAllPosts() {
    List<Post> posts = postRepository.findAll();

    List<PostResponse> postResponses = posts.stream()
            .map(PostResponse::from)
            .toList();

    return PostListResponse.from(postResponses);
}

전체 게시글과 댓글을 조회하는 getAllPosts 메서드이다.

만약 10개의 게시글을 생성하고 각 게시글마다 3개의 댓글을 생성했다고 하자. 과연 몇 개의 쿼리가 발생할까? 간단하게 로그를 찍어서 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Transactional(readOnly = true)
public PostListResponse getAllPosts() {
    SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
    Statistics statistics = sessionFactory.getStatistics();
    statistics.clear();

    List<Post> posts = postRepository.findAll();

    log.info("===== 댓글 접근 전 쿼리 수: {} =====", statistics.getPrepareStatementCount());

    List<PostResponse> postResponses = posts.stream()
            .map(PostResponse::from)
            .toList();

    log.info("===== 댓글 접근 후 쿼리 수: {} =====", statistics.getPrepareStatementCount());

    return PostListResponse.from(postResponses);
}
1
2
3
2025-07-06T20:15:44.129+09:00  INFO 15240 --- [nio-8080-exec-1] o.l.p.post.service.PostService           : ===== 댓글 접근 전 쿼리 수: 1 =====

2025-07-06T20:15:44.151+09:00  INFO 15240 --- [nio-8080-exec-1] o.l.p.post.service.PostService           : ===== 댓글 접근 후 쿼리 수: 11 =====

먼저 findAll 메서드를 통해 모든 게시글을 조회하는 쿼리 1개가 발생한다. 이 시점엔 댓글을 함께 조회하지 않는데, @OneToMany 는 기본적으로 지연 로딩으로 설정되기 때문이다. 즉, 실제로 댓글을 조회할 때 추가적인 쿼리가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public record PostResponse(
        Long id,
        String title,
        String content,
        List<CommentResponse> comments
) {
    public static PostResponse from(Post post) {
        List<CommentResponse> commentResponses = post.getComments().stream()
                .map(CommentResponse::from)
                .toList();

        return new PostResponse(
                post.getId(),
                post.getTitle(),
                post.getContent(),
                commentResponses
        );
    }
}

각 게시글에 대해 댓글을 조회하는 추가적인 쿼리 10개는 from 메서드에서 실제로 댓글 엔티티에 접근할 때 발생한다.

단 한 번의 쿼리를 기대했으나, 연관관계 엔티티를 추가로 조회하는 쿼리가 발생하였고, 이는 성능 저하로 이어진다. 이를 해결하는 방법을 살펴보자.

📌 Fetch Join

1
2
@Query("SELECT DISTINCT p FROM Post p LEFT JOIN FETCH p.comments")
List<Post> findAllWithComments();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2025-07-06T21:11:39.496+09:00  INFO 27076 --- [nio-8080-exec-1] o.l.p.post.service.PostService           : ===== 댓글 접근 전 쿼리 수: 0 =====
2025-07-06T21:11:39.599+09:00 DEBUG 27076 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
    select
        distinct p1_0.id,
        c1_0.post_id,
        c1_0.id,
        c1_0.content,
        p1_0.content,
        p1_0.title 
    from
        post p1_0 
    left join
        comment c1_0 
            on p1_0.id=c1_0.post_id
2025-07-06T21:11:39.662+09:00  INFO 27076 --- [nio-8080-exec-1] o.l.p.post.service.PostService           : ===== 댓글 접근 후 쿼리 수: 1 =====

일반적인 조인을 사용하면 JPQL에서 조회 주체가 되는 엔티티만 불러오지만, Fetch Join 을 사용하면 조인이 걸린 연관 엔티티도 함께 불러온다. 따라서 단 하나의 쿼리만으로 게시글과 댓글을 조회할 수 있다.

📌 @EntityGraph

1
2
3
@Override
@EntityGraph(attributePaths = {"comments"})
List<Post> findAll();

@EntityGraph 를 적용하는 방법은 여러 가지가 있지만, findAll 메서드를 오버라이딩하도록 구현하였다. attributePaths 의 의미는 Post 엔티티와 연관된 comments 필드를 그래프 형태로 탐색한다는 의미이다.

내부적으로 Fetch Join과 유사한 LEFT OUTER JOIN 을 사용하게 된다.

📌 Batch Size

1
2
3
@BatchSize(size = 100)
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2025-07-06T21:33:15.125+09:00  INFO 12888 --- [nio-8080-exec-1] o.l.p.post.service.PostService           : ===== 댓글 접근 전 쿼리 수: 1 =====
2025-07-06T21:33:15.217+09:00 DEBUG 12888 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
    select
        p1_0.id,
        p1_0.content,
        p1_0.title 
    from
        post p1_0
2025-07-06T21:33:15.247+09:00 DEBUG 12888 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
    select
        c1_0.post_id,
        c1_0.id,
        c1_0.content 
    from
        comment c1_0 
    where
        c1_0.post_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2025-07-06T21:33:15.253+09:00  INFO 12888 --- [nio-8080-exec-1] o.l.p.post.service.PostService           : ===== 댓글 접근 후 쿼리 수: 2 =====

@BatchSize 어노테이션을 사용하면 IN 절을 사용하여 설정된 size만큼의 연관관계 엔티티를 한 번에 조회한다.

이전 해결 방법과 다른 점은, 댓글만 조회하는 쿼리가 존재한다는 것이다. 즉, 단 한 번위 쿼리로 최적화되지 않는다.

연관관계 엔티티를 조회할 때 N개의 쿼리가 아니라 N/size 개의 쿼리만 발생한다. 즉, size에 따라 발생하는 쿼리의 수가 달라질 수 있다.

📌 깃허브

https://github.com/whqtker/practice-n-plus-1

This post is licensed under CC BY 4.0 by the author.