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

빌드노트

PostgreSQL에서 NULL 두 개를 UNION하면 뒤의 정수가 실패하는 이유

NULL UNION 1은 성공하는데 NULL UNION NULL UNION 1은 실패하는 이유를 중간 결과의 pg_typeof로 확인한다. 첫 NULL의 integer 캐스트, UNION ALL의 중복 처리, 괄호에 따른 타입 결정 차이를 PostgreSQL 18.6 실행 결과로 비교한다.

PostgreSQL에서 SELECT NULL UNION SELECT NULL UNION SELECT 1을 실행하면 text와 integer를 맞출 수 없다는 오류가 난다. 마지막에 정수가 있어도 앞의 NULL 두 개를 합치는 단계에서 결과 타입이 이미 text로 정해지기 때문이다. 정수 결과가 목적이라면 첫 NULL을 NULL::integer로 바꿔 가장 왼쪽 UNION부터 타입을 정한다.

NULL 하나를 더했을 뿐인데 왜 실패할까

PostgreSQL에 연결된 psql이나 SQL 편집기에서 다음 두 문장을 각각 실행해 비교할 수 있다. 테이블은 필요 없다. 아래는 별도로 만든 PostgreSQL 18.6의 새 연결에서 상수만 조회한 결과다.

첫 문장은 성공하고 두 번째 문장은 실패하는지 확인한다.

SELECT NULL UNION SELECT 1;
SELECT NULL UNION SELECT NULL UNION SELECT 1;

첫 문장은 1과 NULL, 두 행을 반환했다. 행 순서는 보장되지 않으므로 값과 행 수를 비교한다. 두 번째 문장은 결과 행 없이 다음 오류로 끝났다.

ERROR:  UNION types text and integer cannot be matched

여기서 text는 직접 쓴 문자열에서 나온 것이 아니다. PostgreSQL은 괄호 없는 연속 UNION을 왼쪽부터 두 입력씩 묶어 해석한다. 따라서 실패한 문장은 (SELECT NULL UNION SELECT NULL) UNION SELECT 1과 같은 묶음이다. 이는 결과 행을 물리적으로 왼쪽부터 출력한다는 뜻이 아니라, 타입을 맞추는 쿼리의 결합 방식이다. PostgreSQL 18의 집합 쿼리 결합 규칙 (새 탭에서 열림)

NULL 값과 중간 결과의 타입을 나눠 본다

오류를 이해하려면 원래 입력의 타입과 왼쪽 두 항을 합친 결과 타입을 각각 조회한다. pg_typeof는 전달한 식의 데이터 타입을 보여 주는 함수다. pg_typeof 설명 (새 탭에서 열림)

SELECT pg_typeof(NULL) AS null_type, pg_typeof(1) AS one_type;
SELECT pg_typeof(n) AS result_type
FROM (SELECT NULL UNION SELECT NULL) AS q(n);

첫 조회의 NULL은 unknown, 1은 integer였다. 두 번째 조회에서는 값이 여전히 NULL인데도 결과 열의 타입은 text로 나왔다.

 null_type | one_type
-----------+----------
 unknown   | integer
(1 row)

 result_type
-------------
 text
(1 row)

unknown은 이 입력에서 아직 구체적인 타입이 정해지지 않았다는 뜻이다. 모든 NULL의 타입이 항상 unknown이라는 뜻은 아니다. 위 조회처럼 NULL 값이 들어 있는 결과 열에도 text나 integer 타입이 붙을 수 있다.

PostgreSQL 18의 타입 결정 규칙 (새 탭에서 열림)에 따르면, 두 입력이 모두 unknown이면 해당 UNION의 결과는 text가 된다. 반면 unknown과 구체적인 타입이 함께 있으면 나머지 타입 결정 과정에서는 unknown을 제외하고, 정해진 타입으로 입력을 변환한다.

그래서 NULL UNION 1은 integer로 맞춰질 수 있다. 이 두 항의 결과에 pg_typeof를 적용한 별도 조회도 integer였다. 하지만 NULL UNION NULL UNION 1에서는 먼저 만들어진 text 결과와 integer가 다음 UNION에서 충돌한다. 마지막 1이 이미 끝난 왼쪽 묶음의 타입 결정을 되돌려 주지는 않는다.

첫 NULL에 결과로 원하는 타입을 쓴다

다음 조회는 첫 입력만 정수로 명시하고, 전체 결과의 값과 타입을 함께 확인한다. ORDER BY는 비교할 행 순서를 고정하기 위해 붙였다. 출력의 [NULL]은 SQL NULL을 보이게 한 표시이며 문자열 값이 아니다.

