Project/phonebid

[N+1 삽질] 무조건 fetch join, @BatchSize가 정답은 아니다 (feat. 집계 쿼리 최적화)

쉬지마 이굥진 2026. 5. 3. 23:56

필자는 현재 스마트폰 역경매 플랫폼 bidr(비더)를 개발 중이다. (5월 런칭 목표)

 

bidr의 견적 목록 화면에는 각 견적마다 '입찰 5개·최저 8만원' 같은 정보가 표시된다. 구매자 입장에서 내 견적에 입찰이 얼마나 몰렸는지, 가장 저렴한 입찰가가 얼마인지 한눈에 볼 수 있어야 하기 때문이다.

견적 목록 화면

 

그런데 이걸 구현하다가 전형적인 함정(?)을 밟았다.

정색하실게요

 

문제 발견 — 견적 20개를 조회했는데 쿼리가 41번?

처음 구현은, 견적 목록을 불러온 뒤 각 견적마다 입찰 수량 최저가를 개별로 조회하면 된다고 생각해서 아주 단순하게  구현했다. 그런데 기능 구현을 마치고 로컬에서 테스트하다가 콘솔을 무심코 봤는데 뭔가 이상했다. 견적 목록 조회 한 번에 로그가 너무 많이 찍히는 거였다..!

Hibernate: select ... from quotes where status='OPEN' ...
Hibernate: select count(b) from bids where quote_id=?
Hibernate: select min(b.installment_principal) from bids where quote_id=?
Hibernate: select count(b) from bids where quote_id=?
Hibernate: select min(b.installment_principal) from bids where quote_id=?
Hibernate: select count(b) from bids where quote_id=?
Hibernate: select min(b.installment_principal) from bids where quote_id=?
...

 

견적이 20개면 저 패턴이 20번 반복됐다. 세어보니 총 41번이었다. <뭐야이게

 

코드를 다시 한 번 찬찬히 뜯어봤다.

// 개선 전 — N+1 발생 구조
List<Quote> quotes = quoteRepository.findLatestQuotesByStatus(QuoteStatus.OPEN);

quotes.stream().map(quote -> {
    long bidCount = bidRepository.countByQuoteId(quote.getId());               // 쿼리 1번
    Integer lowestPrice = bidRepository.findMinInstallmentPrincipalByQuoteId(  // 쿼리 2번
        quote.getId(), BidStatus.ACTIVE
    );
    return QuoteResponseDto.from(quote, bidCount, lowestPrice);
});

// 견적 N개 → 총 2N+1개 쿼리 실행

 

견적 목록을 불러온 뒤 각 견적마다 쿼리를 2번씩 날리는 구조였다. 견적이 20개면 41번, 100개면 201번. 데이터가 늘어날수록 쿼리가 선형으로 증가하는 구조였다. (= N+1 문제)

쿼리 수 증가 시나리오
──────────────────────────────────────────────────────
견적  20개 → 쿼리  41번
견적  50개 → 쿼리 101번
견적 100개 → 쿼리 201번
데이터가 늘어날수록 쿼리 횟수가 선형으로 증가
──────────────────────────────────────────────────────

 

bidr 초기엔 데이터가 적으니 당장 문제가 터지진 않겠지만 서비스가 커질수록 이 구조는 DB 부하를 선형으로 키운다. 넘어가기 불편했다.

 

해결 선택지 비교 ㅡ 익숙한 방법부터

JPA를 쓰면서 N+1 문제를 만나면 보통 제일 먼저 떠오르는 게 fetch join이나 @BatchSize 다. 하지만 결론부터 말하면 이번엔 둘 다 맞지 않았다.

1. @EntityGraph / fetch join (❌)

그 유명한 fetch join은 연관 엔티티를 통째로 메모리에 올리는 방식이다.

// fetch join으로 Bid를 함께 로딩
List<Quote> quotes = quoteRepository.findAllWithBids();

quotes.stream().map(quote -> {
    long count = quote.getBids().size();              // 애플리케이션에서 직접 count
    int minPrice = quote.getBids().stream()
            .mapToInt(Bid::getInstallmentPrincipal)
            .min().orElse(0);                         // 애플리케이션에서 직접 min
});

