SELECT COUNT(*) 는 왜 느려지고, 무엇으로 대신하나

2026. 10. 27. 15:27ㆍ카테고리 없음

강의 정리 · Hussein Nasser — SELECT COUNT(*) can impact your Backend Application Performance (10분 37초)

 

SELECT COUNT(*) 는 왜 느려지고, 무엇으로 대신하나

"몇 개인지 세기"는 쉬워 보이지만 DB 입장에선 줄을 하나하나 확인하는 일입니다. 6천만 줄짜리 grades 테이블로 PostgreSQL 이 COUNT 를 어떻게 처리하는지 따라가고, 정확한 수가 필요 없을 때 쓰는 추정치까지 정리합니다.

① COUNT(g) → ② COUNT(*) → ③ 수정 후 → ④ 범위 확대 → ⑤ 추정치

※ 강사는 영상 속 실행 시간이 컨테이너·캐시 환경이라 의미가 없고, 실행 계획(plan)을 읽는 것이 핵심이라고 강조합니다.


1. COUNT(g) — 색인을 보고도 테이블까지 간다

실험 테이블은 학생 성적 grades(id, g) 이고 id 에 색인이 있습니다. id 가 1000~4000 인 학생의 성적 g 개수를 세면 약 3,000 이 아니라 약 2,900 이 나옵니다.

-- id 가 1000~4000 인 줄에서, g 값이 비어 있지 않은 것만 센다
SELECT COUNT(g) FROM grades WHERE id BETWEEN 1000 AND 4000;

숫자가 적은 이유는 COUNT(칸이름) 이 NULL(빈 값)을 빼고 세기 때문입니다. 그런데 g 가 비었는지는 색인에 없고 테이블에만 있으니, DB 는 색인으로 줄을 찾은 뒤 줄마다 테이블을 다시 열어 g 를 확인해야 합니다.

실행 계획 DB 가 하는 일
Index Scan 색인에서 id 1000~4000 줄 3,001개를 찾음 → 줄마다 테이블로 가서 g 가 NULL 인지 확인 → NULL 아닌 것만 셈

2. COUNT(*) — 색인만 보고 끝난다

-- id 가 1000~4000 인 줄이 몇 개인지 센다 (빈 값도 포함)
SELECT COUNT(*) FROM grades WHERE id BETWEEN 1000 AND 4000;

흔한 오해는 "* 니까 모든 칸을 다 가져와서 센다"는 것인데, 요즘 DB 는 그러지 않습니다. COUNT(*) 는 "있는 줄 수만 세라"는 뜻이라 NULL 도 포함하고, 테이블을 열 필요 없이 색인만 훑고 끝냅니다.

비교 COUNT(g) COUNT(*)
세는 것 g 가 비어 있지 않은 줄 모든 줄(NULL 포함)
결과 약 2,900 더 큼(색인 기준 3,001줄)
실행 계획 Index Scan
색인 → 테이블
Index Only Scan
색인만

표 1. 테이블에 다시 갈 필요가 없는 Index Only Scan 이 거의 항상 더 낫습니다.


3. 줄을 고친 뒤 — Index Only Scan 인데 테이블을 다시 연다

id 1000~4000 줄의 성적을 한꺼번에 고친 뒤 같은 COUNT(*) 를 돌리면, 계획은 여전히 Index Only Scan 인데 Heap Fetches(테이블을 다시 연 횟수)가 생깁니다.

-- id 1000~4000 줄의 성적을 모두 바꾼다
UPDATE grades SET g = 10 WHERE id BETWEEN 1000 AND 4000;
-- 같은 COUNT 를 다시 돌리면서 실제 계획과 횟수를 본다
EXPLAIN ANALYZE SELECT COUNT(*) FROM grades WHERE id BETWEEN 1000 AND 4000;
-- 테이블을 청소해 "이 줄은 다 정리됐다" 표시를 갱신한다
VACUUM grades;
시점 Heap Fetches (테이블 다시 열기)
수정 전 ░░░░░░░░░░░░░░░░░░░░ 0
UPDATE 직후 ████████████████████ 6,002
VACUUM 후 ░░░░░░░░░░░░░░░░░░░░ 0

그림 1. 영상 속 수치. 6,002 가 줄 수인지 블록 수인지는 강사도 확인이 필요하다고 말합니다.