SELECT n, pg_typeof(n) AS result_type
FROM (
  SELECT NULL::integer
  UNION SELECT NULL
  UNION SELECT 1
) AS q(n)
ORDER BY n NULLS FIRST;
   n    | result_type
--------+-------------
 [NULL] | integer
      1 | integer
(2 rows)

이제 가장 왼쪽 UNION부터 정수 입력이 있으므로, 그 결과도 다음 UNION에 정수 타입으로 전달된다. 이 예제에서는 두 번째 NULL을 NULL::integer로 바꾼 대조도 성공했다. 꼭 첫 항만 가능한 것은 아니며, 처음 묶이는 두 항 중 적어도 하나에 의도한 타입이 있어야 한다. 첫 NULL에 적어 두면 뒤에 항이 추가돼도 쿼리 앞에서 결과 의도를 읽기 쉽다.

실패하는 전체 쿼리를 서브쿼리로 감싸고 바깥 SELECT에서 n::integer만 붙인 대조는 같은 오류가 났다. 바깥에서 값을 변환하기 전에 안쪽 UNION의 타입부터 맞아야 한다. 자신의 쿼리에서도 최종 결과를 감싸기보다 처음 NULL끼리 만나는 묶음을 찾아 수정한다. 여러 열을 합친다면 열마다 타입을 따로 결정하므로, 오류가 난 열의 같은 위치를 확인한다.

UNION ALL과 괄호는 무엇을 바꿀까

중복 제거가 원인인지 구분하려면, 원래 실패한 문장에서 UNION을 모두 UNION ALL로 바꿔 본다.

SELECT NULL UNION ALL SELECT NULL UNION ALL SELECT 1;

이 문장도 UNION types text and integer cannot be matched로 실패했다. NULL 두 개만 UNION ALL로 합친 결과의 타입도 text였다. ALL을 붙여도 결과 열의 타입을 맞추는 문제는 남는다.

타입을 정한 수정본에서만 ALL로 바꾸면 차이가 분명해진다. 앞서 성공한 조회의 두 UNION을 다음처럼 바꾸고 나머지 조회는 그대로 둔다.

SELECT n, pg_typeof(n) AS result_type
FROM (
  SELECT NULL::integer
  UNION ALL SELECT NULL
  UNION ALL SELECT 1
) AS q(n)
ORDER BY n NULLS FIRST;
   n    | result_type
--------+-------------
 [NULL] | integer
 [NULL] | integer
      1 | integer
(3 rows)

둘 다 integer지만 UNION은 NULL 두 행을 하나로 합쳤고, UNION ALL은 두 행을 모두 남겼다. 타입을 정한 뒤 중복 행을 보존할지에 따라 고른다. UNION과 UNION ALL의 중복 처리 (새 탭에서 열림)

괄호는 타입을 맞추는 묶음 자체를 바꾼다. 다음 조회로 정수와 만나는 쪽을 먼저 묶으면 캐스트 없이도 성공하는지 확인할 수 있다.

SELECT n, pg_typeof(n) AS result_type
FROM (
  SELECT NULL
  UNION (SELECT NULL UNION SELECT 1)
) AS q(n)
ORDER BY n NULLS FIRST;

실행 결과는 첫 캐스트 수정본과 같은 NULL·1 두 행, integer 타입이었다. 오른쪽 안쪽 묶음이 NULL UNION 1이어서 정수 타입이 되고, 바깥의 NULL도 그 타입에 맞춰진다. 왼쪽 NULL 두 항을 괄호로 묶은 대조는 원래 쿼리와 동일하게 실패했다. 오른쪽 괄호를 유지한 채 모두 UNION ALL로 바꾼 대조에서는 정수 타입의 세 행이 나왔다.

이 괄호 비교는 타입 결정의 원인을 확인하는 데 유용하다. 실제 쿼리에서 정수 결과를 의도한다면 첫 입력에 NULL::integer를 명시하는 수정부터 적용할 수 있다. 여기서 확인한 범위는 PostgreSQL 18.6의 상수 NULL과 정수 1 조합이다. 이미 text로 선언된 열이나 다른 타입의 값까지 이 캐스트 하나로 결합된다고 보장하지는 않는다. 수정 뒤에도 오류가 나면 같은 결과 열의 나머지 입력과 괄호 안 중간 결과 타입을 확인한다.

← 전체 글 보기