N+1 문제

이재서·2026년 7월 8일

N+1 문제는 무엇인가?

jpa는 아주 유용한 ORM 프레임워크이지만, 데이터베이스와 객체 사이의 불일치성을 완전히 해결할 수는 없었다. RDB에서의 관계를 객체에서 완벽하게 풀어낼 수는 없었지만 jpa는 객체의 참조를 저장하는 방식과 영속성 컨텍스트라는 방법으로 관계를 최대한 표현하고자 했다. 여기서 jpa가 성능을 끌어올리기 위한 방법 중 하나인 영속성 컨텍스트에 관계를 저장하는 방식에 의해서 N+1 문제가 발생하게 되었다.

결론부터 말하자면, N+1 문제는 영속성 컨텍스트의 proxy 때문에 발생하는 문제다.

@Entity
public class Post {
	@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String content;
    
    @OneToMany(mappedBy = "post")
    private List<Comment> comments;
}

@Entity
public class Comments {
	@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String content
    
    @ManyToOne
    @JoinColumn(name = "post_id")
    Post post;
}

엔티티

@Service
@RequiredArgsConstructor
public class PostService {
	private final PostRepository postRepository;
    
    public void nPlusOneProblem {
    	List<Post> posts = postRepository.findAll();
        List<List<String>> commentTitles = posts.forEach(post -> post.getComments().stream()
        	.map(Comment::getTitle)
            .toList();
        )
    }
}

N+1 문제 발생 메서드

위의 메서드가 어떻게 작동하는지 확인해보자.

  1. postRepository.findAll()을 통해 post 테이블의 모든 데이터를 가져온다. fetchType은 기본값인 LAZY이기 때문에 Comments에는 proxy 객체들이 저장된다.
  2. Post 객체에서 comments 컬렉션을 통해 Comment 객체에 접근을 시도한다. 이때, comments는 proxy 객체들이 저장되어 있기 때문에 jpa의 영속성 컨텍스트는 데이터베이스에 실제 객체를 가져오기 위해 쿼리를 실행한다.

여기서 문제는 posts의 원소 각각의 comments를 얻기 위해 데이터베이스에 원소 하나 당 한 번씩 쿼리를 시도한다는 점이다. posts의 크기를 N이라고 하면 N+1번 쿼리가 발생하기 때문에 N+1 문제라고 하는 것이다.

어떻게 해결하는가?

N+1 문제를 해결하기 위한 방법은 5가지 정도 있다. 하나씩 확인해보자.

1. fetchType.EAGER

fetchType을 EAGER로 설정한다면 postRepository.findAll() 메서드를 호출했을 때 join을 통해 각 Post 원소에 대한 Comments 객체를 같이 한 번에 쿼리로 불러오기 때문에 N+1 문제를 해결할 수는 있다.

하지만 Post의 데이터만을 사용하고자 하는데 Comment 객체까지 쿼리로 불러온다는 점은 오히려 성능을 떨어뜨리는 원인이 된다. 이 때문에 fecthType.EAGER는 추천되는 사항이 아니고 N+1 문제를 해결하기 적절한 방법도 아니다.

2. fetch join

fetch join은 SQL에서 제공하는 join 기능이 아니라 JPQL에서 제공하는 join 기능이다. 성능향상을 위해 만들어진 기능이지만, N+1 문제를 해결할 수 있다. fetch join은 Root Entity에 연관되어 있는 객체를 join을 통해 한 번에 가져오는 방법이다.

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
	@Query("select Distinct p from Post p join fetch p.comments")
	List<Post> findAllFetchJoinComment();
}

@Query 어노테이션을 통해 직접 JPQL을 해당 메서드에 매핑하고, sql을 실행할 때 join을 통해 연관된 객체를 데이터베이스에서 가져와 영속화하는 과정을 진행한다. 이를 통해 Post 객체에 연관되어 있는 Comment 객체도 해당 메서드를 통해서 한 번에 영속화되기 때문에, Post 객체의 comments 컬렉션에는 proxy 객체가 아니라 진짜 객체가 포함되게 된다.

fetch join에는 주의해야 할 사항이 몇 가지 있는데 이는 다음에 정리하겠다.

3. @EntityGraph

일반적으로 fetchType을 설정해놓으면 해당 관계에 대해서는 fetchType의 설정대로 엔티티를 조회한다. 하지만 위에서도 말했듯이 N+1 문제같은 것을 해결하고자 fetchType.EAGER를 설정하는 것은 득보다 실이 더 많을 수도 있다. 이를 해결하고자 fetchType을 메서드 범위에서 변경할 수 있도록 하는 것이 @EntityGraph 어노테이션이다.

@Repository
 public interface PostRepository extends JpaRepository<Post, Long> {
 	@EntityGraph(attributePaths = {"comments"})
	List<Post> findAllFetchJoinComment();
}

@EntityGraph 어노테이션은 사실상 fetch join을 하는 것과 크게 다를 것은 없다. 하지만 fetch join과는 다르게 @EntityGraph는 left outer join밖에 사용할 수 없고, JPQL을 따로 작성하지 않아도 된다.

  • @EntityGraph의 속성
속성명타입기본값설명
attributePathsString[]{}바로 페치 조인할 엔티티의 필드 명을 문자열 배열로 지정
typeEntityGraphTypeFETCH그래프 외의 다른 필드들을 FETCH 정책으로 채울지, LOAD 정책으로 채울지 결정

4. default_batch_size, @BatchSize

위의 방법들이 join을 통해서 연관된 엔티티의 데이터를 가져오는 방식인 반면에, 자식 엔티티를 조회하는 쿼리의 개수를 제한하는 방법이다.

default_batch_size

프로젝트 전체에 기본 배치 사이즈를 적용하는 방법으로, application.yml이나 application.properties에 설정한다.

spring:
	jpa:
    	properties:
        	hibernate:
            	default_batch_size: 100

@BatchSize

전역 설정을 사용하지 않거나, 특정 엔티티나 컬렉션에 배치 사이즈를 적용할 때 사용한다.

@Entity
public class Post {
	@Id @GeneratedValue(strategy = GenerataionTpye.IDENTITY)
    private Long id;
    private String title;
    private String content;
    
    @BatchSize(size = 50)
    @OneToMany(mappedBy = "post")
    priate List<Comment> comments = new ArrayList<>();
}
profile
아무거나 쓰는 중

0개의 댓글