COUNT(*) 는 결국 다 세는 일이다

목록 스무 개는 10ms 에 나오는데 전체 개수를 세는 데는 3초가 걸려요. 인덱스를 더해도 크게 안 줄고요. 이 비용이 왜 구조적인지, 정확한 숫자 대신 무엇을 보여 줄 수 있는지 알 수 있어요.

목록 화면에 전체 개수를 보여 주려고 COUNT 를 돌려요. 처음엔 즉시 나왔는데 데이터가 늘면서 몇 초씩 걸려요.

이상한 건 실제 목록 조회는 여전히 빠르다는 거예요. LIMIT 20 으로 스무 개 가져오는 건 10ms 인데, 개수를 세는 건 3초예요. 같은 조건인데요.

인덱스를 추가해 봐도 크게 안 줄어요. 어떤 DB 에서는 COUNT(*) 가 즉시 나온다는 얘기를 듣고 시도해 봤는데 우리 쪽은 그대로고요.

이 차이가 어디서 오는지 알면 대응 방법도 갈려요. 없앨 수 있는 비용인지, 구조적으로 내야 하는 비용인지부터 다릅니다.

왜 그런가

개수를 센다는 건 조건에 맞는 행을 전부 확인한다는 뜻이에요. 스무 개만 필요한 조회와 달리 조기 종료가 안 돼요. 그래서 데이터가 늘면 비례해서 느려집니다.

MySQL 의 MyISAM 은 테이블의 전체 행 수를 따로 저장해 둬서 조건 없는 COUNT(*) 가 즉시 나왔어요. "COUNT 가 빠르다"는 얘기는 대개 여기서 온 거예요.

InnoDB 와 PostgreSQL 은 그렇게 못 해요. MVCC 때문이에요. 트랜잭션마다 보이는 행이 다르니 하나의 전역 카운터로는 답을 낼 수 없어요. 내 트랜잭션에게 보이는 행이 몇 개인지는 실제로 확인해야만 알 수 있습니다.

그래서 이건 최적화로 없앨 수 있는 비용이 아니라 구조적인 비용이에요. 인덱스가 도움은 돼요 — 인덱스만 읽고 끝낼 수 있으면 테이블 본체보다 작아서 빠르니까요. 그런데 앞서 본 대로 가시성 맵이 최신이어야 하고, 그래도 전체를 훑는 건 마찬가지예요.

정리하면 COUNT 의 비용은 "얼마나 많이 세느냐"에 비례해요. 조건으로 좁혀지면 싸고, 전체를 세면 비쌉니다.

코드로 보면

정확한 수가 정말 필요한가:

코드
 SELECT count(*) FROM posts WHERE status='published';
 -- 조건에 맞는 행을 전부 확인한다

· 1) 근삿값으로 충분하면 통계에서 읽는다 (즉시)
 SELECT reltuples::bigint FROM pg_class
 WHERE relname = 'posts';
· ANALYZE 시점 기준의 추정치다

· 2) '100개 이상'만 보여 주면 되면 세다가 멈춘다
 SELECT count(*) FROM (
 SELECT 1 FROM posts WHERE status='published' LIMIT 101
 ) t;
· 101 이면 '100+' 로 표시한다

· 3) 자주 쓰는 개수는 미리 세어 둔다
 집계 테이블 + 트리거 또는 주기적 갱신

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

대응 방법은 "얼마나 정확해야 하는가"에서 갈려요. 이 질문을 먼저 해야 해요.

정확할 필요가 없는 경우가 의외로 많아요. "총 12,483개"에서 뒤의 세 자리를 사람이 보나요? "약 1만 2천 개"면 충분한 경우가 대부분이에요. 그러면 통계에서 추정치를 읽으면 돼요. 즉시 나오고 부하도 0 이에요. 다만 마지막 통계 수집 시점 기준이라 오차가 있습니다.

조건이 붙은 개수의 추정은 더 까다로워요. 옵티마이저의 추정 행 수를 가져다 쓰는 방법이 있는데, 이건 계획을 세워 보고 그 추정을 읽는 방식이라 정확도가 통계 품질에 달려 있어요.

