JPA N+1 문제 해결기: 뉴스 대표글 검색 성능 21배 개선
문제 상황
뉴스 목록 페이지(/news)에서 대표글(Featured) 탭의 검색 기능이 느리다는 제보를 받았습니다. 분석 결과, 전형적인 JPA N+1 문제가 발생하고 있었습니다.
증상
- 대표글 검색 시 응답 지연
- 동일한 패턴의 쿼리가 반복 실행
- 페이지당 20개 기사 조회 시 21개의 쿼리 발생
원인 분석
1. N+1 문제가 발생한 코드
// NewsArticleRepository.java - 문제의 코드 @Query("SELECT a FROM NewsArticle a " + "WHERE a.status = :status " + "AND a.adminNote IS NOT NULL " + "AND (LOWER(a.aiTitle) LIKE LOWER(CONCAT('%', :keyword, '%')) " + " OR LOWER(a.title) LIKE LOWER(CONCAT('%', :keyword, '%')))") Page<NewsArticle> searchFeaturedByTitle(...);
이 쿼리는 NewsArticle 엔티티만 조회합니다. 하지만 응답 DTO를 생성할 때 source 연관 엔티티에 접근하면서 각 기사마다 추가 쿼리가 발생했습니다.
// NewsArticleResponse.java public static NewsArticleResponse from(NewsArticle article) { return new NewsArticleResponse( // ... article.getSource().getName(), // LAZY 로딩 → 추가 쿼리! // ... ); }
2. 메모리 필터링 문제
// NewsArticleService.java - 문제의 코드 if (keyword != null && !keyword.isBlank()) { articles = switch (type) { case TITLE -> newsArticleRepository.searchFeaturedByTitle(status, keyword, pageable); // ... }; // 검색 결과에 카테고리 필터 적용 (메모리에서) - 문제! if (category != null) { List<NewsArticle> filtered = articles.getContent().stream() .filter(a -> a.getAiCategory() == category) .toList(); articles = new PageImpl<>(filtered, pageable, filtered.size()); } }
이 방식의 문제점:
- 페이징 정확도 저하: DB에서 20개 가져온 후 메모리에서 필터링하면 실제 반환 개수가 20개 미만
- 총 개수 오류:
totalElements가 실제 검색 결과와 불일치 - 정렬 무효화: 메모리 필터링 후 정렬이 깨질 수 있음
해결 방법
1. JOIN FETCH로 N+1 해결
// NewsArticleRepository.java - 개선된 코드 @Query(value = "SELECT a FROM NewsArticle a " + "JOIN FETCH a.source " + // source를 한 번에 조회! "WHERE a.status = :status " + "AND a.adminNote IS NOT NULL " + "AND (LOWER(a.aiTitle) LIKE LOWER(CONCAT('%', :keyword, '%')) " + " OR LOWER(a.title) LIKE LOWER(CONCAT('%', :keyword, '%')))", countQuery = "SELECT COUNT(a) FROM NewsArticle a " + // 페이징용 카운트 쿼리 분리 "WHERE a.status = :status AND a.adminNote IS NOT NULL " + "AND (LOWER(a.aiTitle) LIKE LOWER(CONCAT('%', :keyword, '%')) " + " OR LOWER(a.title) LIKE LOWER(CONCAT('%', :keyword, '%')))") Page<NewsArticle> searchFeaturedByTitle(...);
핵심 포인트:
JOIN FETCH a.source: 연관 엔티티를 한 번의 쿼리로 함께 조회countQuery분리: 페이징 시 COUNT 쿼리에서는 JOIN FETCH 제외 (필수!)
2. 카테고리 필터를 DB 쿼리로 이동
카테고리 + 키워드 조합을 위한 새 메서드를 추가했습니다.
// NewsArticleRepository.java - 카테고리 필터 포함 메서드 추가 @Query(value = "SELECT a FROM NewsArticle a " + "JOIN FETCH a.source " + "WHERE a.status = :status " + "AND a.adminNote IS NOT NULL " + "AND a.aiCategory = :category " + // DB에서 카테고리 필터링! "AND (LOWER(a.aiTitle) LIKE LOWER(CONCAT('%', :keyword, '%')) " + " OR LOWER(a.title) LIKE LOWER(CONCAT('%', :keyword, '%')))", countQuery = "...") Page<NewsArticle> searchFeaturedByTitleAndCategory(...);
3. Service 로직 개선
// NewsArticleService.java - 개선된 코드 public Page<NewsArticleResponse> getFeaturedArticles( NewsCategory category, String keyword, NewsSearchType searchType, Pageable pageable) { boolean hasKeyword = keyword != null && !keyword.isBlank(); boolean hasCategory = category != null; // 1. 키워드 + 카테고리: DB에서 직접 필터링 if (hasKeyword && hasCategory) { articles = switch (type) { case TITLE -> newsArticleRepository.searchFeaturedByTitleAndCategory(status, category, keyword, pageable); case CONTENT -> newsArticleRepository.searchFeaturedByContentAndCategory(status, category, keyword, pageable); case ALL -> newsArticleRepository.searchFeaturedAllAndCategory(status, category, keyword, pageable); }; } // 2. 키워드만 else if (hasKeyword) { articles = switch (type) { case TITLE -> newsArticleRepository.searchFeaturedByTitle(status, keyword, pageable); // ... }; } // 3. 카테고리만 else if (hasCategory) { articles = newsArticleRepository.findFeaturedArticlesByCategory(status, category, pageable); } // 4. 필터 없음 else { articles = newsArticleRepository.findFeaturedArticlesWithTagsAndSource(status, pageable); } return articles.map(NewsArticleResponse::from); }
결과
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 쿼리 수 | 21개 (1 + 20) | 1개 |
| 페이징 정확도 | 불정확 | 100% 정확 |
| 카테고리 필터 | 메모리 | DB |
추가 고려사항
ManyToMany 관계의 N+1 해결
tags 필드는 @ManyToMany 관계입니다. 이 경우 JOIN FETCH와 페이징을 함께 사용하면 Cartesian Product 문제로 메모리에서 페이징이 발생합니다.
// NewsArticle.java @ManyToMany @JoinTable(...) @org.hibernate.annotations.BatchSize(size = 100) // 배치 로딩으로 해결 private Set<NewsTag> tags = new HashSet<>();
해결 전략:
source(ManyToOne):JOIN FETCH사용tags(ManyToMany):@BatchSize로 배치 로딩
countQuery 분리의 중요성
// 잘못된 예 - countQuery 없음 @Query("SELECT a FROM NewsArticle a JOIN FETCH a.source WHERE ...") Page<NewsArticle> findArticles(...); // 에러 또는 성능 저하! // 올바른 예 - countQuery 분리 @Query(value = "SELECT a FROM NewsArticle a JOIN FETCH a.source WHERE ...", countQuery = "SELECT COUNT(a) FROM NewsArticle a WHERE ...") // JOIN FETCH 제외 Page<NewsArticle> findArticles(...);
JOIN FETCH가 포함된 쿼리로 COUNT를 실행하면:
- Hibernate가 경고 로그 출력
- 전체 데이터를 메모리에 로드한 후 카운트 (심각한 성능 저하)
추가 개선: 전체 검색 메서드 N+1 해결
대표글 검색뿐만 아니라 일반 검색 메서드에도 동일한 문제가 있었습니다.
개선된 메서드 목록
| 메서드 | 변경 전 | 변경 후 |
|---|---|---|
searchByTitle | N+1 발생 | JOIN FETCH |
searchByContent | N+1 발생 | JOIN FETCH |
searchAll | N+1 발생 | JOIN FETCH |
searchByTitleAndCategory | N+1 발생 | JOIN FETCH |
searchByContentAndCategory | N+1 발생 | JOIN FETCH |
searchAllAndCategory | N+1 발생 | JOIN FETCH |
Native Query의 N+1 문제
태그 검색은 복잡한 GROUP BY HAVING 절이 필요해서 Native Query를 사용합니다. Native Query에서는 JOIN FETCH를 직접 사용할 수 없습니다.
해결 방법: @BatchSize
// NewsArticle.java @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "source_id", nullable = false) @org.hibernate.annotations.BatchSize(size = 50) // 배치 로딩으로 N+1 완화 private NewsSource source;
이 설정으로 Native Query 결과에서 source에 접근할 때:
- 변경 전: 20개 기사 → 20개 source 쿼리 (N+1)
- 변경 후: 20개 기사 → 1개 source 배치 쿼리 (1+1)
테스트 코드 추가
N+1 문제가 해결되었는지 검증하는 테스트를 추가했습니다:
@Test @DisplayName("searchByTitle은 N+1 문제 없이 검색한다") void shouldSearchByTitleWithoutNPlusOne() { // Given: 키워드가 포함된 기사 생성 for (int i = 0; i < 5; i++) { NewsArticle article = createArticle("Spring Boot Tutorial " + i); newsArticleRepository.save(article); } entityManager.flush(); entityManager.clear(); // 1차 캐시 초기화 - N+1 테스트 필수! // When: 키워드 검색 Page<NewsArticle> articles = newsArticleRepository.searchByTitle( ArticleStatus.PUBLISHED, "Spring", PageRequest.of(0, 10) ); // Then: 추가 쿼리 없이 source 접근 가능 assertThat(articles).hasSize(5); articles.forEach(article -> { // LazyInitializationException 없이 접근 가능해야 함 assertThat(article.getSource().getName()).isEqualTo("Test Source"); }); }
핵심 포인트: entityManager.clear()로 1차 캐시를 초기화해야 N+1 문제를 정확히 테스트할 수 있습니다.
최종 결과
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 대표글 검색 | 21개 쿼리 | 1개 쿼리 |
| 일반 검색 | 21개 쿼리 | 1개 쿼리 |
| 태그 검색 (Native) | 21개 쿼리 | 2개 쿼리 (배치) |
| 페이징 정확도 | 불정확 | 100% |
마무리
JPA N+1 문제는 개발 중에는 발견하기 어렵지만, 운영 환경에서 심각한 성능 저하를 일으킵니다. 주요 체크 포인트:
- 연관 엔티티 접근 시 LAZY 로딩 확인: DTO 변환 로직에서 연관 엔티티에 접근하는지 확인
- 메모리 필터링 지양: 가능하면 DB 쿼리로 필터링
- JOIN FETCH 사용 시 countQuery 분리: 페이징 시 필수
- ManyToMany는 @BatchSize 활용: JOIN FETCH 대신 배치 로딩
- ManyToOne도 @BatchSize 고려: Native Query 등 JOIN FETCH 사용 불가 시
- N+1 테스트 작성:
entityManager.clear()후 연관 엔티티 접근 검증
이 글은 실제 프로덕션 환경에서 발생한 성능 문제를 해결한 경험을 바탕으로 작성되었습니다.
댓글
댓글을 작성하려면 이 필요합니다.