[JPA] N+1 문제

2025. 7. 7. 21:04·Spring Framework/JPA
반응형
  • N+1 문제란 ORM 사용 시 발생하는 대표적인 성능 이슈
  • 부모 엔티티를 조회하는 1번의 쿼리 후, 각 부모마다 연관된 자식 엔티티를 조회하는 N번의 추가 쿼리가 실행되는 현상
  • 예시: 100개의 게시물과 댓글 있을 때
    • 게시글 전체 조회: 1번 + 각 게시글의 댓글 조회: 100번 ⇒ 총 101번의 쿼리가 실행
    • 모든 정보를 한 번의 쿼리로 가져오지 못하고 추가 쿼리로 인해 성능 이슈가 발생한다.

문제 상황

  • 게시물(Post), 댓글(Comment) 엔티티가 있다.(이외의 테이블은 없다고 가정)
  • 게시물 목록 조회 시 각 게시물의 댓글 갯수를 함께 조회하고 싶다.
@Entity
@Table(name = "post")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

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

    public Post(String title) {
        this.title = title;
    }

    public void addComment(Comment comment) {
        comments.add(comment);
        comment.setPost(this);
    }

}

@Entity
@Table(name = "comment")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    @Setter(AccessLevel.PACKAGE)
    private Post post;

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

}

서비스 클래스에서 목록을 조회한다.

// DTO 정의
@Builder
public record PostSummaryDto(Long id, String title, int commentCount) {
}

// 서비스 클래스
@Service
@Transactional
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    public List<PostSummaryDto> findAll() {
        return postRepository.findAll()
                .stream()
                .map(post -> PostSummaryDto.builder()
                        .id(post.getId())
                        .title(post.getTitle())
                        .commentCount(post.getComments().size())
                        .build()
                )
                .toList();
    }
}

PostService::findAll 을 호출하면 아래의 쿼리가 발생한다.

select p1_0.id,p1_0.title from post p1_0; -- 게시물 목록 조회

-- 이 후 조회한 게시물 수 만큼 Comment에 대한 SELECT 쿼리가 발생한다.
select c1_0.post_id,c1_0.id,c1_0.content from comment c1_0 where c1_0.post_id=1;

문제 원인

  • 지연 로딩된 연관 엔티티를 반복문 내에서 접근할 때 N+1 문제가 발생한다. PostService::findAll() 에서 조회된 게시물의 수 만큼 post.getComments().size() 를 반복해서 호출하고 있다.
public List<PostSummaryDto> findAll() {
    return postRepository.findAll()
            .stream()
            .map(post -> PostSummaryDto.builder()
                    .id(post.getId())
                    .title(post.getTitle())
                    .commentCount(post.getComments().size()) // N+1 발생
                    .build()
            )
            .toList();
}

필요한 연관 데이터를 미리 로딩하지 않고, 반복문 안에서 개별적으로 접근하기 때문에 N+1 문제가 발생한다. 본 예시 코드에서는 로딩하지 않은 Comment 엔티티를 조회해서 발생했다.

해결 방법

1. JOIN FETCH

  • 한 번의 쿼리로 모든 데이터 조회할 수 있다.
  • 카테시안 곱으로 인한 데이터 중복이 발생할 수 있다.
    • hibernate6 이후에는 DISTINCT 생략 가능
  • 페이징 처리 시 메모리에서 처리된다.
public interface PostRepository extends JpaRepository<Post, Long> {
    @Query("SELECT p FROM Post p LEFT JOIN FETCH p.comments") // hibernate6 이상에서는 DISTINCT 생략 가능
    List<Post> findAllWithCommentsLeftJoin(); // LEFT JOIN을 하지 않으면 댓글이 없는 Post가 조회되지 않는다.
}

페이징 시 문제가 되는 상황

  • 페이징이 전혀 적용되지 않고, 조건에 해당하는 모든 데이터를 가져와 메모리에 올려두고 사용한다.
public interface PostRepository extends JpaRepository<Post, Long> {

		// ...
		
    @Query("SELECT p FROM Post p LEFT JOIN FETCH p.comments")
    Page<Post> findAllWithComments(Pageable pageable);
    
}

발생하는 쿼리를 보면 Pageable을 추가하더라도 SQL에 LIMIT, OFFSET 구문 없이 모든 Post를 조회하는걸 확인할 수 있다.

select 
    ...
from post p1_0 
    left join comment c1_0
    on p1_0.id=c1_0.post_id;
-- limit, offet?

2. @EntityGraph

  • JPQL을 직접 작성해 fetch join을 수행하는 대신 애노테이션만으로 연관 엔티티를 함께 로딩할 수 있다.
  • 발생 쿼리를 확인해보면 fetch join을 사용했을 때와 동일한 SQL을 볼 수 있다.
  • fetch join과 동일한 문제(카타시안곱, 페이징)를 고려해야한다.
public interface PostRepository extends JpaRepository<Post, Long> {

    @EntityGraph(attributePaths = {"comments"})
    List<Post> findAll();
    
    // 특정 메서드에도 적용 가능
    @EntityGraph(attributePaths = {"comments"})
    Optional<Post> findById(Long id);
    
    // 쿼리 메서드에도 적용 가능
    @EntityGraph(attributePaths = {"comments"})
    List<Post> findByTitle(String title);
}

