게시판 페이징, COUNT(*) 쿼리가 불러오는 재앙과 현명한 해결책

궁금하면 500원·2025년 5월 25일

데이터 저장하기

목록 보기
17/29

들어가며: 게시판 페이징, 당연하지만 당연하지 않은 문제

대부분의 웹 서비스에서 게시판은 필수적인 기능입니다. 그리고 게시판에는 언제나 페이징(Pagination) UI가 따라오죠.

<이전> 1 2 3 4 5 <다음> <마지막>

이 익숙한 UI를 만들기 위해 개발자들은 보통 두 가지 쿼리를 사용합니다.

  1. SELECT * FROM board ORDER BY created_at DESC LIMIT 10 OFFSET 0 (현재 페이지 게시글 목록)
  2. SELECT COUNT(*) FROM board (전체 게시글 수)

게시글 수가 몇 천, 몇 만 건일 때는 아무 문제가 없습니다. 하지만 게시글 수가 300만 건을 넘어가는 순간, COUNT(*) 쿼리는 조용히, 그리고 치명적으로 당신의 서비스를 망가뜨릴 수 있습니다. 이 글에서는 COUNT(*) 쿼리가 왜 위험한지, 그리고 이를 어떻게 극복할 수 있는지 다양한 해결책들을 심층적으로 분석해 보겠습니다.


COUNT(*) 쿼리의 문제점: 풀 스캔(Full Scan)의 악몽

관계형 데이터베이스에서 SELECT COUNT(*) 쿼리는 보통 테이블의 모든 로우(Row)를 순회하는 풀 스캔을 수행합니다. 인덱스가 걸려 있더라도, 전체 개수를 정확히 세기 위해서는 인덱스를 처음부터 끝까지 읽어야 합니다.

  • 성능 저하: 300만 건의 데이터를 풀 스캔하는 데는 필연적으로 수 초의 시간이 소요됩니다. 사용자는 게시판 페이지를 로딩하는 데 몇 초씩 기다려야 하는 좋지 않은 경험을 하게 됩니다.
  • DB 커넥션 폭주: 만약 이 COUNT(*) 쿼리가 동시에 수백 개씩 들어온다면 어떻게 될까요? DB 서버는 이 무거운 쿼리들을 처리하느라 다른 작업들을 할 수 없게 되고, 결국 DB 커넥션이 고갈되어 서비스 전체가 마비되는 최악의 상황에 놓이게 됩니다.

즉, COUNT(*) 쿼리는 게시글 수가 적을 때는 문제가 없지만, 서비스가 성장할수록 발목을 잡는 성장통과 같은 존재입니다.


현명한 해결책들: 트레이드오프(Trade-off)의 예술

COUNT(*)의 문제를 해결하기 위한 방법들은 크게 세 가지로 나눌 수 있습니다. 각 방법은 장단점이 명확하므로, 서비스의 특성에 맞춰 신중하게 선택해야 합니다.

1. 커서(Cursor) 기반 페이징: 무한 스크롤에 최적화

이 방법은 전체 게시글 수를 아예 세지 않고 COUNT(*) 쿼리 자체를 회피하는 방식입니다. 인스타그램이나 페이스북의 무한 스크롤이 대표적인 예입니다.

  • 동작 방식: 마지막으로 조회한 게시글의 ID(또는 특정 필드)를 기준으로 다음 페이지를 조회합니다.
    SELECT * FROM board WHERE id < [마지막 게시글 ID] ORDER BY id DESC LIMIT 10
  • 장점:
    • COUNT(*) 쿼리가 필요 없어 성능이 매우 빠릅니다.
    • 대량의 데이터에서도 지연 없이 다음 페이지를 로드할 수 있어 사용자 경험이 뛰어납니다.
  • 단점:
    • <1, 2, 3... 마지막>과 같은 페이지 번호 기반의 UI를 구현할 수 없습니다.
    • 특정 페이지로 바로 이동하는 점프(Jump) 기능이 불가능합니다.

따라서 페이지 번호가 중요하지 않은 '무한 스크롤' 방식에만 적합한 해결책입니다.


2. 통계 테이블 (Aggregation Table): 배치(Batch)로 부담 줄이기

