📌 문제 상황
오늘 리뷰 시스템을 구현하면서 흥미로운 질문이 생겼다.
// 리뷰 생성/수정/삭제 시 호출되는 메서드
private void updateStoreRating(Long storeId) {
Long count = reviewRepository.countByStoreId(storeId);
Double avgRating = reviewRepository.findAvgRatingByStoreId(storeId);
Store store = storeRepository.findById(storeId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 가게입니다."));
store.updateRating(avgRating, count);
}
이 코드를 보면서 "이거 N+1 문제 아닌가?" 하는 의문이 들었다. COUNT 쿼리와 AVG 쿼리가 따로 나가니까 말이다. 하지만 결론부터 말하자면, 이건 N+1 문제가 아니다!
오늘은 이 착각을 통해 N+1 문제에 대해 제대로 이해할 수 있었다.
🤔 N+1 문제란 무엇인가?
정의
N+1 문제는 1번의 쿼리로 N개의 데이터를 조회한 후, 각 데이터마다 추가로 1번씩 쿼리를 실행하여 총 N+1번의 쿼리가 발생하는 성능 문제를 말한다.
발생 조건
- 반복문 안에서 추가 쿼리가 실행될 때
- 주로 연관관계(OneToMany, ManyToOne 등) 를 LAZY 로딩할 때
- 조회한 엔티티의 개수만큼 쿼리가 추가로 발생
N+1 문제의 전형적인 예시
// ❌ N+1 문제 발생!
List<Store> stores = storeRepository.findAll(); // 1번의 쿼리
for (Store store : stores) {
// 각 Store마다 리뷰를 조회 - N번의 추가 쿼리!
Double avgRating = reviewRepository.findAvgRatingByStoreId(store.getId());
store.updateRating(avgRating);
}
// 총 1 + N번의 쿼리 실행
쿼리 실행 흐름:
-- 1번: Store 전체 조회
SELECT * FROM store;
-- N번: 각 Store의 리뷰 평점 조회
SELECT AVG(rating) FROM review WHERE store_id = 1;
SELECT AVG(rating) FROM review WHERE store_id = 2;
SELECT AVG(rating) FROM review WHERE store_id = 3;
...
✅ 현재 구현 방식
데이터베이스 설계
@Entity
public class Store {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 평점과 리뷰 수를 컬럼으로 저장! 🔑
private Double avgRating;
private Long reviewCount;
public void updateRating(Double avgRating, Long reviewCount) {
this.avgRating = avgRating;
this.reviewCount = reviewCount;
}
}
리뷰 생성 시 로직
@Service
@Transactional
public class ReviewService {
public void createReview(CreateReviewRequest request) {
// 1. 리뷰 생성
Review review = Review.builder()
.storeId(request.getStoreId())
.rating(request.getRating())
.content(request.getContent())
.build();
reviewRepository.save(review);
// 2. 해당 가게의 평점 업데이트
updateStoreRating(request.getStoreId());
}
private void updateStoreRating(Long storeId) {
// 총 3번의 쿼리 실행
Long count = reviewRepository.countByStoreId(storeId); // 쿼리 1
Double avgRating = reviewRepository.findAvgRatingByStoreId(storeId); // 쿼리 2
Store store = storeRepository.findById(storeId) // 쿼리 3
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 가게입니다."));
store.updateRating(avgRating, count);
}
}
Repository 메서드
public interface ReviewRepository extends JpaRepository<Review, Long> {
@Query("SELECT COUNT(r) FROM Review r WHERE r.storeId = :storeId")
Long countByStoreId(@Param("storeId") Long storeId);
@Query("SELECT AVG(r.rating) FROM Review r WHERE r.storeId = :storeId")
Double findAvgRatingByStoreId(@Param("storeId") Long storeId);
}
🎯 왜 N+1이 아닌가?
비교 분석표
구분 N+1 문제 현재 구현 방식
| 쿼리 횟수 | 1 + N번 (N은 데이터 개수) | 고정 3번 |
| 반복 여부 | ⭕ 반복문 안에서 실행 | ❌ 단일 실행 |
| 성능 영향 | 데이터가 많을수록 기하급수적 증가 | 항상 동일 |
| 처리 대상 | 여러 개의 엔티티 | 하나의 특정 엔티티 |
핵심 차이점
// ❌ N+1 문제: 100개 Store → 300번 쿼리
List<Store> stores = storeRepository.findAll(); // 1번
for (Store store : stores) { // 100번 반복
Long count = reviewRepository.countByStoreId(store.getId()); // 100번
Double avg = reviewRepository.findAvgRatingByStoreId(store.getId()); // 100번
}
// 총: 1 + 100 + 100 = 201번
// ✅ 현재 방식: 항상 3번 쿼리
createReview(request); // 단일 리뷰 생성
updateStoreRating(storeId); // 해당 가게만 업데이트
// COUNT: 1번, AVG: 1번, Store 조회: 1번 = 총 3번
시각적 비교
N+1 문제 발생 시:
Store 100개 조회 → 각각 COUNT 쿼리 → 각각 AVG 쿼리
↓ ↓ ↓
1개 100개 100개
총 201개의 쿼리! 😱
현재 구현:
리뷰 1개 생성 → 해당 Store의 COUNT → 해당 Store의 AVG → Store 업데이트
↓ ↓ ↓ ↓
1개 1개 1개 1개
총 3개의 쿼리! ✨
💡 배운 점 (Key Takeaways)
1. N+1의 본질은 "반복"이다
N+1 문제는 반복문 내에서 데이터 개수만큼 쿼리가 증가하는 것이 핵심이다. 단순히 쿼리가 여러 번 나간다고 N+1이 아니다!
2. 쿼리 개수보다 "패턴"이 중요하다
- 고정된 쿼리 횟수: 데이터가 늘어나도 쿼리 수 동일 → ✅ 문제 없음
- 데이터에 비례하는 쿼리: 데이터가 늘면 쿼리도 증가 → ❌ N+1 문제
3. COUNT와 AVG를 분리한 이유
Long count = reviewRepository.countByStoreId(storeId);
Double avgRating = reviewRepository.findAvgRatingByStoreId(storeId);
- COUNT: 리뷰 개수를 표시하기 위해 필요
- AVG: 평균 평점을 계산하기 위해 필요
- 두 값은 서로 다른 목적이므로 분리하는 것이 자연스럽다
- 하나의 쿼리로 합칠 수도 있지만, 가독성과 유지보수를 위해 분리
4. N+1 문제 해결 방법
실제로 N+1 문제가 발생한다면 이렇게 해결할 수 있다:
// 1. Fetch Join 사용
@Query("SELECT s FROM Store s JOIN FETCH s.reviews")
List<Store> findAllWithReviews();
// 2. @EntityGraph 사용
@EntityGraph(attributePaths = {"reviews"})
List<Store> findAll();
// 3. Batch Size 설정
@BatchSize(size = 100)
@OneToMany(mappedBy = "store")
private List<Review> reviews;
// 4. 컬럼으로 저장
private Double avgRating;
private Long reviewCount;
🎓 결론
오늘 N+1 문제에 대한 오해를 풀면서 중요한 것을 깨달았다:
- 쿼리 개수가 많다 ≠ N+1 문제
- 반복문 안에서 데이터 개수만큼 증가하는 쿼리 = N+1 문제
- 비즈니스 로직에 맞는 최적화 방법 선택이 중요
'👍코드 회고' 카테고리의 다른 글
| Saga 패턴2 (0) | 2025.11.27 |
|---|---|
| Saga 패턴 (0) | 2025.11.25 |
| 리팩토링 vs 오버 엔지니어링 (10.16) (0) | 2025.10.16 |
| 장바구니 코드 속 store_id는 어디에 둘까? (10.15) (0) | 2025.10.15 |
| 서비스단 깔끔하게 하기 (10.02) (0) | 2025.10.03 |