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 |
