본문 바로가기
빌드노트 검색

빌드노트

PostgreSQL에 행은 있는데 count(column)은 0이다

PostgreSQL에서 count(col)이 0이어도 행은 있을 수 있다. NULL 두 행, 값·NULL·빈 문자열이 섞인 다섯 행, 빈 입력을 실제 비교해 count(*), count(col), count(DISTINCT col) 중 목록에 필요한 개수를 고른다.

PostgreSQL에서 목록의 총건수를 count(col)로 구하면, 행이 있어도 0이 나올 수 있다. 그 열의 값이 모두 NULL일 때다. 목록의 입력 행 수가 필요하면 count(*), 그 열이 NULL이 아닌 행 수가 필요하면 count(col)을 쓴다. 두 식은 같은 질문을 하는 것이 아니다.

공식 문서도 count(*)를 입력 행의 개수로, count(값)을 그 값이 NULL이 아닌 입력 행의 개수로 구분한다. 열 이름을 넣었다고 그 열이 속한 모든 행을 세지는 않는다. PostgreSQL 18 집계 함수 문서 (새 탭에서 열림)

두 행을 읽어도 값은 하나도 세지 않을 수 있다

다음은 실제 서비스 데이터가 아닌 합성값으로 실행한 비교다. PostgreSQL 18.6에서 확인했다. 직접 실행하려면 PostgreSQL에 SELECT를 실행할 수 있는 테스트 연결과 psql 또는 SQL 편집기가 필요하다. VALUES로 입력을 만들기 때문에 테이블을 생성하거나 데이터를 저장할 필요는 없다. 아래 출력은 열을 |로 구분한 psql 형식이다.

SELECT count(*) AS n_rows, count(col) AS n_values,
       count(DISTINCT col) AS n_distinct
FROM (VALUES (NULL::text), (NULL::text)) AS sample(col);
n_rows|n_values|n_distinct
2|0|0
(1 row)

입력은 두 행이다. count(*)는 두 행을 모두 센다. count(col)은 두 NULL을 제외해서 0이고, 중복을 제외하는 count(DISTINCT col)도 NULL이 아닌 값이 없어 0이다. count(col) = 0만 보고 “조건에 맞는 행이 없다”고 판단할 수 없다. 목록 건수에 이 값을 사용했다면 실제로 센 대상부터 바꿔야 한다.

빈 문자열은 세고, 같은 값도 다시 센다

이번에는 'a' 두 개, NULL 두 개, 빈 문자열 '' 하나를 넣었다. ::text는 입력 열을 문자열 타입으로 명시한다.

WITH sample(col) AS (
  VALUES ('a'::text), ('a'), (NULL), (''), (NULL)
)
SELECT count(*) AS n_rows, count(col) AS n_values,
       count(DISTINCT col) AS n_distinct
FROM sample;
n_rows|n_values|n_distinct
5|3|2
(1 row)

다섯 행 중 NULL 두 개만 count(col)에서 빠졌다. 'a'가 두 번 나와도 각각 세고, 빈 문자열도 하나의 NULL 아닌 값으로 세므로 3이다. 이 실험에서 빈 문자열에 IS NULL을 적용한 결과는 거짓이었고, 길이는 0이었다. 화면에서 비어 보이는 것과 SQL의 NULL은 구별해야 한다.

count(DISTINCT col)은 NULL을 제외한 뒤 서로 다른 값의 수를 센다. 여기서는 'a'와 '' 두 종류라서 2다. 이 의미는 집계 표현식 문서의 COUNT 비교 (새 탭에서 열림)에서도 확인할 수 있다. 전체 행 수, NULL 아닌 값의 수, 값의 종류 수가 각각 5, 3, 2로 갈라진 것이다.

따라서 count(col)을 “내용이 작성된 항목 수”로 해석하는 것도 조심해야 한다. NULL을 제외할 뿐 빈 문자열까지 제외하지는 않는다. 빈 문자열 두 개만 넣은 별도 실행에서도 결과는 2|2|1이었다. 빈 문자열을 미작성으로 볼지는 데이터 규칙으로 정하고, 필요하면 그 조건을 쿼리에 명시해야 한다.

빈 입력과 NULL뿐인 입력을 구별한다

앞의 혼합값 쿼리에서 마지막 줄만 다음과 같이 바꾸면, 다섯 행이 모두 걸러져 집계에 들어갈 행이 없어진다.

FROM sample WHERE false;

같은 세 COUNT의 출력은 다음과 같았다.

n_rows|n_values|n_distinct
0|0|0
(1 row)

이 조회에는 GROUP BY나 HAVING이 없다. 빈 입력을 집계한 결과 행 하나에 세 개의 0이 담겼다. 앞의 NULL 두 행은 2|0|0, 지금의 빈 입력은 0|0|0이다. 뒤의 두 숫자만 보면 같지만 count(*)를 함께 읽으면 행이 있는지 구별할 수 있다.

자기 목록을 확인할 때도 같은 FROM·WHERE 조건에서 count(*)와 문제의 count(col)을 나란히 조회한다. 앞의 혼합값처럼 5와 3으로 갈라지면, 두 건의 차이는 그 열이 NULL인 행이다. 전체 행 수가 목적이라면 첫 번째 값을 사용한다. NULL이 아닌 값이 있는 행만 세려는 목적이라면 두 번째 값이 맞다. 중복된 값도 각각 세어야 하는데 DISTINCT를 넣었다면 그것도 제거해야 한다.

두 COUNT가 같은데 화면 건수만 다르다면, NULL 제외만으로 그 차이를 설명할 수 없다. 실제 목록과 건수 쿼리가 같은 조건을 쓰는지, LIMIT·OFFSET으로 잘린 한 페이지의 행 수와 전체 건수를 비교하고 있지는 않은지 확인한다. 또한 count(*)는 집계에 들어온 행을 센다. 조인으로 한 항목이 여러 행이 되면 그 행들도 각각 세므로, 화면의 항목 수와 같다고 바로 단정할 수는 없다.

이번 실행은 PostgreSQL 18.6의 작은 text 입력에 대한 COUNT 비교다. 애플리케이션의 ORM 생성 SQL, 실제 페이지 처리와 조인, 실행 계획과 성능은 검증하지 않았다.

← 전체 글 보기