[Spring] N+1 문제

2025. 10. 3. 01:35·Spring

N+1 문제는 JPA에서 연관관계가 있는 엔티티를 조회할 때, 의도치 않게 쿼리가 1번 + 추가로 N번 더 나가는 현상을 말한다.

 

예시 

예시를 통해서 진행해보자. 게시글 목록 화면에서 글과 작성자, 댓글까지 같이 보여줘야하는 상황이라고 하자. 

 

엔티티 

// 게시글 엔티티 
@Entity
public class Post {
    @Id 
    @GeneratedValue 
    private Long id;
    String title;

    @ManyToOne(fetch = FetchType.LAZY)  
    private User author;

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

// 유저 엔티티 
@Entity
public class User { 
    @Id 
    @GeneratedValue 
    private Long id; 
    
    private String name; 
}

// 댓글 엔티티
@Entity
public class Comment {
    @Id
    @GeneratedValue 
    private Long id;
    
    @ManyToOne(fetch = FetchType.LAZY) 
    private Post post;
    
    String content;
}

 

문제 코드 

// Repository
public interface PostRepository extends JpaRepository<Post, Long> { }

// DTO 
public record PostListView(Long id, String title, String authorName, int commentCount) { }

// Service 
@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    @Transactional(readOnly = true)
    public List<PostListView> getPostList() {
        List<Post> posts = postRepository.findAll(); // 쿼리 1번: 게시글만 가져옴

        return posts.stream()
                .map(post -> new PostListView(
                        post.getId(),
                        post.getTitle(),
                        post.getAuthor().getName(),  // 여기서 author 접근 → 게시글 수만큼 추가 쿼리
                        post.getComments().size()    // 여기서 comments 접근 → 게시글 수만큼 추가 쿼리
                )).toList();
    }
}

위에 코드를 살펴보면,

일단 게시글이 5건일 경우에는 

1. `select * from post` 1번

2. author 접근할 때 `select * from user where id = ?` 일 때 5번

3. commet 접근할 때 `select * from comment where post_id = ?` 일 때 5번으로 

총 11번의 쿼리가 나간다. 


해결법

부모 1개당 연결된 대상이 최대 1개인 경우에는 조인해도 행 수가 잘 늘어나지 않고 이럴 경우에는 1)JPQL fetch join 또는 2)@EntityGraph를 사용한다. 반대로 부모 1개당 연결된 대상이 여러개일 경우 조인하면 행이 부풀어지기 때문에 fetch join 을 사용하는 것을 조심해야한다. 이럴 경우에는 3) 배칭로딩이나 4) 수동 설정을 사용하는 것이 좋다. 

  • to-one (=한 건을 보면 반대편이 최대 1건) : `@ManyToOne` (다대일), `@OneToOne` (일대일) 
  • to-many (=한 건을 보면 반대면이 N건) : `@OneToMany` (일대다)

 1) JPQL fetch join 

public interface PostRepository extends JpaRepository<Post, Long> {
    @Query("select p from Post p join fetch p.author")
    List<Post> findAllWithAuthor(); // Post + Author를 한번에 가져옴
}

@Transactional(readOnly = true)
public List<PostListView> getPostList() {
    List<Post> posts = postRepository.findAllWithAuthor(); // Post+Author 한번에 로딩

    return posts.stream()
            .map(p -> new PostListView(
                    p.getId(),
                    p.getTitle(),
                    p.getAuthor().getName(),  // 추가 쿼리 없음
                    p.getComments().size()    // 그러나 아직 여기서 댓글 N+1 문제가 남아있음
            ))
            .toList();
}

2) `@EntityGraph`

public interface PostRepository extends JpaRepository<Post, Long> {

    @EntityGraph(attributePaths = {"author"})
    @Query("select p from Post p")
    List<Post> findAllWithAuthorByEntityGraph();
}

3) 배치로딩 

아래처럼 application.yml에 작성해준다. 이러면 목록에서 여러 `post.getComments()`를 접근해도, 같은 IN 쿼리 몇 번으로 한꺼번에 가져온다. 그래서 `post.getComments()` 로 내용까지 순회해도 N+1이 아니라 소수의 쿼리로 끝난다. 

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100   # 50~200 권장
// Repository 
public interface PostRepository extends JpaRepository<Post, Long> {

    @EntityGraph(attributePaths = {"author"})           
    @Query("select p from Post p")
    Page<Post> findPageWithAuthor(Pageable pageable);   
}

// Service 
@Service
@RequiredArgsConstructor
public class PostService {
    private final PostRepository postRepository;