쿼리 횟수는 줄어든다. 하지만 문제가 있다.

 

필요한 건 '입찰 수'와 '최저 할부원금', 딱 숫자 2개인데, 그걸 구하기 위해 Bid 엔티티의 모든 필드(가격, 배송일, 요금제, 통신사, 개통방법, 부가서비스...)를 전부 메모리에 올려야 한다.

 

입찰이 많은 견적일수록 메모리에 올라오는 데이터가 폭발적으로 늘어난다(= 즉 메모리 낭비가 심해짐). 집계 연산도 DB가 아니라 애플리케이션이 떠안게 된다. 쿼리 횟수가 많은 문제는 해결했지만 더 큰 문제를 새로 만드는 셈이다.

 

2. @BatchSize (❌)

@BatchSize는 Hibernate가 지연 로딩 시 WHERE id = ?를 개별로 날리는 대신 WHERE id IN (?, ?, ...)으로 묶어주는 최적화다. 쿼리 횟수는 확실히 줄어듦을 보장한다.

-- @BatchSize 없을 때
SELECT * FROM bids WHERE quote_id = '1'
SELECT * FROM bids WHERE quote_id = '2'
SELECT * FROM bids WHERE quote_id = '3'

-- @BatchSize(size=100) 있을 때
SELECT * FROM bids WHERE quote_id IN ('1', '2', '3') -- 쿼리 1번으로 감소

 

전에 N+1 문제를 만났을 때는 @BatchSize를 써서 성능을 엄청 개선시켰던 경험이 있어 이번에도 @BatchSize를 쓸까 했는데, 하지만 이번 문제같은 경우는 궤가 다르다. 위의 예시 SQL을 보면 SELECT * 로 반환하는 건 여전히 bid 엔티티 객체 전체임을 알 수 있다.

 

@BatchSize는 엔티티를 N+1 문제 없이 효율적으로 로딩하는 도구다. 하지만 필요한 게 엔티티가 아닌 집계값인 상황에서는, 쿼리를 IN 절로 묶어줘도 불필요한 데이터를 메모리에 올리는 문제는 여전히 존재한다. fetch join과 마찬가지로 이 상황을 본질적으로 해결하는 방법은 아니라는 생각을 했다.

 

💥즉, fetch join / @BatchSize의 공통 한계

  • 실제로 필요한 것: 입찰 수(숫자 1개), 최저가(숫자 1개)
  • 실제로 올라오는 것: Bid 엔티티 전체 (수십 개의 필드)
  • 두 방법 모두 엔티티 로딩을 전제로 한 최적화 → 이 상황에서의 본질적인 문제는 '엔티티 로딩 자체가 불필요하다'는 것

그래서 두 방법 모두 현재 상황에 맞는 도구가 아니라고 느꼈다.

 

3. IN절 배치 쿼리 + GROUP BY 집계 (⭕)

JPA N+1 문제는 무조건 fetch join이나 batchsize를 써서 해결한다는 발상(?)을 바꿔보자. 어차피 필요한 건 숫자 2개 뿐이니, DB에서 집계까지 끝내고 숫자만 반환하면 되지 않을까?

 

ID 목록을 한 번에 넘기고, GROUP BY로 집계해서 '필요한 데이터'만 효율적으로 돌려받아보자.

  fetch join / @BatchSize IN절 배치 쿼리 + group by
반환 데이터 Bid 엔티티 전체 집계값(숫자)만
집계 주체 애플리케이션 DB
메모리 사용 높음 (Bid 필드 전부) 낮음 (숫자만)
쿼리 횟수 줄어들지만 가변 항상 고정
적합한 상황 엔티티 자체가 필요할 때 집계값만 필요할 때

 


구현 ㅡ 쿼리 2개 + Map 매핑

1. Repository ㅡ 배치 집계 쿼리 작성

// 입찰 수 배치 조회
@Query("SELECT b.quote.id as quoteId, COUNT(b) as bidCount " +
       "FROM Bid b " +
       "WHERE b.quote.id IN :quoteIds " +
       "AND (b.isDelete = false OR b.isDelete IS NULL) " +
       "GROUP BY b.quote.id")
List<BidCountDto> countByQuoteIds(@Param("quoteIds") List<UUID> quoteIds);

