남기다
홈 › 쿼리와 SQL › COUNT(*), COUNT(1), COUNT(컬럼)의 차이와 성능 통념 검증

COUNT(*), COUNT(1), COUNT(컬럼)의 차이와 성능 통념 검증

한서윤 · · 쿼리와 SQL

몇 해 전 겨울, 동료가 올린 통계 쿼리에 리뷰를 하나 달았다. COUNT(*)는 모든 컬럼을 읽으니 COUNT(1)로 바꾸자는 내용이었다. 어디서 주워들은 말을 확인도 없이 옮긴 것이다. 동료는 순순히 고쳤는데, 배포한 뒤에도 쿼리 시간은 조금도 달라지지 않았다. 그때는 그러려니 했다. 한참 뒤 MySQL 문서를 읽다가 얼굴이 뜨거워졌다.

COUNT는 가장 먼저 배우는 집계 함수인데 이상하게 소문이 많이 따라붙는다. 별표를 쓰면 전 컬럼을 읽을 것 같고, 1은 상수라서 가벼울 것 같다. 이런 감각은 대부분 틀렸다. 그중 하나는 데이터베이스에 따라 정반대가 맞기도 하다.

헷갈리는 건 두 질문이 섞여 있어서다. 하나는 결과가 다른가이고, 다른 하나는 속도가 다른가이다. 둘을 따로 따져야 답이 깔끔하게 나온다.

세 COUNT가 세는 대상

PostgreSQL 문서는 두 형태를 한 줄씩으로 구분한다. count(*)는 입력 행의 수를 계산하고, count(식)은 입력값이 NULL이 아닌 행의 수를 계산한다. COUNT(1)은 따로 있는 문법이 아니라 식 자리에 상수 1을 넣은 것일 뿐이다. 1은 어느 행에서도 NULL이 될 수 없으니 결과는 언제나 COUNT(*)와 같다.

형태세는 대상NULL인 행
COUNT(*)모든 행센다
COUNT(1)1이 NULL이 아닌 행, 즉 모든 행센다
COUNT(col)col이 NULL이 아닌 행뺀다
COUNT(DISTINCT col)NULL이 아닌 서로 다른 값뺀다

결과가 갈리는 건 NULL 하나

결과가 실제로 갈리는 일은 월간 회원 리포트를 만들다 처음 겪었다. 회원 수를 COUNT(phone)으로 뽑아 넘겼더니, 가입자 관리 화면과 숫자가 왜 다르냐는 문의가 돌아왔다. 이메일로만 가입해서 휴대폰 번호가 없는 회원이 몇천 명 있었고, phone은 NULL을 허용하는 컬럼이었다. 아무 컬럼이나 적는다는 게 하필 비어 있을 수 있는 컬럼을 고른 것이다.

CREATE TABLE members (id INT PRIMARY KEY, phone VARCHAR(20));
INSERT INTO members VALUES
  (1, '010-1111-2222'), (2, NULL), (3, '010-3333-4444'), (4, NULL);

SELECT COUNT(*), COUNT(1), COUNT(phone), COUNT(DISTINCT phone)
FROM members;
-- 4 | 4 | 2 | 2

MySQL, PostgreSQL, SQLite 모두 같은 결과를 낸다. 의도와 함수가 어긋나도 에러는 나지 않는다. 틀린 숫자가 조용히 나올 뿐이다.

반대로 NULL을 빼고 세는 성질을 일부러 쓸 때도 있다. 고객별 주문 수를 LEFT JOIN으로 셀 때다. 주문이 없는 고객도 오른쪽이 NULL로 채워진 한 줄로 남는다. 그래서 COUNT(*)는 1을 돌려주고, 오른쪽 테이블의 기본 키를 세야 0이 나온다.

SELECT c.id, COUNT(*) AS wrong_cnt, COUNT(o.id) AS order_cnt
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;

COUNT(1)이 빠르다는 통념

MySQL 8.0 문서에는 InnoDB가 SELECT COUNT(*)와 SELECT COUNT(1)을 같은 방식으로 처리하며 성능 차이가 없다고 분명히 적혀 있다. 내 리뷰가 아무 효과도 없었던 이유다. jOOQ를 만든 Lukas Eder가 2019년에 MySQL 8.0.16, Oracle 18c, SQL Server 2017에서 벤치마크를 돌렸을 때도 둘 사이에 의미 있는 차이는 없었다.

PostgreSQL은 사정이 다르다. 같은 벤치마크에서 PostgreSQL 11.3은 COUNT(*)가 10% 가까이 꾸준히 빨랐다. Cybertec의 Laurenz Albe는 count(1)이나 count(id)가 오히려 느린 이유를 이렇게 설명한다. 인자가 있는 집계는 행마다 그 값이 NULL인지 확인해야 한다. 인자가 없는 count(*)는 그 확인을 건너뛴다. 그 리뷰가 PostgreSQL 쿼리에 달렸다면 나는 동료의 쿼리를 조금 느리게 만든 셈이다.

이 차이도 곧 사라진다. 2025년 11월 David Rowley가 PostgreSQL 개발 브랜치에 커밋을 하나 넣었다. 플래너가 COUNT(ANY)를 COUNT(*)로 바꿔 주는 내용이다. 단, 인자가 NULL일 수 없고 집계 안에 ORDER BY나 DISTINCT가 없을 때만 바꾼다. 그래서 PostgreSQL 19부터는 COUNT(1)이나 NOT NULL 컬럼을 센 COUNT(id)도 COUNT(*)와 같은 계획을 받는다.

InnoDB에서 COUNT(*)가 느린 이유

