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