이 방법은 COUNT(*) 쿼리를 실시간으로 실행하는 대신, 별도의 통계 테이블에 전체 게시글 수를 미리 저장해두고 주기적으로 갱신하는 방식입니다.

  • 동작 방식:
    1. board_count와 같은 통계 테이블을 만듭니다.
    2. 스케줄러를 이용해 매 시간, 혹은 매일 새벽에 SELECT COUNT(*) FROM board 쿼리를 한 번 실행하고, 그 결과를 board_count 테이블에 업데이트합니다.
    3. 클라이언트의 요청이 들어오면 SELECT total_count FROM board_count 쿼리를 실행하여 매우 빠르게 총 개수를 가져옵니다.
  • 장점:
    • DB 부하가 매우 낮습니다. 무거운 COUNT(*) 쿼리는 배치 작업으로 딱 한 번만 실행됩니다.
    • 기존의 글쓰기 로직(게시글 INSERT)을 전혀 건드리지 않아도 되므로 개발 공수가 적습니다.
  • 단점:
    • 데이터 불일치 문제: 배치 주기에 따라 실제 게시글 수와 통계 테이블의 값이 일시적으로 다를 수 있습니다. (예: 배치 실행 후 10개의 글이 새로 등록된 경우)
    • 조회 조건의 다양성: '추천 수 5개 이상', '리뷰가 달린 글' 등 다양한 조건으로 필터링된 게시글의 총 개수를 구하려면, 각 조건에 맞는 통계 테이블을 따로 만들어야 합니다. 조회 조건이 늘어날수록 로직이 복잡해집니다.

이 방법은 데이터의 불일치를 어느 정도 허용할 수 있는 경우에 적합하며, 조회 조건이 단순하고 한정적일 때 효과적입니다.


3. Redis 캐싱: 유연성과 성능을 동시에

가장 유연하고 강력한 방법 중 하나로, Redis의 In-memory DB를 활용하여 COUNT(*) 쿼리 결과를 캐싱하는 방식입니다.

  • 동작 방식:
    1. Redis에 board:count와 같은 키에 전체 게시글 수를 저장합니다.
    2. 새로운 글이 등록되거나 삭제될 때마다, 이 Redis 값을 INCR 또는 DECR 명령어로 1씩 증가/감소시킵니다.
    3. TTL(Time-to-Live)을 설정하여 일정 시간(예: 5분)이 지나면 캐시가 만료되도록 합니다.
  • 장점:
    • 매우 빠른 응답 속도: DB에 접근하지 않고 메모리에서 데이터를 가져오므로 성능이 뛰어납니다.
    • 유연한 조회 조건 대응: 키를 board:count:notice 또는 board:count:rating:5와 같이 규칙에 따라 설정하면 다양한 필터링 조건에 쉽게 대응할 수 있습니다.
    • 점진적 갱신: 게시글이 추가될 때마다 값을 1씩 변경하므로, 통계 테이블 방식보다 데이터 불일치 문제가 적습니다.
  • 단점:
    • 캐시 만료 시점의 위험: TTL이 만료되어 캐시가 비워지는 순간, 모든 요청이 DB로 몰려들어 COUNT(*) 쿼리를 날릴 수 있습니다. 이 순간의 DB 커넥션 폭주를 막기 위한 방어 로직(Mutex Lock 등)이 반드시 필요합니다.
    • 기존 코드 수정: 게시글 등록/삭제 로직에 Redis 캐시를 갱신하는 코드를 추가해야 하므로, 기존 코드를 수정하고 QA를 다시 진행해야 합니다.
    • 데이터 불일치 가능성: DB 트랜잭션과 캐시 갱신 로직이 분리되어 있어, DB 작업은 성공했지만 캐시 갱신이 실패하는 등의 일시적인 불일치가 발생할 수 있습니다.

Redis 캐싱은 복잡도가 높지만, 성능과 유연성을 모두 잡을 수 있는 가장 이상적인 해결책입니다. 단, 발생할 수 있는 모든 예외 상황을 고려하고 대비책을 마련해야 합니다.


마치며: 정답은 없으며, 서비스 요구사항에 따라 달라진다

COUNT(*) 쿼리의 문제를 해결하는 방법에는 정답이 없습니다. 각 방법은 명확한 장단점을 가지고 있으며, 이는 곧 개발 비용, 성능, 데이터 일관성 사이의 트레이드오프 문제입니다.

  • 페이지 번호가 중요하지 않다면 👉 커서 기반 페이징
  • 데이터의 일시적 불일치를 허용하고, 조회 조건이 단순하다면 👉 통계 테이블
  • 성능과 유연성이 모두 중요하며, 시스템 복잡도를 감수할 수 있다면 👉 Redis 캐싱

무엇보다 중요한 것은 "A ms -> B ms로 개선했다"와 같은 단순한 수치 개선에만 집중하지 않고, 시스템 전체의 안정성과 데이터 일관성까지 고려하는 깊이 있는 설계입니다. 여러분의 서비스에 맞는 최적의 해결책을 찾아 현명하게 적용하시길 바랍니다.

profile
공부할게 많아졌어요

0개의 댓글