사일런트 킬러: NULL이 데이터베이스 성능에 미치는 영향 이해하기
James Reed
Infrastructure Engineer · Leapcell

서론
데이터베이스의 세계에서 "NULL"이라는 개념은 어디에나 존재합니다. 이는 값의 부재, 알 수 없음, 또는 적용 불가능한 상태를 나타냅니다. 겉보기에는 무해해 보이지만, 개발자들은 종종 NULL의 더 깊은 함의를 파악하는 데 실패합니다. 그것은 단순한 데이터 포인트 그 이상이며, 그 고유한 특성은 데이터베이스 엔진이 쿼리를 최적화하고 실행하는 방식을 근본적으로 바꿀 수 있습니다. 특히, NULL 값은 인덱싱, COUNT()와 같은 집계 함수, 관계형 JOIN 작업과 같은 중요한 데이터베이스 작업의 효율성에 큰 영향을 미칠 수 있습니다. 이러한 효과를 이해하는 것은 성능이 뛰어나고 견고한 데이터베이스 애플리케이션을 작성하는 데 중요합니다. 이 글에서는 NULL이 이러한 핵심 영역에 어떻게 조용히 영향을 미치는지 자세히 살펴보고, 종종 과소평가되는 그 힘에 대한 더 명확한 그림을 제공합니다.
핵심 개념
세부 사항으로 들어가기 전에, 우리의 논의에 중심이 되는 몇 가지 핵심 개념을 간략하게 정의해 보겠습니다.
- NULL: SQL에서 NULL은 모든 데이터 값의 부재를 나타냅니다. 0, 빈 문자열, 또는 false와 동등하지 않습니다. 누락되거나 알 수 없는 데이터를 나타내는 특수 표시입니다.
 - 인덱스: 데이터베이스 인덱스는 데이터베이스 테이블에 대한 데이터 검색 작업의 속도를 향상시키는 데이터 구조로, 일반적으로 B-트리 또는 B+ 트리입니다. 이를 통해 데이터베이스 시스템은 전체 테이블을 스캔하지 않고 쿼리 기준과 일치하는 행을 빠르게 찾을 수 있습니다.
 - COUNT(): 그룹 내 항목 수를 반환하는 집계 함수입니다. 
COUNT(*)는 모든 행을 계산하고,COUNT(column_name)은 지정된 열의 NULL이 아닌 값을 계산합니다. - JOIN: 두 개 이상의 테이블 간의 관련 열을 기반으로 행을 결합하는 데 사용되는 SQL 절입니다. 일반적인 유형에는 
INNER JOIN,LEFT JOIN,RIGHT JOIN,FULL OUTER JOIN이 있습니다. - 카디널리티: 열에 있는 고유 값의 수입니다. 높은 카디널리티는 많은 고유 값을 의미하고, 낮은 카디널리티는 적은 고유 값을 의미합니다.
 - 선택도: 고유 값 대 총 행 수의 비율입니다. 높은 선택도는 열 값이 필터링에 좋다는 것을 의미합니다.
 
