Slow Query Log를 분석하던 중 게시글 목록 조회 시마다 아래 쿼리가 반복 실행되고 있었습니다.
select count(p1_0.id) from post p1_0 where p1_0.board_id = ?
부하 테스트(10분 구간) 동안 2,173건이 찍혔고, 건당 평균 실행 시간은 1,264ms였습니다.
| 지표 | 수치 |
|---|---|
| 발생 건수 | 2,173건 |
| 평균 실행 시간 | 1,264ms |
| P95 | 1,642ms |
| Max | 1,921ms |
| 검사 행 수 | 약 202,000행 |
post.board_id에는 idx_post_board 인덱스가 존재했습니다. 인덱스가 사용되고 있음에도 게시판당 글이 약 20만 건에 달해, 동시 요청이 많아지면 인덱스 스캔만으로도 경합이 발생하고 있었습니다.
PostRepository.findPostListByBoard가 Spring Data의 Page<T>를 반환하고 있었습니다.
@Query(
value = "SELECT new com.example.popping.dto.PostListItemResponse(...) " +
"FROM Post p LEFT JOIN p.author u WHERE p.board = :board",
countQuery = "SELECT COUNT(p) FROM Post p WHERE p.board = :board"
)
Page<PostListItemResponse> findPostListByBoard(@Param("board") Board board, Pageable pageable);
Page<T>는 전체 페이지 수(totalPages)를 계산하기 위해 매 요청마다 count 쿼리를 자동으로 실행합니다. PostService에서 postPage.getTotalElements()를 호출하는 시점이 이 count 쿼리의 실행 지점입니다.
return new PostPageResponse(
postResponsePage.getContent(),
(int) postPage.getTotalElements(), // ← 여기서 COUNT 쿼리 실행
postPage.getNumber(),
postPage.getTotalPages(),
postPage.hasNext(),
postPage.hasPrevious()
);
게시판 페이지에 접근할 때마다 "이 게시판의 총 게시글 수가 얼마인가"를 DB에 물어보는 구조였습니다. 동시 요청이 쌓이면 동일한 20만 행 범위를 여러 스레드가 동시에 스캔하면서 경합이 심해집니다.
COUNT 쿼리를 줄이는 방법은 크게 세 가지를 고려했습니다.
A. Board.postCount 비정규화 — Board 엔티티에 postCount 필드를 두고 게시글 생성·삭제 시 함께 갱신하는 방식입니다. likes 사례처럼 런타임 COUNT를 쓰기 시점 비용으로 옮길 수 있습니다. 다만 생성·삭제와 count 증감을 같은 트랜잭션 안에서 맞춰야 하고, 예외 상황에서 보정 전략도 필요합니다. 총 페이지 수를 반드시 보여줘야 한다면 현실적인 대안입니다.
B. Spring Cache로 COUNT 캐싱 — @Cacheable / @CacheEvict로 count 결과를 캐시하고 쓰기 시 무효화합니다. 빠르게 적용할 수 있지만 캐시 미스 순간 다시 비싼 COUNT가 터지고, 게시글 생성·삭제가 잦으면 무효화가 자주 발생합니다. 구조 문제 자체를 없애지 못하므로 보조 수단에 가깝습니다.
C. Page → Slice 전환 — 이번 병목의 본질은 "COUNT 쿼리가 느리다"가 아니라 "매 요청마다 큰 범위를 세야 한다"는 구조 문제입니다. Spring Data 공식 문서도 count 비용이 크면 Slice를 대안으로 안내합니다. Slice<T>는 COUNT 쿼리를 아예 실행하지 않으므로 병목을 구조적으로 제거할 수 있습니다. 이 서비스는 게시판별 글이 많고 동시 요청도 많아 가장 효과가 클 것으로 판단했습니다.
총 페이지 수를 포기할 수 있다면 C가 가장 근본적인 해결이기 때문에 C → A → B 순으로 우선순위를 두고, 우선 C를 적용하기로 했습니다.
전체 페이지 수가 필요 없다면 COUNT 쿼리 자체를 없애는 방향이 가능합니다. Spring Data의 Slice<T>는 size + 1개를 조회해 다음 페이지 존재 여부(hasNext)만 판단하고 count 쿼리를 실행하지 않습니다.
현재 화면의 페이지네이션은 "이전 / N페이지 / 다음" 구조였습니다. 총 페이지 수 표시("N / M페이지")를 포기하면 Slice로 전환할 수 있습니다. 커뮤니티 게시판 특성상 "총 몇 페이지인지"보다 "다음 글이 있는지"가 더 중요하다고 판단해 Slice를 선택했습니다.
PostRepository — Page → Slice, countQuery 제거
// 변경 전
@Query(
value = "SELECT new com.example.popping.dto.PostListItemResponse(...) " +
"FROM Post p LEFT JOIN p.author u WHERE p.board = :board",
countQuery = "SELECT COUNT(p) FROM Post p WHERE p.board = :board"
)
Page<PostListItemResponse> findPostListByBoard(@Param("board") Board board, Pageable pageable);
// 변경 후
@Query(
value = "SELECT new com.example.popping.dto.PostListItemResponse(...) " +
"FROM Post p LEFT JOIN p.author u WHERE p.board = :board"
)
Slice<PostListItemResponse> findPostListByBoard(@Param("board") Board board, Pageable pageable);
PostPageResponse — totalPosts, totalPages 필드 제거
// 변경 전
public record PostPageResponse(
List<PostListItemResponse> posts,
int totalPosts,
int currentPage,
int totalPages,
boolean hasNext,
boolean hasPrevious
) { ... }
// 변경 후
public record PostPageResponse(
List<PostListItemResponse> posts,
int currentPage,
boolean hasNext,
boolean hasPrevious
) { ... }
PostService — Page → Slice 타입 적용
// 변경 전
Page<PostListItemResponse> postPage = postRepository.findPostListByBoard(board, PageRequest.of(page, size));
// ...
return new PostPageResponse(
postResponsePage.getContent(),
(int) postPage.getTotalElements(),
postPage.getNumber(),
postPage.getTotalPages(),
postPage.hasNext(),
postPage.hasPrevious()
);
// 변경 후
Slice<PostListItemResponse> postPage = postRepository.findPostListByBoard(board, PageRequest.of(page, size));
// ...
return new PostPageResponse(
posts,
postPage.getNumber(),
postPage.hasNext(),
postPage.hasPrevious()
);
detail.html — totalPages() 표시 제거, 이전/다음 버튼만 유지
<!-- 변경 전 -->
<div th:if="${postPage.totalPages() > 1}" class="mt-6 flex justify-center gap-2">
<a th:if="${postPage.hasPrevious()}" ...>이전</a>
<span th:text="|${postPage.currentPage() + 1} / ${postPage.totalPages()}|">1 / 1</span>
<a th:if="${postPage.hasNext()}" ...>다음</a>
</div>
<!-- 변경 후 -->
<div th:if="${postPage.hasPrevious() or postPage.hasNext()}" class="mt-6 flex justify-center gap-2">
<a th:if="${postPage.hasPrevious()}" ...>이전</a>
<span th:text="${postPage.currentPage() + 1} + '페이지'">1페이지</span>
<a th:if="${postPage.hasNext()}" ...>다음</a>
</div>
1% 법칙: 90% 읽기(lurker) / 9% 소극적 참여(좋아요) / 1% 직접 작성
| Thread Group | 역할 | 스레드 수 | 비율 |
|---|---|---|---|
| TG1 | 비로그인 읽기 | 60 | 60% |
| TG2 | 로그인 읽기 | 30 | 30% |
| TG3 | 비로그인 작성 | 1 | 1% |
| TG4 | 로그인 작성 | 1 | 1% |
| TG5 | 좋아요 | 8 | 8% |
| 구분 | 비율 | CSV |
|---|---|---|
| Hot (인기 게시물 top 20) | 80% | hot_test_data.csv |
| Normal (일반 게시물 1000개) | 20% | test-data.csv |
| 엔드포인트 | Before Avg | After Avg | 개선 | Before P99 | After P99 | 개선 |
|---|---|---|---|---|---|---|
| GET /boards/{slug} (Hot) | 662ms | 34ms | 95% | 2,024ms | 68ms | 97% |
| GET /boards/{slug} (Normal) | 635ms | 31ms | 95% | 1,971ms | 64ms | 97% |
| GET /boards/{postId} (Hot) | 98ms | 33ms | 66% | 1,314ms | 53ms | 96% |
| GET /boards/{postId} (Normal) | 113ms | 32ms | 72% | 1,563ms | 53ms | 97% |
| GET /comments (Hot) | 61ms | 20ms | 67% | 529ms | 49ms | 91% |
| GET /comments (Normal) | 59ms | 17ms | 71% | 556ms | 56ms | 90% |
| TOTAL | 226ms | 27ms | 88% | 1,747ms | 128ms | 93% |
동일 10분 동안 처리한 총 요청 수도 52,096건 → 63,596건 (+22%) 증가했습니다.
| 지표 | Before | After | 변화 |
|---|---|---|---|
| CPU | 99% | 52% | -47%p |
| Sys Load | 1,129% | 133% | -88% |
| Avg Response Time (안정화 후) | ~100ms | ~10ms | -90% |
| P99 (안정화 후) | ~1,000ms | ~0ms | 측정 불가 수준 |
| DB Connection Usage | 60~100% (포화) | 4~10% | 극적 개선 |
| Slow Query QPS | 최대 12.5 | 최대 1.25 | -90% |
| Peak Threads Running | avg 17.3 / max 31 | avg 2.3 / max 3 | -87% |
| MySQL QPS | 707 | 888 | +26% |
Before 테스트(04:32~04:42 UTC)와 After 테스트(06:02~06:12 UTC) 구간을 슬로우 로그에서 각각 추출했습니다.
| 쿼리 | Before | After |
|---|---|---|
| COUNT(post) by board_id | 2,173건 / 평균 1,264ms | 0건 ✅ |
| CTE comment_tree | 490건 / 평균 24ms / P95 87ms | 542건 / 평균 6ms / P95 17ms |
| 슬로우 쿼리 총계 | 2,703건 | 542건 (-80%) |
COUNT 쿼리가 완전히 사라졌습니다. CTE 쿼리 응답시간이 25ms → 6ms로 줄어든 것도 주목할 부분입니다. COUNT 쿼리가 DB 스레드를 점유하던 경합이 사라지면서, 댓글 쿼리도 대기 없이 처리된 결과입니다.
Slice는 다음 페이지 존재 여부만 알 수 있어 "3 / 50페이지" 형태의 표시를 제공할 수 없습니다. "이전 / N페이지 / 다음" 방식으로 변경했습니다. 총 게시글 수나 총 페이지 수가 UI에 반드시 필요하다면 Page로 되돌리거나 별도 캐싱 전략이 필요합니다.
COUNT 쿼리 제거 후에도 After 테스트에서 Slow Query QPS가 1.25 수준으로 남아 있습니다. 해당 쿼리는 전부 CTE comment_tree이며, 이 구간의 평균은 6ms로 문제 수준은 아닙니다. 하지만 After Grafana에서 CPU가 여전히 40~50% 수준으로 유지되는 것은 DB I/O 이외의 처리 비용(렌더링, 직렬화 등)이 남아 있기 때문입니다.
Page<T> 반환으로 인해 게시글 목록 조회마다 COUNT 쿼리가 실행됐고, 게시판당 약 20만 건의 데이터를 동시에 스캔하면서 DB 커넥션이 포화 상태가 됐습니다. Slow Query Log 기준으로 10분 테스트 구간에서만 2,090건, 평균 1,269ms의 COUNT 쿼리가 발생했습니다.
Slice<T>로 전환해 COUNT 쿼리를 구조적으로 제거하자 DB 커넥션 사용률이 60~100%에서 4~10%로 떨어지고, 게시글 목록 응답시간이 648ms → 33ms로 95% 감소했습니다. 동일 조건에서 처리 가능한 요청 수도 52,096건 → 63,596건으로 22% 증가했습니다.
COUNT 쿼리 제거는 코드 변경이 작고 즉각적인 효과가 큰 최적화입니다. 다만 총 페이지 수를 포기해야 하므로, 서비스에서 페이지 수가 필수 정보인지 먼저 확인하는 것이 전제조건입니다.