오늘은 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에 대한 정리를 마치겠습니다.
감사합니다.
'Database' 카테고리의 다른 글
Indexing, Partitioning 을 통해 서비스 성능 개선하기 (0) | 2025.06.12 |
---|---|
[Postgresql] Transaction Isolation Level (0) | 2022.05.20 |
[PostgreSQL] upsert 시 excluded 명령어 (0) | 2022.03.25 |
[MyBatis] if와 foreach를 활용하여 동적 쿼리생성 (필터링 기능) (0) | 2019.12.18 |