본문 바로가기
한화시스템 Beyond SW Camp/백엔드

[Spring Boot] N+1 문제

by taeh00n 2025. 2. 24.

N+1 문제

Spring JPA를 사용하며 프로젝트를 하다보면 한 번씩 N+1 문제를 겪는다.

public class Member  {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idx;
    private String email;
    private String password;

    @Builder.Default
    @OneToMany(mappedBy = "member")
    List<Post> posts = new ArrayList<>();
}

public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idx;
    private String contents;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="member_idx")
    private Member member;
}

위와 같이 MemberPost1:N(일대다)관계라고 가정을 해보겠다.

 

N+1 문제 발생

하나의 회원을 조회하면서 그 회원의 게시글도 함께 조회한다고 가정해 보겠다.

public void getMemberWithPosts(Long memberId) {
    List<Member> members = memberRepository.findAll();
    for (Member member : members) {
        System.out.println(member.getPosts().size()); // 여기서 N+1 문제 발생!
    }
}

위의 코드가 모든 회원의 게시물을 조회하는 코드이다. findAll()로 모든 회원을 조회하고 해당 회원이 작성한 게시글은 member.getPosts()로 가져온다.

이렇게 되면 findAll() 할 때 SELECT * FROM Member 모든 회원을 조회하는 쿼리가 1번 동작하고 member.getPosts() 할 때 각 회원의 게시물을 조회하는  SELECT * FROM Post WHERE member_id = ? 라는 쿼리가 회원의 수(N개)만큼 동작한다.

예를 들어 10명의 회원이 있고 모든 회원들이 10개씩 게시물을 갖고 있다면 모든 회원을 조회하는 쿼리 1번, 각 회원의 게시물을 조회하는 쿼리 10번으로 10+1번의 쿼리가 실행된다. 이것을 N+1 문제라고 한다.

 

=> 이렇게 불필요한 반복 쿼리 때문에 성능이 저하가 되고 회원 수가 많게 되면은 쿼리 수가 급격하게 많아질 것이여서 프로젝트 중 성능 개선을 신경쓴다면 꼭 해결해야할 문제이다.


해결 방법 1 : Fetch Join 사용 (ManyToOne, OneToOne)

Fetch Join을 사용하면 단 한 번의 쿼리로 모든 데이터를 가져올 수 있다.

@Query("SELECT m FROM Member m JOIN FETCH m.posts")
List<Member> findAllMembersWithPosts();

public void getMemberWithPosts(Long memberId) {
    List<Member> members = memberRepository.findAllMembersWithPosts();
    for (Member member : members) {
        System.out.println(member.getPosts().size());  // 추가 쿼리 발생 X
    }
}

실행되는 쿼리

SELECT m.*, p.* 
FROM Member m
LEFT JOIN Post p ON m.idx = p.member_idx;

이렇게 하면 한 번의 쿼리모든 회원과 게시물을 한 번에 가져온다. 한 번의 쿼리로 결과가 메모리에 로드되어있는 상태이므로 for 반복문이 돌 때 추가적인 쿼리가 발생하지 않는다.

 

지연 로딩(Lazy Loading) : 연관된 엔티티가 실제로 필요할 때 로딩. (member.getPost() 호출 시 Post 조회)

즉시 로딩(Eager Loading) : 연관된 엔티티를 즉시 로딩. (Member 조회 시 Post도 함께 조회)

 

=> Fetch join은 연관된 엔티티를 조인해 한 번의 쿼리로 즉시 로딩하는 방식


해결 방법 2 : EntityGraph 사용 

JPA에서 연관된 엔티티를 즉시 로딩(Eager Loading) 방식으로 로드할 때 사용되는 방법이다.

EntityGraph는 연관 관계에 있는 엔티티를 어떻게 로드할지 명시적으로 설정하는 방법이다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    @EntityGraph(attributePaths = {"posts"})
    Member findById(Long id); // Member와 그와 관련된 posts를 즉시 로딩
}

Member를 조회할 때 posts 연관 관계를 즉시 로딩으로 처리하겠다는 코드이다. 기본적으로 @OneToMany지연 로딩이지만 EntityGraph로 즉시 로딩으로 바꾸면 Member를 조회하며 이와 연관된 posts도 즉시 로딩한다는 뜻이다. (Member를 조회하며 조인을 이용해 posts도 함께 가져온다는 뜻)

실행되는 쿼리

SELECT m.* 
FROM member m
LEFT JOIN post p ON m.idx = p.member_idx
WHERE m.idx = ?

결과적으로 Member와 posts의 정보를 한 번에 조회하기 때문에 N+1 문제가 발생하지 않는다.


해결 방법 3 : 페이징 처리 (OneToMany, ManyToMany)

페이징 처리 : 데이터베이스에서 너무 많은 데이터를 한 번에 처리하지 않고 필요한 데이터만 일정 단위로 나눠서 조회하는 방식. 메모리 성능에 대한 부담을 줄여준다.

페이징 처리가 필요없으면

Fetch join을 이용해 한 번에 연관된 데이터를 로딩한다. (N+1 문제 방지)


페이징 처리가 필요하고 페이지 번호도 필요하면 (Page로 반환)

배치 사이즈(batch size)를 사용해 최적화한다. 배치 사이즈는 연관된 엔티티를 한 번에 가져올 개수를 설정하는 옵션이다.

데이터를 Page로 반환하기 때문에 페이징 처리된 결과를 쉽게 받을 수 있다.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "member")
    @BatchSize(size = 50)  // 연관된 게시물들을 한 번에 50개씩 로드
    private List<Post> posts;
}

배치 사이즈는 위와같이 @BatchSize 애노테이션을 사용해서 @BatchSize(size = xx) 형태로 작성한다.