왜 다시 열까? PostgreSQL 은 줄을 고치거나 지울 때 옛 버전을 바로 지우지 않고 남겨 둡니다(MVCC — 다른 트랜잭션이 아직 옛 값을 보고 있을 수 있어서). 색인에는 "이 줄이 지금도 살아 있는가"가 없으니, 최근에 바뀐 구역의 줄은 테이블에 가서 직접 확인해야 합니다.

① UPDATE · 새 버전을 추가하고 옛 버전은 남겨 둠
↓
② 가시성 지도(visibility map) · 그 구역에 "확인 필요" 표시
↓
③ COUNT(*) · 색인을 훑다가 표시된 줄은 테이블에 가서 살아 있는지 확인 → 느려짐
↓
④ VACUUM · 아무도 안 보는 옛 버전 정리, 지도를 "확인 불필요"로 갱신 → 다시 색인만으로 끝남

그림 2. 수정 → 확인 표시 → 테이블 재방문 → 청소.


4. 세는 범위가 넓어질수록 비례해서 느려진다

VACUUM 으로 Heap Fetches 를 0 으로 만들어도, 세는 범위(4000 → 그 이상)를 넓히면 시간은 계속 늘어납니다. PostgreSQL 이 일꾼(병렬 워커)을 여러 개 붙여 색인을 나눠 훑어도 소용없습니다.

범위를 조금 넓힘 █████░░░░░░░░░░░░░░░ 약 1.5초
더 넓힘(병렬 워커 사용) ████████████████████ 약 6초

그림 3. 영상 속 실행 시간. COUNT 는 세는 줄 수에 비례하는 일이라, 줄이 많으면 공짜가 아닙니다.


5. 대안 — 추정치(estimate)로 받기

"좋아요 수"처럼 정확한 수가 필요 없다면 쿼리를 실제로 돌리지 않고 PostgreSQL 이 이미 들고 있는 통계에서 대략적인 줄 수를 꺼냅니다. 방법은 SELECT 앞에 EXPLAIN 을 붙이는 것입니다.

-- 쿼리를 실행하지 않고 "대략 몇 줄 나올지" 계획만 JSON 으로 받는다
EXPLAIN (FORMAT JSON) SELECT * FROM grades WHERE id BETWEEN 1000 AND 4000;
-- 테이블 전체 줄 수 추정치는 통계 메모에서 바로 읽는다
SELECT reltuples FROM pg_class WHERE relname = 'grades';
-- 데이터가 많이 바뀌었으면 통계를 새로 고친다 (오래 걸릴 수 있음)
ANALYZE grades;
실제 줄 수(색인 기준) ████████████████████ 3,001
EXPLAIN 추정치 ███████████████████░ 2,868

그림 4. 약 130줄 차이지만, 줄을 하나도 열어 보지 않아 거의 즉시 나옵니다.

쓰는 말 무슨 일이 일어나나
SELECT … 쿼리를 진짜로 돌려 결과를 가져옴
EXPLAIN SELECT … 돌리지 않고 계획과 예상 줄 수(rows=)만 보여 줌 → 추정치
EXPLAIN ANALYZE SELECT … 진짜로 돌린 뒤 계획과 실제 줄 수·시간을 보여 줌 → 추정용으로 쓰면 안 됨

표 2. 색인을 쓸지 말지는 EXPLAIN 을 붙이든 안 붙이든 DB 가 똑같이 정합니다. 차이는 "실제로 돌리느냐"뿐입니다.

이 통계는 ANALYZE 나 자동 청소(autovacuum)가 돌 때 일부 줄만 표본으로 뽑아 갱신합니다. 그래서 정확하진 않지만, 꺼내는 비용은 테이블 크기와 상관없이 거의 0 입니다.


정리 — 언제 무엇을 쓰나

상황 선택
줄이 적고 앞으로도 적음 그냥 COUNT(*)
계속 커질 테이블, 대략이면 충분
(좋아요 수 · 게시물 수)
EXPLAIN 추정치 / reltuples
"6천만 320명" 처럼 정확히 보여 줄 필요는 거의 없음
NULL 을 빼고 세야 함 COUNT(칸) — 테이블까지 가므로 더 느림을 감안
수정이 잦아 Heap Fetches 가 큼 VACUUM 이 제때 도는지 확인
추정치가 많이 틀림 ANALYZE 로 통계 갱신

참고: 강사가 이 방법을 가져왔다고 밝힌 글 — CYBERTEC · PostgreSQL: count(*) made fast

주식 데이터 분석 앱 → dinsightlab.com

 

Passive

미국 지수 투자자를 위한 금융 데이터 분석 사이트

dinsightlab.com