    @Transactional(readOnly = true)
    public Page<PostListView> getPostPage(Pageable pageable) {
        Page<Post> page = postRepository.findPageWithAuthor(pageable);

        // 여기서 post.getComments()에 여러 번 접근해도
        // comment는 내부적으로 IN 쿼리로 한/두 번에 가져온다.
        return page.map(post -> new PostListView(
                post.getId(),
                post.getTitle(),
                post.getAuthor().getName(),
                post.getComments().stream()
                    .map(comment -> new CommentView(comment.getId(), comment.getContent())
                    .toList()
        ));
    }
}

// DTO 
public record PostListView(Long id, String title, String authorName, int commentCount) {}
public record CommentView(Long id, String content){}

4) 수동으로 제어 

배치로딩을 사용하지 않고, 직접 두 번에 나누어서 가져와 묶어도 된다. 아래는 예시이다. 

public interface PostRepository extends JpaRepository<Post, Long> {

    @EntityGraph(attributePaths = {"author"})           
    @Query("select p from Post p")
    Page<Post> findPageWithAuthor(Pageable pageable);
}

public interface CommentRepository extends JpaRepository<Comment, Long> {

    List<Comment> findByPostIdIn(Collection<Long> postIds);
}
@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;
    private final CommentRepository commentRepository;

    @Transactional(readOnly = true)
    public Page<PostWithCommentsDto> getPost(Pageable pageable) {
    
        // 1) 부모 페이지 먼저 (Post + Author)
        Page<Post> page = postRepository.findPageWithAuthor(pageable);
        List<Post> posts = page.getContent();
        List<Long> postIds = posts.stream().map(Post::getId).toList();

        if (postIds.isEmpty()) {
            return page.map(p -> new PostWithCommentsDto(p.getId(), p.getTitle(), p.getAuthor().getName(), List.of()));
        }

        // 2) 자식 한 번에
        List<Comment> comments = commentRepository.findByPostIdIn(postIds);

        // 3) postId를 기준으로 그룹
        Map<Long, List<Comment>> byPostId = comments.stream()
                .collect(Collectors.groupingBy(comment -> comment.getPost().getId()));

        // 4) DTO로 매핑
        return page.map(post -> new PostWithCommentsDto(
                post.getId(),
                post.getTitle(),
                post.getAuthor().getName(),
                byPostId.getOrDefault(post.getId(), List.of())
        ));
    }
}

// DTO 
public record PostWithCommentsDto(Long id, String title, String authorName, List<Comment> comments) {}

'Spring' 카테고리의 다른 글

[Spring] DTO 활용 패턴 - toEntity(), from() 정적 팩토리 메서드  (0) 2025.10.13
[Spring] 외부 API 연동하기 (RestTemplate vs. WebClient vs. OpenFeign)  (0) 2025.10.05
[Spring] 객체지향 쿼리 언어  (0) 2025.10.02
[Spring] 즉시 로딩과 지연 로딩  (0) 2025.09.21
[Spring] Transactional 사용 시 자기 호출(Self-Invocation) 이슈  (0) 2025.09.11
'Spring' 카테고리의 다른 글
  • [Spring] DTO 활용 패턴 - toEntity(), from() 정적 팩토리 메서드
  • [Spring] 외부 API 연동하기 (RestTemplate vs. WebClient vs. OpenFeign)
  • [Spring] 객체지향 쿼리 언어
  • [Spring] 즉시 로딩과 지연 로딩
erika0915
erika0915
백엔드 개발자가 되고 싶어요 .
  • erika0915
    erikoding
    erika0915
  • 전체
    오늘
    어제
    • 분류 전체보기 (78)
      • 프로젝트 (13)
        • 끼니콩 (3)
        • 덕메랑 (3)
        • handDoc (7)
        • Haeil (0)
      • Java (9)
        • 클린코더스 (0)
      • Spring (30)
      • Redis (3)
      • CS (7)
        • 운영체제 (3)
        • 컴퓨터구조 (0)
        • 네트워크 (4)
      • DevOps (2)
      • 코딩테스트 (0)
      • Tech (14)
        • TDD (1)
        • 정리 (5)
        • 우테코 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    스프링부트
    jira
    java
    docker
    AI
    몽고디비
    스프링
    CoolSMS
    github
    도커
    MongoDB
    redis
    네트워크
    지라
    promtail
    OS
    coderabbit
    깃
    springboot
    운영체제
    레디스
    TDD
    깃허브
    파인튜닝
    STT
    git
    Spring
    Network
    자바
    코드레빗
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
erika0915
[Spring] N+1 문제

티스토리툴바