필자는 현재 스마트폰 역경매 플랫폼 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 이슈 없이 잘 최적화돼 있어도, 반환하는 데이터 자체가 불필요하게 크면 그걸 최적화라고 말할 수 있을까? 라는 생각이 든다. '뭘 가져올 것인가'가 '어떻게 가져올 것인가'만큼 중요한 것 같다.
'Project > phonebid' 카테고리의 다른 글
| 계약하기 버튼, 두 번 누르면 어떻게 될까 ㅡ 비관적 락 적용기 (1) | 2026.04.29 |
|---|---|
| SSE 기반 실시간 알림 구현기 : 기술 선택부터 프로덕션 안정화까지 (2) | 2026.04.26 |
| [리팩토링] 알림 발송 로직을 동기에서 이벤트 기반 비동기로 개선하기 (feat. @TransactionalEventListener) (2) | 2026.04.23 |
| [Spring/JPA] refresh token 통합 테스트 중 데이터 불일치 문제 삽질기 (@Modifying의 flushAutomatically 옵션) (2) | 2026.01.27 |
| [Spring 트래픽 제한] 인증 없는 API에 안전장치 달기: Bucket4j로 Rate Limiting 구현 (3) | 2026.01.15 |