두 번째 방향은 세는 범위를 제한하는 거예요. 검색 결과에 "100개 이상"이라고 표시해도 되면, 101개까지만 세고 멈추면 돼요. 실제 사용자 경험에서 5,000페이지째로 가는 사람은 없으니, 정확한 총 개수를 계산하는 비용이 대부분 낭비예요.

세 번째는 미리 세어 두는 거예요. 집계 테이블을 두고 트리거로 갱신하거나 주기적으로 다시 세는 방식이요. 정확하고 조회가 즉시지만, 갱신 비용과 정합성 문제가 생겨요. 트리거로 하면 쓰기가 느려지고 경합이 생기고, 주기적 갱신이면 그 사이 값이 낡아요.

네 번째는 UI 를 바꾸는 거예요. 무한 스크롤이면 총 개수가 애초에 필요 없어요. "다음 페이지 있음" 여부만 알면 되고, 그건 하나 더 가져와 보는 것으로 확인돼요.

그리고 목록과 개수를 한 요청에서 같이 구하는 구조를 다시 보세요. 개수는 첫 페이지에서만 필요한 경우가 많아요. 페이지를 넘길 때마다 매번 다시 세고 있다면 그것부터 없애면 됩니다.

처방

  1. 정확한 수가 정말 필요한지 물어보세요. 이 질문 하나로 대부분 해결됩니다.
  2. 근삿값으로 충분하면 통계에서 읽으세요. 조건 없는 전체 개수는 즉시 나와요.
  3. "100+" 같은 상한 표시가 가능하면 세다가 멈추세요. 서브쿼리에 LIMIT 을 걸어 개수를 제한합니다.
  4. 자주 쓰는 개수는 집계 테이블에 유지하세요. 다만 갱신 비용과 경합을 감안해야 해요.
  5. 무한 스크롤이면 총 개수를 없애세요. 다음 페이지 존재 여부는 하나 더 가져와 보면 됩니다.
  6. 개수 조회를 페이지마다 반복하지 마세요. 첫 요청에서만 구하고 클라이언트가 들고 있으면 됩니다.
  7. 인덱스만 읽고 셀 수 있게 만드세요. 조건 컬럼이 인덱스에 있으면 본체를 안 읽어도 되고, 가시성 맵이 최신이면 실제로 그렇게 됩니다.
  8. 개수 조회에 타임아웃을 거세요. 목록은 나왔는데 개수 때문에 화면 전체가 안 뜨는 상황을 막습니다.

지금 확인해 보세요

얼마나 비싼지, 대안은 얼마나 싼지:

코드
· 실제 비용
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM posts WHERE status='published';
 읽은 블록 수가 테이블 크기에 가까우면 전체를 훑은 것

· 근삿값 — 즉시 나온다
SELECT reltuples::bigint AS estimate, relname
  FROM pg_class WHERE relname = 'posts';

· 통계가 언제 갱신됐나 (추정치의 신선도)
SELECT last_analyze, last_autoanalyze
  FROM pg_stat_user_tables WHERE relname='posts';

· 개수 조회가 전체 부하에서 차지하는 비중
SELECT round(total_exec_time) AS total_ms, calls, left(query,50)
  FROM pg_stat_statements
 WHERE query ILIKE '%count(%'
 ORDER BY total_exec_time DESC LIMIT 10;

핵심 한 줄개수를 세는 건 조기 종료가 없는 일이다. 정확도를 먼저 물어라.

자주 묻는 것

PostgreSQL 에서 COUNT(*) 를 빠르게 하는 방법이 있나요?

조건 없는 전체 개수라면 pg_class 의 reltuples 로 근삿값을 즉시 읽을 수 있어요. ANALYZE 시점 기준이라 오차가 있어요. '100개 이상'으로 보여 줘도 되면 서브쿼리에 LIMIT 101 을 걸고 세다가 멈추면 돼요.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

인덱스·쿼리 성능 글 20편 모두 보기 →