// 최저 할부원금 배치 조회
@Query("SELECT b.quote.id as quoteId, MIN(b.installmentPrincipal) as minPrice " +
       "FROM Bid b " +
       "WHERE b.quote.id IN :quoteIds " +
       "AND b.status = :status " +
       "AND (b.isDelete = false OR b.isDelete IS NULL) " +
       "GROUP BY b.quote.id")
List<BidMinPriceDto> findMinInstallmentPrincipalByQuoteIds(
        @Param("quoteIds") List<UUID> quoteIds,
        @Param("status") BidStatus status);

 

실행되는 SQL은 아래와 같다.

SELECT b.quote_id AS quoteId, COUNT(b.id) AS bidCount
FROM bids b
WHERE b.quote_id IN ('1번-uuid', '2번-uuid', '3번-uuid', ...)
AND (b.is_delete = false OR b.is_delete IS NULL)
GROUP BY b.quote_id

 

즉 위와 같은 구조는 견적이 100개면 IN 절 안의 값만 100개로 늘어날 뿐, 쿼리 수는 그대로 1번이다.

 

 

2. Service ㅡ Map으로 변환 후 애플리케이션 레이어 매핑

쿼리 결과를 List로 받은 다음, 바로 Map으로 변환하는 게 핵심이다.

private List<QuoteResponseDto> convertToListDto(List<Quote> quotes) {
    List<UUID> quoteIds = quotes.stream()
            .map(Quote::getId)
            .collect(Collectors.toList());

    // 입찰 수: Map<견적ID, 입찰수>
    Map<UUID, Long> bidCountMap = bidRepository.countByQuoteIds(quoteIds).stream()
            .collect(Collectors.toMap(
                    BidRepository.BidCountDto::getQuoteId,
        			BidRepository.BidCountDto::getBidCount
            ));
    // { '1번-uuid' → 5, '2번-uuid' → 3, '3번-uuid' → 8 }

    // 최저가: Map<견적ID, 최저가>
    Map<UUID, Integer> lowestPriceMap = bidRepository
            .findMinInstallmentPrincipalByQuoteIds(quoteIds, BidStatus.ACTIVE)
            .stream()
            .collect(Collectors.toMap(
                    BidRepository.BidMinPriceDto::getQuoteId,
                    BidRepository.BidMinPriceDto::getMinPrice
            ));
    // { '1번-uuid' → 350000, '2번-uuid' → 280000 }

    return quotes.stream()
            .map(quote -> QuoteResponseDto.from(
                    quote,
                    bidCountMap.getOrDefault(quote.getId(), 0L),
                    lowestPriceMap.get(quote.getId())
            ))
            .collect(Collectors.toList());
}

 

💡getOrDefault(quote.getId(), 0L)의 의미
입찰이 하나도 없는 견적은 GROUP BY 결과에 포함되지 않는다. Map에서 해당 키를 찾지 못하면 기본값 0을 반환하도록 처리한 것이다. 최저가도 마찬가지로 입찰 없는 견적은 null 을 반환한다.

 

Q. 여기서 굳이 List를 Map으로 변환해서 쓰는 이유가 뭘까?

리스트를 그대로 쓰면 특정 견적의 값을 찾기 위해 매번 리스트를 순회해야 한다.

// Map 없이 리스트를 그대로 사용하면
for (Quote quote : quotes) {
    long bidCount = bidCountList.stream()
            .filter(dto -> dto.getQuoteId().equals(quote.getId()))
            .findFirst()
            .map(BidCountDto::getBidCount)
            .orElse(0L);
    // 견적 N개 × 리스트 순회 → O(N²)💥
}

 

그래서 복잡도가 O(N^2)가 되는데, quoteId를 key로 하는 Map으로 변환해서 쓰면 getOrDefault() 한 번으로 O(1) 에 꺼낼 수 있다. Map 생성과 견적 목록 순회가 각각 O(N), 각 순회에서의 Map 조회가 O(1)이니 전체 복잡도는 O(N)이 된다. List 순회 방식의 O(N^2)과 비교하면 데이터가 클수록 차이가 커진다.

