N+1 해결법으로 fetch join, @BatchSize, 캐시를 떠올리기 쉽다. 하지만 이것들이 통하지 않는 상황이 있다. "피드 목록에서 피드마다 좋아요 수, 댓글 수, 옷 스냅샷을 따로 가져오는" 같은 패턴이다. 이 문서에서는 그 상황을 정확히 진단하고, 배치 로딩이라는 해법의 직관을 잡는다.
한 명씩 주문을 받는 카페
카페에 20명이 단체로 들어왔다. 점원이 첫 번째 손님에게 주문을 받고, 주방에 전달하고, 돌아와서 두 번째 손님에게 주문을 받고, 또 주방에 전달한다. 20명이 끝날 때까지 점원은 카운터와 주방을 20번 왕복한다.
주방은 한 번에 여러 잔을 만들 수 있는데, 점원이 한 건씩 전달하기 때문에 비효율이 생긴다.
이 점원이 JPA Repository 메서드이고, 주방이 데이터베이스다. 손님 한 명의 주문을 전달하는 행위가 "쿼리 1회 실행"에 해당한다. 손님이 20명이면 쿼리도 20번이다.
코드로 보는 단건 반복
카페 비유를 실제 코드로 옮겨보자. 피드 목록 API에서 toFeedDto()가 이 점원 역할을 한다.
private FeedDto toFeedDto(Feed feed, User loginUser) {
return FeedDto.from(
feed,
feedClothesRepository.findAllSnapshotByFeed(feed), // 주문 1
feedLikeRepository.countByFeed(feed), // 주문 2
commentRepository.countByFeed(feed), // 주문 3
feedLikeRepository.existsByFeedAndLikedUser(feed, loginUser) // 주문 4
);
}
이 메서드 하나가 DB를 4번 찌른다. 그리고 이걸 피드 목록에서 이렇게 호출한다.
feeds.stream()
.map(f -> toFeedDto(f, loginUser)) // 피드마다 4번 × 20건 = 80번
.toList()
피드 20건이면 toFeedDto()가 20번 실행되고, 안에서 4개 쿼리가 각각 나가니 80쿼리. 메인 쿼리까지 합치면 81쿼리다.
각 쿼리가 하는 일
findAllSnapshotByFeed
피드 하나에 달린 옷 스냅샷 목록을 가져온다. JSONB 컬럼에 저장된 ClothesSnapshot을 SELECT하는 쿼리다.
왜 피드마다 별도 쿼리인가. FeedClothes는 Feed와 별도 테이블이기 때문에, Feed를 조회한다고 자동으로 따라오지 않는다. @ManyToOne(fetch = LAZY)로 연결되어 있어서 명시적으로 조회해야 한다.
countByFeed — 좋아요 수
해당 피드의 좋아요 총 개수를 COUNT(*)로 세는 쿼리다.
@Cacheable이 붙어 있어서 Redis에 캐시된다. 하지만 캐시가 만료되었거나 처음 조회하는 피드라면 DB를 찌른다. 20건 중 캐시 미스가 15건이면 15쿼리가 나간다.
countByFeed — 댓글 수
좋아요 수와 동일한 구조다. 역시 @Cacheable로 캐시되지만, 캐시 미스 시 DB 쿼리가 발생한다.
existsByFeedAndLikedUser
현재 로그인한 유저가 이 피드에 좋아요를 눌렀는지 확인하는 EXISTS 쿼리다.
이 쿼리는 캐시가 없다. 캐시 키에 피드 ID와 유저 ID가 모두 들어가야 해서 캐시 설계가 까다롭기 때문이다. 매 요청마다 피드 수만큼 쿼리가 나간다.
전체 쿼리 흐름
분홍 영역이 핵심이다. 이 영역이 피드 건수만큼 반복된다. 피드가 50건이면 201쿼리, 100건이면 401쿼리다.
주문서를 한 장으로 모으면
카페로 돌아가자. 점원이 방식을 바꿨다. 20명에게 먼저 주문을 전부 받아서 종이 한 장에 정리한다. 그 종이를 주방에 한 번만 전달한다. 주방은 한 번에 20잔을 만들어서 내보낸다.
점원이 주방을 1번만 왕복한다. 20번 왕복이 1번으로 줄었다.
이 "종이 한 장에 모아서 전달"이 SQL의 WHERE feed_id IN (?, ?, ?, ...)이다. 피드 ID 20개를 한 쿼리에 넘기면 DB는 한 번의 실행으로 20건의 결과를 돌려준다. 그리고 "주문서 보고 각 손님에게 배분"이 Java의 Map으로 결과를 분류하는 단계다.
배치 로딩의 세 단계
카페 비유에서 세 가지 동작이 나왔다. 이것이 배치 로딩의 구조 전부다.
주문 수집 — 피드 ID 리스트 추출
List<UUID> feedIds = feeds.stream().map(Feed::getId).toList();
20건의 피드에서 ID만 뽑아서 리스트로 만든다. 이게 "종이 한 장"이다.
왜 ID만 뽑는가. IN절 쿼리에 엔티티 전체를 넘길 필요가 없다. DB가 필요한 것은 식별자뿐이다.
일괄 전달 — IN절 쿼리 실행
SELECT feed_id, COUNT(*) FROM feed_likes
WHERE feed_id IN (?, ?, ?, ..., ?)
GROUP BY feed_id
DB에게 "이 20개 피드의 좋아요 수를 한 번에 세 달라"고 요청한다. 쿼리 1개로 20건의 결과가 돌아온다.
왜 GROUP BY가 필요한가. IN절로 20개 피드의 좋아요를 한 번에 가져오면, 피드별로 몇 개인지 구분해야 한다. GROUP BY feed_id가 그 역할을 한다.
배분 — Map으로 분류
Map<UUID, Long> likeCountMap = results.stream()
.collect(Collectors.toMap(
row -> (UUID) row[0], // feedId
row -> (Long) row[1] // count
));
쿼리 결과를 feedId → 좋아요 수 Map으로 변환한다. 이후 피드 리스트를 순회하면서 map.get(feedId)로 바로 꺼낸다.
왜 Map인가. 리스트를 순회하면서 "이 피드의 좋아요 수"를 찾으려면 매번 리스트를 뒤져야 한다. Map은 키로 O(1)에 접근할 수 있어서, 조합 단계에서 추가 비용이 없다.
쿼리 수 비교
같은 20건 피드 조회에서 단건 반복과 배치 로딩의 차이를 정리한다.
| 단건 반복 | 배치 로딩 | |
|---|---|---|
| 메인 쿼리 | 1 | 1 |
| 스냅샷 조회 | 20 | 1 |
| 좋아요 수 | 0~20 | 1 |
| 댓글 수 | 0~20 | 1 |
| 좋아요 여부 | 20 | 1 |
| 합계 | 41~81 | 5 |
배치 로딩은 피드가 20건이든 100건이든 항상 5쿼리다. 건수에 비례해서 쿼리가 늘어나지 않는다.
왜 캐시가 아니라 배치인가
"쿼리가 많으면 Redis에 캐시하면 되지 않나?" 좋은 질문이다. 실제로 좋아요 수와 댓글 수에는 이미 @Cacheable이 붙어 있다. 하지만 캐시는 이 문제의 근본 해법이 아니다.
캐시의 한계
캐시가 해결하는 것은 "같은 데이터를 반복 조회"하는 상황이다. 한번 조회한 결과를 저장해두고, 다음에 같은 요청이 오면 DB 대신 캐시에서 꺼낸다.
하지만 배치 로딩이 해결하는 문제는 다르다. "서로 다른 20개 데이터를 한 건씩 조회"하는 패턴이다. 캐시가 전부 적중해도 Redis를 20번 찌르는 것은 동일하다. 네트워크 왕복이 20번 발생한다.
비교
| 캐시 | 배치 로딩 | |
|---|---|---|
| 해결하는 문제 | 동일 데이터 반복 조회 | 다수 데이터 개별 조회 |
| 네트워크 호출 | N번 (캐시 서버로) | 1번 (DB로) |
| 캐시 미스 시 | DB N번 | 해당 없음 |
| 첫 요청 | 느림 (전부 미스) | 빠름 (항상 1쿼리) |
배치 로딩을 먼저 적용하고, 그 위에 캐시를 얹는 것이 올바른 순서다. 배치 쿼리의 결과를 통째로 캐시하면 두 전략의 장점을 모두 가져올 수 있다.
캐시는 "지난번 주문이랑 같으면 안 만들고 바로 줌"이다. 배치 로딩은 "주문서를 한 장으로 모아서 전달"이다. 둘은 해결하는 문제가 다르다. 주문서를 모아서 전달하되, 지난번이랑 같은 주문은 만들지 않으면 최적이다.
다음 편에서는 IN절 배치 쿼리를 Repository에 어떻게 작성하는지 다룬다.