티스토리 뷰
✏️ 들어가며
최근 프로젝트에서 데이터를 조회하던 중, 팀원으로부터 “이거 N+1 문제가 발생할 수 있어요”라는 얘기를 들었습니다.
사실 N+1 문제는 이전부터 익숙한 주제였고, 면접 준비할 때도 공부했던 부분이지만… 막상 실제 상황에서는 바로 캐치하지 못했던 것이죠. 😅
이번 기회에 다시 한 번 개념을 정리하고, 실제 코드에서 어떤 문제가 생기는지, 그리고 어떻게 해결할 수 있는지 기록해두려 합니다. 이 글은 미래의 저를 위한 기억 보조장치이자, 같은 고민을 하는 분들께도 도움이 되었으면 좋겠습니다.
✏️ N+1 문제란?
JPA에서 특정 엔티티를 조회한 뒤, 그 연관된 엔티티들을 지연 로딩(LAZY)할 때 발생하는 문제입니다.
예를 들어, 회원(Member) 엔티티가 팀(Team)과 연관되어 있고 LAZY로딩으로 설정되어 있다면 아래 코드처럼 될 수 있습니다.
아래의 경우
- 첫 번째 쿼리로 회원 전체를 한 번에 조회하고(1),
- 각 회원마다 팀 정보를 가져오기 위해 추가 쿼리 N개가 실행됩니다.
총 N+1개의 쿼리가 발생하게 되며, 이를 N+1문제라고 부릅니다.
List<Member> members = memberRepository.findAll(); // 1번 쿼리
for (Member member : members) {
System.out.println(member.getTeam().getName()); // N번 쿼리
}
✏️ N+1 문제가 발생하면 어떤 문제가 발생하는가?
1. 쿼리 수 증가 -> 성능 저하
- 회원이 100명이라면 팀 조회 쿼리가 100번 추가로 실행됨
- 쿼리 수가 많아질수록 DB 부하가 급격히 증가함
2. 페이지네이션과 충돌
- Fetch Join으로 해결하려고 할 경우, @OneToMany 관계에서는 페이징이 제대로 안 됨
3. 디버깅 어려움
- 처음엔 잘 동작하는 것처럼 보여도, 데이터량이 많아지면 갑자기 느려지기 시작
- 쿼리가 자동으로 발생하므로 로그를 꼼꼼히 보지 않으면 놓치기 쉬움
✏️ 문제 상황
val reviewList = reviewRepository.findAllByUserId(user.userId)
val categoryCounts = reviewList
.flatMap { review -> reviewCategoryRepository.findAllByReview(review) }
.groupingBy { it.category }
.eachCount()
- 1차 쿼리: findAllByUserId() → 유저의 모든 리뷰를 가져옴 (reviewList)
- N차 쿼리: 각 Review 마다 findAllByReview() → 리뷰에 속한 카테고리를 매번 DB에서 조회
즉,
리뷰가 10개라면 → 1 + 10 = 11개의 쿼리 발생
리뷰가 100개라면 → 1 + 100 = 101개의 쿼리 발생
이게 바로 전형적인 N+1 문제입니다.
✏️ 문제 발생 원인
@OneToMany(mappedBy = "review", fetch = FetchType.LAZY)
var categories: MutableList<ReviewCategory> = mutableListOf()
- Review → ReviewCategory는 지연 로딩(LAZY) 설정
- 그래서 review.categories를 조회할 때마다 별도 쿼리가 나감
그리고 현재 로직에서는 review.categories를 사용하지 않고
reviewCategoryRepository.findAllByReview(review)를 호출하고 있으므로,
명시적으로 N개의 select 쿼리를 반복 호출하고 있는 상황입니다.
✏️ 개선 방향 요약
해결 방안 1: Fetch Join(저같은 경우는 이 방식을 사용하였습니다)
@Query("SELECT r FROM Review r JOIN FETCH r.categories WHERE r.userId = :userId")
fun findAllWithCategoriesByUserId(@Param("userId") userId: Long): List<Review>
해결 방안 2: DTO 쿼리
@Query("""
SELECT new com.example.UserReviewCategoryDto(rc.category, COUNT(rc))
FROM Review r
JOIN ReviewCategory rc ON rc.review = r
WHERE r.userId = :userId
GROUP BY rc.category
""")
fun getUserReviewCategorySummary(@Param("userId") userId: Long): List<UserReviewCategoryDto>
✏️ 마치며
JPA를 쓰다 보면 "내가 쓴 코드에서 쿼리가 이렇게 많이 나가고 있었어?" 하는 순간이 종종 찾아옵니다.
이번에도 팀원의 피드백 덕분에 N+1 문제를 인지하고 다시 점검할 수 있었고,
그 과정을 정리하면서 JPA에서 연관 관계를 다룰 땐 쿼리 수까지 항상 같이 생각해야 한다는 점을 다시 한번 느꼈습니다.
앞으로는 연관된 데이터를 조회할 때
단순히 코드만 보지 않고, 쿼리 로그를 함께 보면서
내가 지금 어떤 방식으로 데이터를 끌고 오고 있는지 꼼꼼히 체크해보려고 합니다.
이 글이 과거의 저처럼 JPA 사용 중 갑자기 느려진 쿼리 앞에서 당황하고 있는 분들께
조금이나마 도움이 되었으면 좋겠습니다. 😊
'Back-End' 카테고리의 다른 글
| [Spring/스프링] - Spring Boot에서 Facade 패턴 도입기 (8) | 2025.08.22 |
|---|---|
| [Spring/스프링] - Spring Boot에서의 MVC 흐름 (4) | 2025.08.21 |
| [Spring/스프링] - Spring Boot에서 Entity에 Setter를 사용하면 안 되는 이유 (2) | 2025.08.19 |
| [Spring/스프링] - Spring Boot + JWT 로그인 구현하기 (7) | 2025.08.18 |
| [Spring/스프링] - 스프링 부트에서 계층관점으로 바라본 아키텍처 설계: DDD vs 레이어드 아키텍처 (6) | 2025.08.17 |
- Total
- Today
- Yesterday
- C++
- 자바스크립트
- 스택
- 카운팅 정렬
- DFS
- 반복문
- Do it!
- 자바
- 이분 매칭
- 알고리즘 공부
- 유클리드 호제법
- CSS
- 백준 풀이
- 자료구조
- 알고리즘
- js
- 투 포인터
- HTML5
- 백준
- 세그먼트 트리
- DP
- c++ string
- 유니온 파인드
- 에라토스테네스의 체
- java
- html
- BFS
- 스프링 부트 crud 게시판 구현
- 우선순위 큐
- C++ Stack
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