NULL과 인덱스
NULL 값과 인덱스의 상호 작용은 성능에 영향을 미칠 수 있는 가장 중요한 영역 중 하나입니다. 대부분의 데이터베이스 시스템은 인덱스를 구축하고 탐색할 때 NULL을 특별하게 처리합니다.
인덱스가 NULL을 처리하는 방법:
일반적으로 가장 일반적인 유형인 B-트리 인덱스는 NULL을 허용하는 열에 대해 NULL 값을 리프 노드에 명시적으로 저장하지 않습니다. 이는 NULL이 다른 값과의 내재적 순서가 없으며, 포함하면 인덱스 구조와 탐색 논리가 복잡해지기 때문입니다. 그러나 정확한 동작은 다를 수 있습니다:
- 대부분의 데이터베이스 (예: MySQL, PostgreSQL, SQL Server): 기본적으로 단일 열 B-트리 인덱스는 인덱싱된 열이 NULL인 행을 일반적으로 포함하지 않습니다. 이는 
SELECT * FROM my_table WHERE my_column IS NULL과 같은 쿼리가my_column에 인덱스가 있더라도 일반적으로 전체 테이블 스캔으로 이어진다는 것을 의미합니다. 인덱스가 NULL 항목을 포함하지 않기 때문에 사용되지 않습니다. - 복합 인덱스: 복합 (다중 열) 인덱스에서 주어진 행에 대해 인덱스의 열 중 하나라도 NULL이면 해당 행이 인덱스에 포함되지 않거나 특별한 방식으로 저장될 수 있습니다. 예를 들어, MySQL에서는 복합 인덱스의 모든 열이 NOT NULL이면 해당 행이 인덱싱됩니다. 하나라도 NULL이면 해당 행은 전체 인덱스에 나타나지 않을 수 있습니다.
 - 특수 NULL 처리: 일부 데이터베이스는 NULL을 인덱싱하는 특정 방법을 제공합니다. 예를 들어, PostgreSQL은 "부분 인덱스" 또는 인덱스에 표현식 사용 (
CREATE INDEX ON my_table ((my_column IS NULL)))을 허용하여 NULL이 인덱싱되도록 강제할 수 있습니다. SQL Server의 필터링된 인덱스도WHERE절 (CREATE INDEX ix ON MyTable (Col1) WHERE Col1 IS NOT NULL)을 포함하여 이를 달성할 수 있습니다. 
예시: 인덱스 사용에 미치는 영향
delivery_date에 인덱스가 있는 orders 테이블을 고려해 보겠습니다.
CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_id INT, order_date DATE NOT NULL, delivery_date DATE -- 아직 배송되지 않은 경우 NULL일 수 있음 ); CREATE INDEX idx_delivery_date ON orders (delivery_date); -- 쿼리 1: 효율적이며 인덱스를 사용합니다. EXPLAIN ANALYZE SELECT * FROM orders WHERE delivery_date = '2023-10-26'; -- 쿼리 2: DB에 따라 전체 테이블 스캔으로 이어질 수 있습니다. -- NULL이 일반적으로 인덱싱되지 않기 때문입니다. EXPLAIN ANALYZE SELECT * FROM orders WHERE delivery_date IS NULL; -- 쿼리 3: 복합 인덱스 예시 사용 CREATE INDEX idx_customer_delivery ON orders (customer_id, delivery_date); -- 이 쿼리는 customer_id에 대해 복합 인덱스를 사용할 수 있지만, DB가 복합 인덱스에서 NULL을 인덱싱하지 않는 경우 -- NULL delivery_date에 대한 필터링으로 돌아갈 수 있습니다. EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 123 AND delivery_date IS NULL;
실질적인 함의:
- 비효율적인 
IS NULL쿼리:IS NULL을 필터링하는 쿼리는 종종 표준 B-트리 인덱스를 우회하여 전체 테이블 스캔과 좋지 않은 성능으로 이어집니다. 특히 대규모 테이블에서 그렇습니다. - 인덱스 크기 및 오버헤드: NULL이 많은 열이지만 인덱스의 일부인 경우, 해당 인덱스는 여전히 불필요하게 크거나 많은 중요한 쿼리에 
IS NULL조건이 포함된 경우 덜 효과적일 수 있습니다. - 솔루션:
- 부분/필터링된 인덱스: 데이터베이스가 지원하는 경우, NULL 값에 대한 인덱스 (
CREATE INDEX idx_null_delivery ON orders ((delivery_date IS NULL))) 또는 NULL이 아닌 값에 대한 인덱스 (CREATE INDEX idx_not_null_delivery ON orders (delivery_date) WHERE delivery_date IS NOT NULL)를 생성합니다. - 기본값: 가능한 경우 의미상 올바르다면, 기본 NULL이 아닌 값을 할당합니다 (예: 배송되지 않은 경우 특정 날짜 
9999-12-31, 또는 정수 필드의 경우0). 그러나 이는 애플리케이션 논리를 복잡하게 만들 수 있습니다. - 별도 테이블: NULL 비율이 매우 높고 쿼리 패턴이 다른 열의 경우, 테이블을 분할하거나 희소 열 접근 방식을 사용하는 것을 고려할 수 있지만, 이는 복잡성을 더합니다.
 
 - 부분/필터링된 인덱스: 데이터베이스가 지원하는 경우, NULL 값에 대한 인덱스 (
 
NULL과 COUNT() 성능
COUNT() 집계 함수는 COUNT(*)인지 COUNT(column_name)인지에 따라 다르게 동작하며, 이 구분은 NULL 값과 직접적으로 관련이 있습니다.
COUNT(*): 이는 아무 열에 NULL이 포함되어 있는지 여부에 관계없이 결과 집합의 모든 행을 계산합니다. 많은 데이터베이스 시스템이 기본 키 인덱스 (항상 NOT NULL)를 사용하거나 메타데이터에서 행 수를 가져와 최적화할 수 있으므로 일반적으로 가장 효율적인COUNT()형태입니다.COUNT(column_name): 이 형태는column_name이 NULL이 아닌 행만 계산합니다. 지정된 열이 NULL을 허용하고 NULL 값이 많은 경우,COUNT(column_name)은 각 행에 대해 NULL 유무를 추가로 확인해야 하므로COUNT(*)보다 느릴 수 있습니다. 해당 열이 인덱싱되어 있고 NOT NULL이면,COUNT(column_name)도 매우 빠를 수 있습니다.
예시: COUNT() 동작
SELECT COUNT(*) FROM orders; -- 모든 행을 계산합니다. SELECT COUNT(delivery_date) FROM orders; -- delivery_date가 NULL이 아닌 행을 계산합니다. -- 100,000개의 주문이 있고, 50,000개가 NULL delivery_date를 가지고 있다고 가정해 봅시다. -- COUNT(*)는 빠르게 100,000을 반환할 것입니다. -- COUNT(delivery_date)는 50,000을 반환할 것이며, 각 행에 대해 delivery_date 열을 검사하거나 -- NULL이 제외된 인덱스를 활용해야 하므로 느릴 수 있습니다.
성능 함의:
COUNT(*)대COUNT(column_name): 총 행 수가 필요한 경우, 성능상의 이유로 항상COUNT(*)를 선호하십시오. 열의 NULL이 아닌 값 수를 구체적으로 계산해야 하는 경우COUNT(column_name)이 적절하지만,COUNT(*)와 비교하여 잠재적인 성능 특성을 인지하십시오.COUNT(column_name)에 대한 인덱스 영향:column_name이 인덱싱되어 있고 NOT NULL이면, 옵티마이저는 인덱스를 사용하여 항목 수를 계산할 수 있으므로COUNT(column_name)이 매우 빠를 수 있습니다. 그러나column_name이 NULL을 허용하는 경우, 해당 인덱스가 완전히 활용되지 않거나 데이터베이스가 각 개수에 대해 NULL을 확인하기 위해 실제 행을 스캔해야 할 수 있습니다.
실질적인 함의:
COUNT(*)전략적 선택: 열의 NULL이 아닌 값에 대해 신경 쓰는 경우보다 총 행 수를 원할 때COUNT(*)를 사용하십시오.- NOT NULL 제약 조건 고려: 열이 절대로 NULL이어서는 안 되는 경우, 
NOT NULL제약 조건을 강제하십시오. 이는 데이터 무결성을 향상시킬 뿐만 아니라 데이터베이스가 집계 개수 및 인덱스 활용에 대해 더 나은 가정을 할 수 있도록 합니다. 
NULL과 JOIN 성능
SQL의 3가지 값 논리(TRUE, FALSE, UNKNOWN)로 인해 NULL 값은 JOIN 작업에 특이한 영향을 미칩니다. 값과 NULL을 비교할 때, 결과는 항상 UNKNOWN입니다.
- 
INNER JOIN: 행은 두 테이블 모두에 일치하는 항목이 있을 때만 반환됩니다. 어느 테이블의 조인 열에 NULL이 포함되어 있으면, 다른 NULL이나 NULL이 아닌 값과 결코 일치하지 않으며, 다른 NULL과 조인되지도 않습니다. 따라서 조인 열에 NULL이 있는 행은INNER JOIN결과에서 제외됩니다. 이는 일반적으로 성능이 좋습니다. 왜냐하면 일치시켜야 할 행 수가 적기 때문입니다.SELECT o.order_id, c.customer_name FROM orders o INNER JOIN customers c ON o.customer_id = c.customer_id; -- 주문에 대해 o.customer_id가 NULL이면 해당 주문은 여기에 나타나지 않습니다. - 
LEFT JOIN(및RIGHT JOIN):LEFT JOIN은 왼쪽 테이블의 모든 행을 반환하고, 오른쪽 테이블의 일치하는 행을 반환합니다. 왼쪽 테이블의 행에 대한 일치 항목이 없으면 오른쪽 테이블의 열에 NULL이 포함됩니다. 오른쪽 테이블의 조인 열이 NULL이면 아무것도 일치하지 않지만, 왼쪽 테이블의 행은 여전히 포함됩니다. 왼쪽 테이블의 조인 열이 NULL이면, 해당 값에 대한INNER JOIN과 유사하게 작동하여, 오른쪽 테이블의 NULL이 아닌 값과 일치하는 항목을 찾지 못하므로 오른쪽 테이블의 열에 대해 NULL이 발생합니다.SELECT o.order_id, c.customer_name FROM orders o LEFT JOIN customers c ON o.customer_id = c.customer_id; -- 주문에 대해 o.customer_id가 NULL이면 해당 주문은 여전히 나타납니다. -- c.customer_name은 해당 주문에 대해 NULL이 될 것입니다. -- 고객에 대해 c.customer_id가 NULL이면 해당 고객은 주문과 절대 조인되지 않습니다. - 
FULL OUTER JOIN: 두 테이블 중 하나에 일치 항목이 있을 때 모든 행을 반환합니다. 일부 행에 일치하는 항목이 없으면 일치하는 테이블이 없는 쪽에서 NULL이 발생합니다. 다시 말하지만, 조인 열 자체의 NULL은 일치를 생성하지 않습니다. 
성능 함의:
- NULL과의 동등성 없음: 
NULL = NULL이라는 기본 규칙은 FALSE (또는 SQL의 3가지 값 논리에서 정확히 UNKNOWN)입니다. 이는 표준 동등 연산자를 사용하여 NULL 값으로 조인할 수 없음을 의미합니다. 조인 열에 NULL이 많고 이를 조인하려고 하는 경우 (예: "둘 다 알 수 없을 때 조인"), 특별한 처리가 필요합니다. - 조인 조건 복잡성: 
IS NULL시나리오를 처리하기 위해OR절을 포함하는JOIN조건 (예:ON a.col = b.col OR (a.col IS NULL AND b.col IS NULL))이 있는 경우, 이러한 조건은 일반적으로 옵티마이저가 인덱스를 효율적으로 사용하는 것이 훨씬 어렵습니다. 이는 해시 조인이나 더 큰 부분을 스캔하는 중첩 루프 조인과 같은 덜 최적화된 조인 계획으로 이어질 수 있습니다. - 인덱스 미사용: 
a.col = b.col과 같은 조인 조건이 자주 NULL을 포함하는 열과 관련되어 있고, 선택한 인덱스가 NULL을 제대로 처리하지 않는 경우 (앞서 논의한 대로), 옵티마이저는 모든 관련 키를 포함하지 않을 것이라는 것을 알기 때문에 인덱스를 사용하지 않기로 선택할 수 있습니다. 
예시: NULL과 조인
employees와 departments 테이블을 조인한다고 가정해 보겠습니다.
CREATE TABLE employees ( emp_id INT PRIMARY KEY, emp_name VARCHAR(50), dept_id INT -- 할당되지 않은 직원의 경우 NULL일 수 있음 ); CREATE TABLE departments ( dept_id INT PRIMARY KEY, dept_name VARCHAR(50) ); -- 'John Doe' 직원이 emp.dept_id = NULL이라고 가정합니다. -- 'R&D' 부서가 dept_id = 1이라고 가정합니다. -- 또 다른 직원 'Jane Smith'가 emp.dept_id = 1이라고 가정합니다. -- INNER JOIN은 John Doe를 제외합니다: SELECT e.emp_name, d.dept_name FROM employees e INNER JOIN departments d ON e.dept_id = d.dept_id; -- 결과: Jane Smith, R&D -- LEFT JOIN은 John Doe를 포함하며, 부서는 NULL입니다: SELECT e.emp_name, d.dept_name FROM employees e LEFT JOIN departments d ON e.dept_id = d.dept_id; -- 결과: Jane Smith, R&D -- John Doe, NULL -- NULL 조인 시도 (동등성을 통해 예상대로 작동하지 않음): -- 이것은 다른 테이블에 조인할 NULL이 있는 경우에도 'John Doe'의 NULL dept_id를 일치시키지 않습니다. -- 예를 들어, 'departments'에 NULL인 'unassigned_dept_id' 열이 있는 경우. -- 다음은 양쪽이 NULL일 때 절대로 일치하지 않습니다: SELECT e.emp_name, d.dept_name FROM employees e INNER JOIN departments d ON e.dept_id = d.dept_id OR (e.dept_id IS NULL AND d.dept_id IS NULL); -- 이 `OR` 조건은 종종 전체 스캔/비용이 많이 드는 조인을 강제합니다.
실질적인 함의:
- JOIN에서 NULL 동작 이해: 
NULL = NULL이 참이 아니라는 점을 명확히 인지하십시오. 이는 종종 개발자들에게 예상치 못한 결과를 안겨줍니다. - 복잡한 JOIN 조건 피하기: 가능하다면, 
IS NULL로직을 포함하는JOIN조건을 피하도록 데이터를 구조화하거나 미리 처리하십시오. NULL을 포함한OR조건은 조인 성능 및 인덱스 활용에 매우 해로울 수 있습니다. - 데이터 정리: 조인 열의 NULL이 실제로 알 수 없거나 적용 불가능한 상태를 나타내는 경우, 조인하기 전에 해당 행을 필터링하거나 
LEFT JOIN으로 명시적으로 처리하는 것이 더 나을 수 있습니다. NULL이 특정 의미 (예: "기본 부서")를 함축하는 경우, JOIN을 용이하게 하기 위해 이를 잘 정의된 NULL이 아닌 값으로 대체하는 것을 고려하십시오. 
결론
데이터베이스의 NULL은 빈자리 그 이상이며, SQL의 데이터 모델의 근본적인 측면입니다. 이는 성능에 깊은 영향을 미칩니다. 인덱스의 경우, NULL은 종종 제외되어 IS NULL 쿼리에 대해 전체 테이블 스캔을 강제합니다. COUNT()의 경우, NULL 제외로 인해 COUNT(column_name)보다 COUNT(*)가 효율성 면에서 빛을 발합니다. JOIN 작업에서 NULL은 고유한 문제를 제기하며, 표준 동등 비교를 거부하여 조인 조건과 인덱스 사용을 복잡하게 만들 수 있습니다. 이러한 동작을 이해함으로써 개발자는 더 효율적인 스키마를 설계하고, 더 똑똑한 쿼리를 작성하며, 궁극적으로 더 성능이 뛰어난 데이터베이스 애플리케이션을 구축할 수 있습니다. NULL을 성능의 사일런트 킬러로 취급하는 것이 데이터베이스 최적화의 열쇠입니다.

