게시글 목록 COUNT 쿼리 제거

조용현·2026년 3월 31일

문제 해결

목록 보기
8/15

배경

popping_page_slow

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
P951,642ms
Max1,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를 적용하기로 했습니다.

Page vs Slice

전체 페이지 수가 필요 없다면 COUNT 쿼리 자체를 없애는 방향이 가능합니다. Spring Data의 Slice<T>size + 1개를 조회해 다음 페이지 존재 여부(hasNext)만 판단하고 count 쿼리를 실행하지 않습니다.

현재 화면의 페이지네이션은 "이전 / N페이지 / 다음" 구조였습니다. 총 페이지 수 표시("N / M페이지")를 포기하면 Slice로 전환할 수 있습니다. 커뮤니티 게시판 특성상 "총 몇 페이지인지"보다 "다음 글이 있는지"가 더 중요하다고 판단해 Slice를 선택했습니다.

코드 변경

PostRepositoryPageSlice, 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);

PostPageResponsetotalPosts, 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
) { ... }

PostServicePageSlice 타입 적용

// 변경 전
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.htmltotalPages() 표시 제거, 이전/다음 버튼만 유지

<!-- 변경 전 -->
<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>

테스트 시나리오

테스트 환경

  • EC2 t2.micro (1 vCPU, 953 MiB RAM), Docker 환경
  • MySQL 8 (InnoDB Buffer Pool 128 MiB), Spring Boot, HikariCP (pool size: 30)
  • JMeter + Prometheus + Grafana 모니터링

시나리오 구성

1% 법칙: 90% 읽기(lurker) / 9% 소극적 참여(좋아요) / 1% 직접 작성

Thread Group역할스레드 수비율
TG1비로그인 읽기6060%
TG2로그인 읽기3030%
TG3비로그인 작성11%
TG4로그인 작성11%
TG5좋아요88%
  • Duration: 600s, Ramp-up: 60s
  • Think Time: GaussianRandomTimer (1000ms ± 500ms)

인기 게시물 집중 트래픽 반영 (80/20)

구분비율CSV
Hot (인기 게시물 top 20)80%hot_test_data.csv
Normal (일반 게시물 1000개)20%test-data.csv

테스트 결과

JTL 수치 비교

엔드포인트Before AvgAfter Avg개선Before P99After P99개선
GET /boards/{slug} (Hot)662ms34ms95%2,024ms68ms97%
GET /boards/{slug} (Normal)635ms31ms95%1,971ms64ms97%
GET /boards/{postId} (Hot)98ms33ms66%1,314ms53ms96%
GET /boards/{postId} (Normal)113ms32ms72%1,563ms53ms97%
GET /comments (Hot)61ms20ms67%529ms49ms91%
GET /comments (Normal)59ms17ms71%556ms56ms90%
TOTAL226ms27ms88%1,747ms128ms93%

동일 10분 동안 처리한 총 요청 수도 52,096건 → 63,596건 (+22%) 증가했습니다.

popping_slice

Grafana 비교

지표BeforeAfter변화
CPU99%52%-47%p
Sys Load1,129%133%-88%
Avg Response Time (안정화 후)~100ms~10ms-90%
P99 (안정화 후)~1,000ms~0ms측정 불가 수준
DB Connection Usage60~100% (포화)4~10%극적 개선
Slow Query QPS최대 12.5최대 1.25-90%
Peak Threads Runningavg 17.3 / max 31avg 2.3 / max 3-87%
MySQL QPS707888+26%

popping_slice_slow

Slow Query Log 비교

Before 테스트(04:32~04:42 UTC)와 After 테스트(06:02~06:12 UTC) 구간을 슬로우 로그에서 각각 추출했습니다.

쿼리BeforeAfter
COUNT(post) by board_id2,173건 / 평균 1,264ms0건
CTE comment_tree490건 / 평균 24ms / P95 87ms542건 / 평균 6ms / P95 17ms
슬로우 쿼리 총계2,703건542건 (-80%)

COUNT 쿼리가 완전히 사라졌습니다. CTE 쿼리 응답시간이 25ms → 6ms로 줄어든 것도 주목할 부분입니다. COUNT 쿼리가 DB 스레드를 점유하던 경합이 사라지면서, 댓글 쿼리도 대기 없이 처리된 결과입니다.


트레이드오프

총 페이지 수 표시 불가

Slice는 다음 페이지 존재 여부만 알 수 있어 "3 / 50페이지" 형태의 표시를 제공할 수 없습니다. "이전 / N페이지 / 다음" 방식으로 변경했습니다. 총 게시글 수나 총 페이지 수가 UI에 반드시 필요하다면 Page로 되돌리거나 별도 캐싱 전략이 필요합니다.

DB 부하가 아닌 다른 병목이 드러남

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 쿼리 제거는 코드 변경이 작고 즉각적인 효과가 큰 최적화입니다. 다만 총 페이지 수를 포기해야 하므로, 서비스에서 페이지 수가 필수 정보인지 먼저 확인하는 것이 전제조건입니다.


참고

profile
백엔드 개발자

0개의 댓글