Database

JPA의 N+1 문제, 왜 생기고 어떻게 해결할까?

zamezzz 2025. 6. 13. 21:59

오늘은 JPA를 사용한다면 반드시 알아야 하고, 자주 겪을 수 있는 N+1 문제에 대해 정리해보겠습니다.

 

▶️ N+1 문제란?

쉽게 말하면, DB에서 1번만 가져오면 될 데이터를 여러 번에 걸쳐 조회하게 되는 현상입니다.

 

간단한 예를 들어 게시글 10개를 가져오면서, 각 게시글의 작성자 정보도 함께 조회한다고 해보겠습니다.
- 게시글 1번 조회 → 작성자 1번 조회 (10개) → 총 11번 쿼리 실행.

 

이게 바로 N+1 문제입니다. (N+1은 "1개의 select + N개의 연관 select"의 의미입니다.)

 

▶️ 왜 발생할까?

JPA는 객체지향스럽게 데이터를 가져오고자 지연 로딩(LAZY)을 기본으로 합니다.
즉, 연관된 엔티티는 처음에 바로 가져오지 않고, 실제로 접근할 때 추가 쿼리를 날립니다.

 

게시글과 해당 작성자 정보를 조회하는 간단한 코드를 보겠습니다.

List<Post> posts = postRepository.findAll();
for (Post post : posts) {
    System.out.println(post.getAuthor().getNickname()); // 이 시점에서 author 조회 쿼리 발생
}

 

 

게시글은 한 번의 쿼리로 가져왔지만, 작성자(author)는 접근 시점마다 따로 가져오기 때문에 결국 게시글 수만큼 추가 쿼리가 발생합니다.

이것이 성능 이슈의 핵심입니다.

 

▶️ N+1 문제 발생 주요 사례와 해결 방법

N+1 문제는 방향에 관계없이 발생할 수 있으며, 양방향 연관 관계 모두에서 조심해야 합니다.

그럼 각 방향에서 발생할 수 있는 예시와 함께 어떻게 해결하면 될지 한번 작성해보겠습니다.

 

⊙ 예시 1. 게시판 API의 조회 성능 이슈 (부모 -> 자식 방향의 조회)

@GetMapping("/posts")
public List<PostDto> getPosts() {
    List<Post> posts = postRepository.findAll();
    return posts.stream()
                .map(post -> new PostDto(post.getId(), post.getAuthor().getNickname()))
                .collect(Collectors.toList());
}

 

 

예시 API의 문제점

- findAll()로 조회된 게시글 100개 일 때 → 작성자(author) 조회 쿼리 100번 추가됨.

- 로그에서 select 쿼리만 101번 찍힘 → 성능 저하 및 DB 부하 증가

 

해결 방법 1. fetch join 사용

@Query("SELECT p FROM Post p JOIN FETCH p.author") 
List<Post> findAllWithAuthor();

 

  • JOIN FETCH는 연관된 엔티티를 한 번의 쿼리로 같이 조회하게 해줍니다.
  • Lazy 로딩이 아닌 Eager처럼 동작하여 성능 개선에 효과적.

   ⚠️ 주의점

  • 컬렉션을 fetch join 할 경우 중복 발생 (ex: n개의 댓글도 fetch join 하는 경우)
  • 페이징 불가능

 

해결 방법 2. DTO로 Projection

@Query("SELECT new com.example.dto.PostDto(p.id, a.nickname) FROM Post p JOIN p.author a") 
List<PostDto> findAllDtos();
  • 필요한 필드만 조회 → 불필요한 엔티티 로딩 제거
  • 성능 최적화와 명확한 API 응답 구조를 함께 달성
  • Pageable을 인자로 받아 setFirstResult, setMaxResults를 통해 페이징 처리

   ⚠️ 주의점

  • 엔티티가 아니므로 영속성 컨텍스트 미적용 (변경 감지 X)
  • 쿼리 재사용이 어려울 수 있음

 

⊙ 예시 2. 해당 댓글이 달린 게시글을 조회하는 API의 성능 이슈 (자식 → 부모 방향의 조회)

@GetMapping("/comments")
public List<CommentDto> getComments() {
    List<Comment> comments = commentRepository.findAll();
    return comments.stream()
            .map(c -> new CommentDto(c.getId(), c.getPost().getTitle())) // 여기서 post 조회
            .collect(Collectors.toList());
}

 

예시 API의 문제점

 

- Comment → Post는 ManyToOne 관계 (N:1)

- 1개의 게시글에 수십 개 댓글이 연결되어 있어도, 지연 로딩이므로 댓글마다 게시글을 따로 조회해버립니다 (N+1)

 

해결 방법 1. fetch join 사용

@Query("SELECT c FROM Comment c JOIN FETCH c.post")
List<Comment> findAllWithPost();

 

해결 방법 2. DTO로 Projection ( + Paging)

@Repository
@RequiredArgsConstructor
public class CommentQueryRepository {
    private final EntityManager em;

    public Page<CommentResponse> findAllWithPost(Pageable pageable) {
        List<CommentResponse> content = em.createQuery("""
            SELECT new com.example.api.CommentResponse(c.id, c.content, p.title)
            FROM Comment c
            JOIN c.post p
            ORDER BY c.id DESC
        """, CommentResponse.class)
            .setFirstResult((int) pageable.getOffset())
            .setMaxResults(pageable.getPageSize())
            .getResultList();

        Long total = em.createQuery("SELECT COUNT(c) FROM Comment c", Long.class)
            .getSingleResult();

        return new PageImpl<>(content, pageable, total);
    }
}

 

여기서는 실제 Pageable을 받아 페이징하는 코드까지 작성해봤습니다.

해결 방법에 대한 설명은 예시1과 동일하니 생략하겠습니다. 

 

그럼에도 예시1과 예시2를 구분한것은

부모 -> 자식 / 자식 -> 부모 조회 방향에 상관없이 N+1 문제가 발생할 수 있음을 보여주려 하였습니다.

 

▶️ 마무리

위의 사례와 해결방법을 간단히 정리하면 다음과 같습니다.

  • fetch join은 매우 강력하지만, 페이징(Pageable)과는 함께 못 씀
  • fetch join 사용 시 데이터 중복 (row 증가) 문제도 주의해야 함.
  • DTO projection은 쿼리 성능은 좋지만 클래스가 많아질 수 있음

N+1을 이해하면, 이를 해결하는 방법은 어렵지 않습니다.

위의 fetch join이나 DTO Projection 외에도 해결 방법은 더 많이 있습니다.

 

쿼리와 서비스 상황에 맞게 올바른 해결책을 찾는 것이 중요합니다. 그럼 오늘 N+1에 대한 정리를 마치겠습니다.

감사합니다.