N+1 (10.17)
2025. 10. 17. 23:47

📌 문제 상황

오늘 리뷰 시스템을 구현하면서 흥미로운 질문이 생겼다.

// 리뷰 생성/수정/삭제 시 호출되는 메서드
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번의 쿼리가 발생하는 성능 문제를 말한다.

발생 조건

  1. 반복문 안에서 추가 쿼리가 실행될 때
  2. 주로 연관관계(OneToMany, ManyToOne 등) 를 LAZY 로딩할 때
  3. 조회한 엔티티의 개수만큼 쿼리가 추가로 발생

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 문제에 대한 오해를 풀면서 중요한 것을 깨달았다:

  1. 쿼리 개수가 많다 ≠ N+1 문제
  2. 반복문 안에서 데이터 개수만큼 증가하는 쿼리 = N+1 문제
  3. 비즈니스 로직에 맞는 최적화 방법 선택이 중요

'👍코드 회고' 카테고리의 다른 글

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