N+1 쿼리와 캐시 미스 — 커뮤니티 모듈 성능 개선기
이런 증상을 겪고 계신가요?
커뮤니티 모듈의 API 응답 시간이 점점 느려지고 있었습니다. 로그를 열어보니 원인은 명확했습니다.
- 리뷰 목록 조회 시 리뷰 20건에 쿼리 40번
- 게시글 검색 시 게시글마다 해시태그 개별 조회
- 등급 뱃지 캐시를 적용했는데 적중률이 거의 0%
이 글에서는 이 세 가지 문제를 각각 진단하고 해결한 과정을 정리합니다.
Phase 1. 리뷰 목록 N+1 — 루프 안의 개별 쿼리 제거
문제: 리뷰 20건에 쿼리가 40번?
리뷰 목록을 조회할 때, 각 리뷰에 포함된 상품의 판매자 정보와 이미지를 건별로 개별 조회하고 있었습니다.
// Before: 리뷰마다 2번의 추가 쿼리
val itemDtos: List<ItemDto> = items.mapNotNull { item ->
val seller = sellerPort.getSellerDto(item) // N번 실행
val image = imagePort.getImage(item)?.let { ... } // N번 실행
ItemDto(seller = seller, image = image, ...)
}
// 리뷰 20개 → 40번의 추가 쿼리
해결: 배치 조회 + Map 매핑
ID 목록을 미리 수집하고, 한 번의 배치 쿼리로 필요한 데이터를 모두 가져온 뒤 Map으로 변환하는 패턴을 적용했습니다.
// After: 배치 조회 + Map 매핑
val sellerMap = sellerPort.getSellerDtoMap(items)
val imageMap = imagePort.list(
items.mapNotNull { it.id }
).associateBy { it.targetId }
val itemDtos: List<ItemDto> = items.mapNotNull { item ->
val seller = sellerMap[item.id] // O(1) Map 조회
val image = imageMap[item.id]?.let { ... } // O(1) Map 조회
ItemDto(seller = seller, image = image, ...)
}
// 리뷰 20개 → 2번의 배치 쿼리
이 패턴을 정리하면 다음과 같습니다.
1. 필요한 ID 목록 수집
2. ID 목록으로 배치 조회 (1회 쿼리)
3. 결과를 Map<ID, Entity>로 변환
4. 원본 목록을 순회하며 Map에서 O(1) 조회
Phase 2. 해시태그 조회 N+1 — 배치 JPQL과 트랜잭션 경계
문제: 게시글마다 해시태그를 따로 조회한다고?
게시글 검색 결과에 해시태그를 표시할 때, 게시글별로 해시태그를 개별 조회하고 있었습니다. 게시글이 10건이면 해시태그 쿼리도 10번 나가는 구조였습니다.
해결: 배치 JPQL + 메모리 그룹핑
join fetch로 한 번에 가져온 뒤, 메모리에서 게시글 ID 기준으로 그룹핑했습니다.
// Repository: 배치 조회 쿼리
@Query(
"select atag from ArticleTag atag " +
"join fetch atag.tag " +
"where atag.article.id in (:articleIds)"
)
fun findAllByArticleIdIn(articleIds: Collection<String>): List<ArticleTag>
// Service: 메모리에서 그룹핑
val tagsByArticleId = articleTagRepository
.findAllByArticleIdIn(articles.map { it.id })
.groupBy { it.article.id }
articles.associate { article ->
article.id to tagsByArticleId[article.id]
.orEmpty()
.map(::toView)
}
트랜잭션 경계도 함께 명시화
해시태그 조회가 Lazy Loading에 의존하지 않도록, TransactionTemplate으로 트랜잭션 경계를 명시했습니다.
TransactionTemplate(transactionManager).apply {
isReadOnly = true
}.execute {
val articles = articleRepository.listByIds(ids)
val tags = articleTagRepository.findAllByArticleIdIn(articles.map { it.id })
// 동일 트랜잭션 내에서 모든 데이터 로드 완료
assembleResult(articles, tags)
}
참고:
@Transactional은 프록시 기반이라 같은 클래스 내부 호출에서 동작하지 않을 수 있습니다.TransactionTemplate을 사용하면 트랜잭션 범위를 코드 레벨에서 명확하게 제어할 수 있고,isReadOnly = true는 Spring/Hibernate 환경에서 flush와 변경 감지 비용을 줄이는 데 도움이 될 수 있습니다.
Phase 3. 등급 뱃지 캐시 적중률 — 캐시 키가 원인이었다
문제: 캐시를 걸었는데 왜 매번 계산하지?
등급 뱃지 계산은 비용이 큰 작업입니다. 한 달간의 전체 구매 데이터를 집계해 상위 구매자를 추출하는 로직이라, 특정 사용자가 아닌 월 단위 글로벌 계산입니다. Redis 캐시를 적용했지만, 캐시 적중률이 거의 0%에 가까웠습니다.
원인은 캐시 키에 있었습니다.
// Before: Request 객체 전체가 캐시 키
@Cacheable(cacheNames = ["CALCULATE_GRADE_BADGE"], key = "#request")
fun calculate(request: GradeBadgeRequest) { ... }
GradeBadgeRequest에는 타임스탬프가 포함되어 있었습니다. 같은 월이라도 요청 시각이 다르면 캐시 미스가 발생했습니다.
요청 1: { at: 1772377200000 } → 캐시 MISS, 계산 실행
요청 2: { at: 1772377201000 } → 캐시 MISS, 다시 계산 (1초 차이)
요청 3: { at: 1772377202000 } → 캐시 MISS, 또 계산...
해결: 월 단위 캐시 키
등급 뱃지는 월별 구매 이력을 기반으로 계산하므로, 같은 월의 모든 요청은 동일한 결과를 반환합니다. 이 도메인 특성을 캐시 키에 반영했습니다.
companion object {
const val CACHE_NAME = "CALCULATE_GRADE_BADGE"
private val KOREA_ZONE_ID = ZoneId.of("Asia/Seoul")
private val MONTH_KEY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM")
}
@Cacheable(cacheNames = [CACHE_NAME], key = "#root.target.toMonthKey(#request)")
fun calculate(request: GradeBadgeRequest) { ... }
fun toMonthKey(request: GradeBadgeRequest): String =
Instant.ofEpochMilli(request.at)
.atZone(KOREA_ZONE_ID)
.format(MONTH_KEY_FORMATTER) // "2026-03"
요청 1: { at: 1772377200000 } → 키: "2026-03" → 캐시 MISS, 계산
요청 2: { at: 1772377201000 } → 키: "2026-03" → 캐시 HIT!
요청 3: { at: 1772463600000 } → 키: "2026-03" → 캐시 HIT!
요청 4: { at: 1777647600000 } → 키: "2026-05" → 캐시 MISS, 새 월 계산
TTL도 함께 조정했습니다.
Before: 15일 (월 중간에 캐시 만료 → 불필요한 재계산)
After: 40일 (한 달 + 여유분, 월이 바뀌면 새 키로 자동 전환)
참고: 한국 서비스이므로
Asia/Seoul타임존으로 정규화합니다. UTC 기준으로 하면 한국 시간 자정~오전 9시 사이에 월이 다르게 계산될 수 있습니다.
개선 결과
| 영역 | 개선 전 | 개선 후 | 응답 시간 (추정) |
|---|---|---|---|
| 리뷰 목록 (20건) | 40번 개별 쿼리 | 2번 배치 쿼리 | ~120ms → ~15ms |
| 해시태그 조회 (10건) | 10번 개별 쿼리 | 1번 배치 쿼리 (join fetch) |
~60ms → ~8ms |
| 등급 뱃지 캐시 | 거의 매번 MISS | 월 1회만 계산 | ~200ms → ~3ms (캐시 HIT 시) |
| 트랜잭션 관리 | 암시적 (Lazy Loading 의존) | 명시적 (TransactionTemplate) |
— |
교훈
- "루프 안에서 쿼리를 실행하는가?" — 이 한 가지 질문만으로 대부분의 N+1을 잡을 수 있습니다. 코드 리뷰에서 가장 먼저 확인해야 할 체크리스트입니다.
- 배치 조회 + Map 매핑은 N+1의 정석 패턴입니다. — ID 목록 수집 → 배치 쿼리 →
Map변환 → O(1) 조회. 이 흐름을 기계적으로 적용할 수 있습니다. - 캐시 키 설계는 기술적 판단이 아니라 도메인 판단입니다. — "이 데이터가 실제로 언제 바뀌는가?"를 기준으로 키의 차원과 TTL을 결정해야 합니다. 불필요한 차원(타임스탬프, 디바이스 타입 등)은 적중률을 떨어뜨릴 뿐입니다.
- 트랜잭션 경계는 명시적으로 관리하는 것이 안전합니다. — Lazy Loading에 의존하면 트랜잭션 밖에서 프록시 접근 에러가 발생할 수 있고,
TransactionTemplate은 이 문제를 코드 레벨에서 방지해줍니다.