💡이 방식의 한 가지 주의할 점
IN 절에 들어가는 ID 수가 매우 많아지면 DB에 따라 쿼리 길이 제한에 걸릴 수 있다. 이 경우 ID 목록을 일정 크기로 나눠 배치 처리하는 방식으로 대응할 수 있다. 현재 bidr의 페이지당 조회 건수 기준에서는 해당 없지만, 스케일업 시 챙겨야 할 포인트다.
💡쿼리 갯수를 더 줄일 수 있다면?
현재는 입찰 수 조회와 최저가 조회를 별도 쿼리로 나눠 총 3번이다. 역할 분리와 가독성을 위한 선택이었는데, 성능을 더 끌어올려야 하는 시점이 오면 두 집계를 하나의 쿼리로 합쳐 2번으로 줄이는 것도 가능하다.

결과

위 방법대로 개선 후, 콘솔에는 견적이 몇 개든 항상 쿼리가 3개로 고정된다.

-- 1번: 견적 목록 조회
SELECT ... FROM quotes WHERE status = 'OPEN' ORDER BY created_at DESC

-- 2번: 입찰 수 배치 조회
SELECT b.quote_id, COUNT(b.id) FROM bids b
WHERE b.quote_id IN ('uuid1', 'uuid2', ...) GROUP BY b.quote_id

-- 3번: 최저가 배치 조회
SELECT b.quote_id, MIN(b.installment_principal) FROM bids b
WHERE b.quote_id IN ('uuid1', 'uuid2', ...) GROUP BY b.quote_id

 

현재 구현된 페이지당 20건 기준으로 생각해보면,

  • 개선 전: 2N + 1 = 41번
  • 개선 후: 3번 (고정)
  • 감소율: 92.7%

로 정리할 수 있다. 견적이 20개든 200개든 쿼리는 항상 3번으로 고정되니 데이터가 늘어나도 DB 부하가 늘어나지 않는 것이다.

- N+1 구조 (개선 전) fetch join / @BatchSize IN절 + GROUP BY (개선 후)
쿼리 횟수 2N + 1 (가변) 1 ~ 소수 (가변) 3 (고정)
DB 부하 견적 수에 비례 낮음 견적 수 무관
집계 주체 DB (N번 반복) 애플리케이션 DB (1번으로 통합)
메모리 사용 Bid 엔티티 전체 Bid 엔티티 전체 숫자값만 변환

 

👥👥사용자 입장에서는?

쿼리 횟수가 줄었다는 건 일단 기술적인 개선점이다. 그럼 사용자 입장에서는 어떤 점이 달라졌을까?

 

(당연히) 견적 목록 화면이 더 빠르게 뜬다. bidr(비더)는 유저 본인이 올려둔 견적을 수시로 들여다보는 서비스다. 새 입찰이 들어올 때 마다 목록을 확인하는 패턴이 잦을 텐데, 그 조회 하나하나가 가벼워진 셈이다.

 

비즈니스 관점에서도 의의가 있다. 서비스 초기에는 견적 수 자체가 적어서 큰 차이가 없어 보일 것이다. 하지만 견적 데이터가 쌓일 수록 개선 전 구조는 응답이 느려지고, 최악의 경우 속도가 느려 답답했던 사용자가 목록 화면에서 이탈하는 원인이 된다. 이번 개선으로 데이터가 아무리 쌓여도 목록 조회 성능이 일정하게 유지되는 구조가 되었다.

 


마무리

N+1 문제라고 하면 fetch join이나 @BatchSize 부터 떠올리게 되는데, 이번 케이스는 그게 맞는 도구가 아니었다. 두 방법 모두 전제가 '엔티티 로딩'이었는데, 필요한 건 엔티티가 아니라 숫자 2개였으니까. 필요한 게 '엔티티'냐 '집계값'이냐에 따라 접근이 달라지고 결과는 천지차이로 바뀔 수 있다는 걸 이번 경험을 통해 배웠다.

 

쿼리가 아무리 N+1 이슈 없이 잘 최적화돼 있어도, 반환하는 데이터 자체가 불필요하게 크면 그걸 최적화라고 말할 수 있을까? 라는 생각이 든다. '뭘 가져올 것인가'가 '어떻게 가져올 것인가'만큼 중요한 것 같다.