오래된 MyISAM 테이블들을 InnoDB로 바꾸던 주였다. 엔진 변환은 별 탈 없이 끝났다. 그런데 다음 날 아침 관리자 대시보드 첫 화면이 3초씩 멈췄다. 화면 맨 위에 있는 전체 주문 건수 위젯이 원인이었다. WHERE 없는 SELECT COUNT(*) FROM orders 한 줄이었고, MyISAM 시절에는 눈 깜짝할 새 끝나던 쿼리였다. 당황했지만 문서를 펴 보니 이유가 바로 나왔다.

MyISAM은 테이블의 정확한 행 수를 따로 저장해 둔다. 그래서 테이블 하나에서 WHERE 없이 세면 저장된 값만 읽고 끝낸다. InnoDB는 행 수를 저장하지 않는다. 동시에 도는 트랜잭션들이 같은 순간에 서로 다른 행 수를 볼 수 있어서다. 아직 커밋하지 않은 INSERT가 있으면 그 트랜잭션에는 행이 하나 더 보이고 다른 트랜잭션에는 보이지 않는다. 그래서 COUNT(*)는 현재 트랜잭션에 보이는 행을 매번 직접 센다.

MyISAM은 저장해 둔 행 수를 바로 돌려주고, InnoDB는 가장 작은 인덱스를 훑으며 현재 트랜잭션에 보이는 행만 세는 과정을 비교한 그림
MyISAM은 저장된 숫자를 읽고, InnoDB는 매번 직접 센다

세는 경로도 정해져 있다. InnoDB는 가장 작은 보조 인덱스를 훑고, 보조 인덱스가 없으면 클러스터드 인덱스를 훑는다. 인덱스 레코드가 버퍼 풀에 다 올라와 있지 않으면 디스크를 읽느라 더 오래 걸린다. 8.0.13부터는 WHERE나 GROUP BY가 없는 COUNT(*)의 단일 스레드 성능이 나아졌다. 그래도 행을 하나씩 센다는 구조는 바뀌지 않았다.

-- 어떤 인덱스로 세는지 확인
EXPLAIN SELECT COUNT(*) FROM orders;

-- 옵티마이저용 추정치 (InnoDB에서는 부정확)
SELECT TABLE_ROWS
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME = 'orders';

TABLE_ROWS는 InnoDB에서 실제 값과 40~50%까지 차이가 날 수 있다고 문서에 나와 있다. 대략적인 숫자로 쓰기에도 너무 거칠었다. 결국 주문 건수는 집계 테이블에 하루 한 번 쌓아 두고 거기서 읽도록 바꿨다.

PostgreSQL에서는 근삿값으로

PostgreSQL도 MVCC 때문에 같은 문제를 안고 있다. 트랜잭션마다 보이는 데이터가 다르니 행마다 가시성을 확인해야 한다. 9.2부터 인덱스 전용 스캔이 생겼다. 하지만 visibility map에 전부 보임으로 표시된 페이지가 많아야 제힘을 낸다.

게시판 목록 위의 “총 n건”을 손볼 때 처음으로 pg_class의 추정치를 꺼냈다. 수백만 행짜리 테이블을 페이지를 넘길 때마다 다시 세는 건 낭비였다.

SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE oid = 'public.posts'::regclass;

reltuples는 플래너가 쓰는 추정치이고 VACUUM, ANALYZE, 일부 DDL이 갱신한다. Laurenz Albe의 설명으로는 autovacuum과 autoanalyze가 이 값을 갱신하니 보통 10% 넘게 틀리지 않는다. 그런데 대량 이관 직후에 숫자가 이관 전 값에 그대로 머물러 있는 걸 보고 허탈했던 적이 있다. 그 뒤로는 큰 적재가 끝나면 ANALYZE를 직접 돌린다.

조건이 붙은 개수는 실행 계획의 예상 행 수로 구한다. PostgreSQL 위키에 실린 함수다.

CREATE OR REPLACE FUNCTION count_estimate(query text)
RETURNS integer LANGUAGE plpgsql AS $$
DECLARE
    plan jsonb;
BEGIN
    EXECUTE FORMAT('EXPLAIN (FORMAT JSON) %s', query) INTO plan;
    RETURN plan->0->'Plan'->'Plan Rows';
END;
$$;

SELECT count_estimate('SELECT 1 FROM posts WHERE board_id = 3');

받은 문자열을 그대로 실행하는 함수라 사용자 입력이 들어가면 SQL 인젝션 통로가 된다. 그래서 코드에 고정된 쿼리만 넘긴다.

지금 내가 COUNT를 쓰는 방식

행을 셀 때는 COUNT(*)만 쓴다. 어느 데이터베이스에서도 손해가 없고, 읽는 사람이 의도를 바로 안다. 남의 코드에서 COUNT(1)을 봐도 고치자는 말은 하지 않는다. 그 리뷰를 단 사람으로서 할 말이 별로 없다.

COUNT(컬럼)은 NULL을 빼고 세려는 의도일 때만 쓰고, 그럴 때는 주석을 한 줄 단다. 실수인지 의도인지 다음 사람이 고민하지 않게 하려는 것이다.

개수를 띄워 달라는 요청이 오면 먼저 정확해야 하는 숫자인지 묻는다. 정산이나 재고처럼 틀리면 안 되는 숫자는 조건을 좁혀 인덱스를 태운 다음 정확히 센다. 목록의 총 건수나 대시보드 숫자는 근삿값이나 미리 쌓아 둔 집계로 대개 충분하다. 속도를 정하는 건 괄호 안에 무엇을 적느냐가 아니라 몇 행을 읽게 만드느냐다. 그 리뷰를 단 뒤로 이걸 알기까지 꽤 오래 걸렸다.

참고한 자료