반응형
- 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를 사용하는 환경에서 해결할 수 있는 여러 방법을 알아봤다. 각 방법은 고유한 특징과 장단점이 있으므로, 상황에 맞는 적절한 방법을 고민해야 한다. 특히 무조건 쿼리 수를 줄이는 것보다 애플리케이션의 특성과 데이터 특성을 고려한 최적화가 중요하다.
반응형