모임 조회 3단계 — ROW_NUMBER 제거로 5초 → 317ms
모임 조회 API 시리즈 1단계. 튜닝 없이 v1 쿼리 분석 — 389초 2단계. 쿼리 분리 + 복합 인덱스 — 389초 → 5초 3단계. ROW_NUMBER 제거 — 5초 → 317ms (현재 글) 4단계. Redis 캐시 + Redisson 분산 락 — p50 11ms
Problem
2단계에서 쿼리 분리로 389초 → 5초까지 개선했지만, ROW_NUMBER()가 풀 스캔을 강제하는 구조적 한계가 남아 있었다.
v2 쿼리의 내부 서브쿼리:
SELECT g.*, ROW_NUMBER() OVER (PARTITION BY g.category_id ORDER BY g.count DESC) AS rownum
FROM gathering g
(category_id, count) 복합 인덱스가 존재하지만, ROW_NUMBER()는 전체 행에 번호를 매긴 뒤 WHERE rownum BETWEEN 1 AND 9로 필터링하므로, MySQL 옵티마이저는 인덱스 대신 풀 스캔을 선택할 수밖에 없다.
인덱스를 추가하는 것만으로는 해결되지 않는다. ROW_NUMBER() 자체를 제거해야 인덱스가 동작한다.
Action
핵심 전략: ROW_NUMBER() 제거 → 카테고리별 단순 쿼리 전환
“카테고리별 인기 9개”를 구하는 데 ROW_NUMBER()가 반드시 필요한가?
카테고리별로 개별 쿼리를 날리면, 각 쿼리는 WHERE + ORDER BY + LIMIT만으로 충분하다. 이 구조는 복합 인덱스를 100% 활용할 수 있다.
v3 쿼리: 카테고리별 LIMIT 9
SELECT g.id, g.title, g.content, g.register_date AS registerDate,
g.category_id AS categoryId, cr.username AS createdBy, im.url AS url
FROM gathering g
LEFT JOIN user cr ON g.user_id = cr.id
LEFT JOIN image im ON g.image_id = im.id
WHERE g.category_id = ?
ORDER BY g.count DESC
LIMIT 9
WHERE category_id = ? ORDER BY count DESC LIMIT 9 — 이 조합은 (category_id, count) 복합 인덱스를 타면 인덱스 레인지 스캔으로 9건만 읽고 즉시 반환한다.
JdbcGatheringRepository
public List<MainGatheringsProjectionV2> gatheringsV3(Long categoryId) {
String sql = "select g.id, g.title, g.content, g.register_date as registerDate, " +
"g.category_id as categoryId, cr.username as createdBy, im.url as url " +
"from gathering g " +
"left join user cr on g.user_id = cr.id " +
"left join image im on g.image_id = im.id " +
"where g.category_id = ? " +
"order by g.count desc " +
"limit 9";
return jdbcTemplate.query(con -> {
PreparedStatement pstmt = con.prepareStatement(sql);
pstmt.setLong(1, categoryId);
return pstmt;
}, mainGatheringsV2RowMapper());
}
GatheringService: 카테고리별 반복 호출
public ApiResponse gatheringsV3() {
Map<Long, String> categoryNameMap = categoryRepository.findAll().stream()
.collect(Collectors.toMap(Category::getId, Category::getName));
List<MainGatheringsProjectionV2> projections = categoryNameMap.keySet().stream()
.flatMap(categoryId ->
jdbcGatheringRepository.gatheringsV3(categoryId).stream())
.toList();
List<Long> gatheringIds = projections.stream()
.map(MainGatheringsProjectionV2::getId).toList();
Map<Long, Integer> enrollmentCounts =
jdbcGatheringRepository.gatheringEnrollmentCounts(gatheringIds);
List<MainGatheringsProjection> mainGatheringElements = projections.stream()
.map(p -> MainGatheringsProjection.builder()
.id(p.getId())
.title(p.getTitle())
.content(p.getContent())
.registerDate(p.getRegisterDate())
.category(categoryNameMap.getOrDefault(p.getCategoryId(), "unknown"))
.createdBy(p.getCreatedBy())
.url(p.getUrl())
.count(enrollmentCounts.getOrDefault(p.getId(), 0))
.build())
.toList();
Map<String, CategoryTotalGatherings> map = categorizeByCategory(mainGatheringElements);
return toMainGatheringResponse(map);
}
카테고리가 20개면 쿼리 20번 + enrollment 배치 1번 = 총 21번. ROW_NUMBER()의 99만 행 풀 스캔 대비 각 쿼리가 인덱스로 9건만 읽으므로, N번의 라운드트립 비용을 감안해도 압도적으로 빠르다.
Result
EXPLAIN ANALYZE

Index lookup on g using idx_gathering_category_count— rows=9, actual time 0.02~0.05msLimit: 9 row(s)— 인덱스에서 9건 읽고 즉시 반환- 개별 쿼리: 0.17ms
5초 → 317ms (15배 개선). 개별 쿼리는 0.17ms지만, 카테고리 수만큼 반복 + enrollment 배치 + 네트워크 오버헤드를 합산하면 실제 API 응답은 317ms다.
Locust 100명 부하테스트

- 28 RPS, 0% 실패
- p50 1,600ms / p95 1,900ms
이전 61% 실패에서 0% 실패로 전환됐다.
Grafana — HikariCP

- Active connections: 10
- Pending threads: 33 — v2의 91에서 크게 감소
- 개별 쿼리가 빨라졌지만, 카테고리 수만큼 커넥션을 반복 사용하면서 여전히 대기 발생
RDS — DBLoadCPU

- DBLoadCPU: 0.12 — v2의 2.47에서 95% 감소
- 인덱스 레인지 스캔으로 CPU 부하가 극적으로 줄었다
Reflection
ROW_NUMBER()는 “한 번의 쿼리로 카테고리별 Top N”을 구하는 우아한 방법이다. 하지만 전체 행에 번호를 매기는 특성상 인덱스를 무력화한다. 99만 행에서 9개만 필요한데, 99만 행을 전부 읽고 정렬한 뒤 9개를 고르는 것이다.
ROW_NUMBER()를 제거하고 카테고리별 단순 쿼리로 전환하면, 비로소 복합 인덱스가 동작한다. 쿼리 횟수는 늘어나지만, 각 쿼리가 인덱스로 9건만 읽으므로 총 비용은 비교할 수 없을 만큼 작다.
다만 p50 1,600ms / p95 1,900ms로 응답 지연이 남아 있다. 카테고리 수만큼 쿼리가 발생하는 구조적 한계 — 카테고리가 늘어날수록 쿼리 수가 선형 증가한다. 이 한계를 넘으려면 캐시 레이어가 필요하다.
쿼리 튜닝의 한계를 캐시로 보완한다. Redis + Redisson 분산 락 + DB fallback. 4단계: Redis 캐시 + Redisson 분산 락 — p50 11ms →