@Query("SELECT m FROM Member m LEFT JOIN FETCH m.posts")
Page<Member> findAllMembersWithPosts(Pageable pageable);

Pageable 객체를 이용해 페이징 처리된 데이터를 가져오고 Page 객체는 페이지 정보와 함께 데이터를 제공해서 페이지 번호와 크기 등을 쉽게 처리할 수 있다. 배치 사이즈를 사용하여 한 번에 처리할 데이터를 미리 설정하면 데이터를 가져올 때 불필요한 쿼리 홧수를 줄일 수 있다.


페이징 처리가 필요하고 페이지 번호도 필요하면 (Slice로 반환)

Slice는 Page와 비슷하지만 페이지 번호를 반환하지 않고 마지막 페이지인지 여부는 알 수 있다.

@Query("SELECT m FROM Member m LEFT JOIN FETCH m.posts")
Slice<Member> findAllMembersWithPosts(Pageable pageable);

Slice 반환페이지 번호 없이 데이터를 가져오고 마지막 페이지를 체크할 수 있으므로 페이지 번호가 필요없으면 Slice 사용하는 것이 효과적이다.

예외 발생 경우 (ToMany 여러개 가져오려고 할 때)

두 개 이상의 ToMany 관계를 한 번에 Fetch join으로 처리하면 MultipleBagFetchException 발생

데이터가 제일 많은 ToMany를 우선적으로 페치 조인으로 처리하고 나머지는 배치 사이즈로 최적화한다.

@Entity
public class Author {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "author")
    private List<Book> books;
}

@Entity
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    
    @ManyToOne
    private Author author;
    
    @OneToMany(mappedBy = "book")
    private List<Comment> comments;
}

@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String content;

    @ManyToOne
    private Book book;
}

저자는 여러 권의 책을 쓸 수 있고, 책에는 여러 개의 댓글이 달릴 수 있다. 만약 Author를 조회하면서 Book과 Comment도 한 번에 Fetch join 하려고 한다면 MultipleBagFetchException가 발생할 수 있다.

public List<Author> fetchAuthorsWithBooksAndComments() {
    return entityManager.createQuery(
        "SELECT a FROM Author a " +
        "JOIN FETCH a.books b " + 
        "JOIN FETCH b.comments c", Author.class)
        .getResultList();
}

 

해결 방법

public List<Author> fetchAuthorsWithBooksAndComments() {
    return entityManager.createQuery(
        "SELECT a FROM Author a " +
        "JOIN FETCH a.books b", Author.class)
        .setHint("javax.persistence.fetchgraph", "AuthorWithBooks")
        .getResultList();
}

public List<Book> fetchBooksWithComments() {
    return entityManager.createQuery(
        "SELECT b FROM Book b " +
        "LEFT JOIN FETCH b.comments c", Book.class)
        .setMaxResults(10) // 배치 사이즈
        .getResultList();
}

이 코드에서는 Book을 먼저 Fetch Join으로 가져오고 Comment는 배치 사이즈로 묶어서 나중에 처리한다. 이렇게 하면 한 번에 너무 많은 데이터를 가져오지 않도록 제어할 수 있다.


해결 방법 3 : 반정규화(Denormalization)

반정규화 : 데이터를 중복 저장하거나 계산된 값을 미리 저장하여 쿼리 성능을 향상시키는 방법 (COUNT, SUM, AVG)

예를 들어 매번 COUNT, SUM, AVG를 실행하는 대신 미리 계산된 결과를 저장해두면 쿼리 실행 속도가 빨라진다.

반정규화 적용 전

public interface PostRepository extends JpaRepository<Post, Long> {
    @Query("SELECT p, COUNT(l) FROM Post p LEFT JOIN p.likes l GROUP BY p.id")
    List<Object[]> findAllPostsWithLikeCount(); // 게시글과 해당 게시글의 좋아요 수를 조회
}

기존에는 위와 같이 LEFT JOIN을 사용Post와 Likes 테이블을 조인하고 좋아요 수를 집계하는 방식으로 사용한다. 이 방식은 JOIN 연산이 필요하기에 게시글 수가 많으면 성능 문제가 발생할 수 있다.


반정규화 적용

반정규화를 통해 좋아요 수를 직접 Post 엔티티에 저장한다. 이렇게 하면 좋아요 수 계산할 때마다 JOIN이 아닌 Post 테이블에 저장된 값만 조회하면 된다.

@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idx;
    private String contents;

    @Builder.Default
    @OneToMany(mappedBy = "post")
    List<Likes> likes = new ArrayList<>();

    private int likesCount;

    public void addLikesCount() {
        this.likesCount = this.likesCount + 1;
    }
    public void subLikesCount() {
        this.likesCount = this.likesCount - 1;
    }
}

@Entity
public class Like {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "post_id")
    private Post post;  // 좋아요는 특정 게시글에 속함
}
@Transactional
public CreateLikesRes create(Member member, Long idx) {
    Post post = postRepository.findById(idx).get();
    Optional<Likes> result = likesRepository.findByMemberAndPost(member, post);

    if (result.isPresent()) {
        post.subLikesCount();
        likesRepository.delete(result.get());
        return CreateLikesRes.builder().result("삭제 성공").build();
    } else {
        post.addLikesCount();
        likesRepository.save(Likes.builder().member(member).post(post).build());
        return CreateLikesRes.builder().result("저장 성공").build();
    }
}

반정규화 적용 후Post 엔티티에 likesCount 필드를 추가해 좋아요 수를 직접 저장한다. 이를 통해서 좋아요 수 계산 때마다 JOIN이 필요없고 이미 저장된 값을 조회만 하면 집계 함수 없이 빠르게 결과를 얻어낼 수 있다..