3. Batch Size 설정

  • N+1 문제를 완전히 제거하지는 않지만 N번의 쿼리를 적절한 배치 크기로 묶어서 쿼리 횟수를 줄인다.
  • 지연 로딩 시 연관 엔티티를 한 번에 여러 개씩 조회하여 성능을 개선
  • 예시: 100개의 게시물이 있을 때
    • Batch Size 없음: 1(게시물) + 100(각 댓글) = 101번의 쿼리
    • Batch Size = 10: 1(게시물) + 10(댓글 배치) = 11번의 쿼리
// application.properties에 설정 추가
spring.jpa.properties.hibernate.default_batch_fetch_size=100

// 또는 엔티티의 속성에 @BatchSize 애너테이션 추가
@Entity
public class Post {

    // ....

    @BatchSize(size = 5)  // 댓글을 5개씩 배치로 조회
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Comment> comments = new ArrayList<>();

}

// 10개의 게시물을 조회한 결과
select p1_0.id,p1_0.title from post p1_0;
select ... from comment c1_0 where c1_0.post_id = any ('{"700","701","702","703","704"}');
select ... from comment c1_0 where c1_0.post_id = any ('{"705","706","707","708","709"}');
  • 페이징 처리 시에도 문제없이 동작하며 카테시안 곱 문제가 발생하지 않는다.
  • 필요한 데이터만 로딩한다.(이전의 방법들은 조건에 맞는 모든 데이터를 메모리에 불러와야했다.)
  • 하지만 여전히 쿼리가 여러번 발생한다. 또 적절한 값 설정을 고려해야한다.

4. FetchMode.SUBSELECT

  • 연관 엔티티를 조회할 때 IN 절 대신 서브쿼리를 사용하는 방식
  • 첫 번째 쿼리로 부모 엔티티들을 조회한 후, 두 번째 쿼리에서 서브쿼리로 한 번에 모든 자식 엔티티를 조회 : 두 번의 쿼리 발생
@Entity
public class Post {

    // ....

    @Fetch(FetchMode.SUBSELECT)  // 서브쿼리로 일괄 조회
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Comment> comments = new ArrayList<>();

}

select p1_0.id,p1_0.title from post p1_0; -- 모든 게시물 조회
select ...
from comment c1_0
where c1_0.post_id in (select p1_0.id from post p1_0); -- 서브쿼리
  • 페이징 처리 시에도 문제없이 동작하며 카테시안 곱 문제가 발생하지 않는다.
  • Hibernate에서만 사용할 수 있다.(다른 JPA 구현체에서 불가능)
  • 지연 로딩되기 때문에 연관 엔티티에 실제로 접근 할 때 서브쿼리가 실행된다.
  • 단일 엔티티 조회 시 효과 없다.

N+1 문제는 ORM을 사용할 때 반드시 만나게 되는 대표적인 성능 이슈이다. JPA와 Hibernate를 사용하는 환경에서 해결할 수 있는 여러 방법을 알아봤다. 각 방법은 고유한 특징과 장단점이 있으므로, 상황에 맞는 적절한 방법을 고민해야 한다. 특히 무조건 쿼리 수를 줄이는 것보다 애플리케이션의 특성과 데이터 특성을 고려한 최적화가 중요하다.

반응형
저작자표시 비영리 변경금지 (새창열림)
'Spring Framework/JPA' 카테고리의 다른 글
  • [JPA] 5개 데이터가 4+1개 쿼리로 나누어지다.
  • [JPA] Batch Insert
  • [JPA] orphanRemoval, CasecaseType.REMOVE
덴마크초코우유
덴마크초코우유
IT, 알고리즘, 프로그래밍 언어, 자료구조 등 정리
    반응형
  • 덴마크초코우유
    이것저것끄적
    덴마크초코우유
  • 전체
    오늘
    어제
    • 분류 전체보기 (130) N
      • Spring Framework (13)
        • Spring (7)
        • JPA (4)
        • Spring Security (0)
      • Language (52)
        • Java (12)
        • Python (10)
        • JavaScript (5)
        • NUXT (2)
        • C C++ (15)
        • PHP (8)
      • DB (16)
        • MySQL (10)
        • Reids (3)
        • Memcached (2)
      • 개발 (3)
      • 프로젝트 (4) N
      • Book (2)
      • PS (15)
        • 기타 (2)
        • 백준 (2)
        • 프로그래머스 (10)
      • 딥러닝 (8)
        • CUDA (0)
        • Pytorch (0)
        • 모델 (0)
        • 컴퓨터 비전 (4)
        • OpenCV (1)
      • 기타 (16)
        • 디자인패턴 (2)
        • UnrealEngine (8)
        • ubuntu (1)
        • node.js (1)
        • 블로그 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 미디어로그
    • 위치로그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Unreal
    php
    알고리즘
    클래스
    언리얼엔진4
    C
    게임
    Python
    NUXT
    Unreal Engine
    MySQL
    select
    map
    딥러닝
    C++
    웹
    pytorch
    FPS
    자바
    프로그래머스
    redis
    블루프린트
    memcached
    JavaScript
    CPP
    PS
    게임 개발
    JS
    mscoco
    파이썬
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
덴마크초코우유
[JPA] N+1 문제
상단으로

티스토리툴바