관계형 데이터베이스 실전 입문 - 07. NULL과의 싸움

강력한 데이터 모델인 관계형 모델이지만 SQL에서는 이를 아주 간단하게 파괴할 수 있는 것이 존재한다.

NULL이다.

1. NULL

RDB에서는 NULL을 피해야 한다고 알려졌다. 릴레이션에는 NULL이라는 개념이 존재하지 않기 때문이다.

NULL은 관계형 모델에는 존재하지 않고 SQL의 테이블에만 존재한다. NULL을 가진 테이블은 1NF의 요건을 만족하지 않는다.

NULL이란

NULL은 컬럼의 값을 모를 때 사용하는 마커(표시)로 값이 존재하지 않거나 값이 분명하지 않음을 나타낸다.

NULL이 들어간 컬럼값은 실제로도 존재하지 않으며 NULL은 값조차 없다. 어디까지나 마커다.

공집합과 NULL은 다르다. 공집합은 요소가 0개인 집합이며 존재하지 않는 집합인 NULL과 다르다.

한편 SQL에서 NULL인지 아닌지에 대한 판단은 IS NULL or IS NOT NULL로 한다. NULL은 값이 아니라서 값처럼 비교할 수 없다.

3치논리(3VL)

NULL의 폐해는 NULL이 될 수 있는 컬럼을 연산할 때 드러난다. 쿼리로 테이블에서 행을 구할 때 컬럼의 값을 비교하거나 연산으로 가공하는 경우가 많다.

NULL은 이러한 비교 연산에 큰 문제를 일으킨다. 컬럼의 값이 NULL이라는 것은 값을 알 수 없다는 것이다.

NULL은 연산을 망친다

다음 예시를 보자.

NULL + 1 , NULL > 100

NULL에 무엇을 더해도 대답은 NULL이다. 수치 연산뿐만 아니라 문자열의 조작도 마찬가지다.

비교 연산도 마찬가지로 NULL이다.

CONCAT('ABC', NULL) # NULL
CONCAT('ABC', NULL) LIKE 'ABC%' # NULL

검색 결과가 의도하지 않은 결과가 될 가능성

컬럼이 NULL이 되는 것을 의식하지 않고 특별한 조치를 하지 않으면 WHERE 절에서 NULL과의 비교가 이뤄진다. SELECT가 행을 반환하는 것은 WHERE 절의 조건이 TRUE가 됐을 때뿐이다.

NULL은 조건에 맞지 않다고 판단되어 행을 가져오지 않는다.

SELECT * FROM users WHERE age <> 20; # 나이가 20살이 아닌 모든 사용자

그러나 이 조건에는 나이가 NULL로 되어 있는 사람은 모두 해당되지 않는다. 실제로는 ‘나이를 알 수 있고 나이가 20살이 아닌 사람’ 이라는 조건이다.

만약 ‘나이가 20살이라고 알려진 사람을 제외한 모든 사람’ 이라는 조건이라면 다음과 같이 쿼리 작성이 필요하다.

SELECT * FROM user WHERE age <> 20 OR age 15 IS NULL;

NULL에 의한 제3의 논리값

컬럼에 NULL이 포함됐을 때(또는 NULL이 될 가능성이 있을 때)와 NULL일 때는 어떻게 대처해야 하는가? 라는 논리가 필요하다.

NULL을 특별 취급하지 않으면 안되는 이유는 NULL이 TRUE, FALSE도 아닌 제3의 존재이기 때문이다. SELECT가 결과를 반환하는 때는 WHERE 절의 조건이 TRUE가 될 때 뿐이다. FALSE 또는 NULL은 조건을 만족하지 않는다고 판단하게 된다.

NULL은 값이 존재하지 않음을 나타내는 마커다.

→ NULL이 포함된 식은 NULL이 되어버리며, NULL이 있는 덕분에 마치 논리값이 3개가 존재하는 것처럼 존재

이처럼 TRUE, FALSE, Unknown의 세 가지 논리값에 의해서 판정이 이뤄지는 논리 시스템을 3치 논리(3VL)이라고 한다.

생각보다 성가신 3VL

NULL의 가장 큰 문제는 3VL을 다루지 않으면 안 된다는 것이다. 3VL은 일반적으로 2VL(T, F)보다 복잡하다.

3VL은 수치 연산이나 문자열 조작뿐만 아니라 AND나 OR와 같은 논리 연산에도 적용된다. 어떤 평가 방식에서 그 요소에 하나라도 NULL이 되는 컬럼이 있다면 식 전체가 3VL이 된다.

3치 논리의 한계

3VL은 2VL보다 복잡하지만 논리학적으로는 잘못된 것은 없다. 그러나 SQL에서 3VL을 사용하는 게 문제가 되는 이유는 3VL이 현실을 제대로 표현할 수 없기 때문이다.

관계형 모델은 현실 세계를 잘 표현하기 위한 모델이다. 3VL의 도입 때문에 모델의 의미를 잃어버리고 원점으로 돌아가게 된다.

Unknown과 모호함

정보가 부자연스러운 Unknown이라는 값을 이용한 쿼리는 연산을 거듭할수록 버려진 정보로 인해서 오차는 더욱 커지게 된다. 3VL이 논리적으로는 올바르더라도 값이 정확하지 않으므로 쿼리의 결과가 아무 의미 없게 된다.

NULL은 폐쇄 세계 가정에 반한다.

NULL을 사용하면 안 되는 가장 큰 이유는 관계형 모델을 근본적으로 뒤집는 존재이기 때문이다. 관계형 모델은 폐쇄 세계 가정이라는 가설 위에 이뤄져 있다. 이 가설 덕분에 판명된 사실은 릴레이션에 포함돼 있으며, 릴레이션끼리 조합해 연산한 결과 릴레이션도 사실 전부 딱 맞게 포함된다.

따라서 모든 질의가 릴레이션의 연산만으로 해결이 가능하다.

그런데 NULL은 이 전제를 뒤집는다. NULL은 현시점에서는 알 수 없는 값이다. 두 개의 테이블을 결합할 때 3VL에 정의해서 기계적으로 연산하면 키가 NULL인 경우에 그 행은 결과에 포함되지 않을 것이다.

하지만 의미적으로는 정확하지 않다. NULL은 현시점에서는 알려지지 않은 사실이지만 일치할수도 있고 아닐 수도 있기 때문이다.

원래 관계형 모델의 기반이 되는 술어 논리는 2VL이다. 3VL을 적용하면 술어논리나 집합론의 다양한 법칙을 적용할 수 없게 된다. NULL은 관계형 모델을 근본적으로 파괴하는 것이다.

옵티마이저에 대한 폐해

NULL의 존재가 나쁜 영향을 미치는 것은 관계형 모델이라는 논리적인 측면뿐만 아니라 쿼리의 실행 계획인 옵티마이저의 구현에도 큰 악영향이 있다.

옵티마이저의 가장 큰 역할은 쿼리의 실행이 최적의 성능을 내도록 바꿔주는 것이다. 옵티마이저에 의한 내부적인 쿼리의 재작성은 당연하지만 쿼리의 결과가 등가가 되게 수학적으로 증명할 수 있는 조합 중의 하나를 선택하지 않으면 안된다. 쿼리의 성능을 개선해도 결과가 다르면 의미가 없다.

그런데 NULL이 존재하는 경우 등가가 되면 수학적으로 증명할 수 있는 조합은 많이 줄어든다. 그 결과 옵티마이저는 별다른 일도 못하고 사람이 노력해서 쿼리를 튜닝해야 한다.

NULL이 옵티마이저의 판단을 둔하게 하는 것은 쿼리의 재작성뿐만 아니다. 쿼리의 비용계산에도 영향이 이싿. NULL이 있는 인덱스 항목은 인덱스 상에도 그 인덱스의 제일 앞 또는 제일 뒤에 몰아서 배치된다.

IS NULL을 해결하려면 인덱스의 제일 앞부분이나 제일 뒷부분을 스캔해야 한다. 어느 정도 NULL이 아닌 값이 드문드문 분산돼도 인덱스 상의 컬럼이 NULL인 모든 행은 같이 취급된다.

행이 늘어나면 늘어날수록 IS NULL을 스캔하는 시간이 걸린다.

2. NULL의 대책

테이블을 정규화한다

NULL을 제거하는 가장 전통적인 방법은 테이블을 적절하게 정규화하는 것이다. 테이블이 1NF의 요건을 만족하려면 NULL이 포함돼서는 안된다.

컬럼의 값이 NULL인 것은 응용프로그램에서 아직 그 데이터가 필요하지 않다는 뜻이다. 또한, 다른 기능을 요구하는 데이터를 같은 테이블에 저장하고 기능마다 별도의 데이터가 필요할지도 모른다.

테이블 나누기 전

이름 학년 클럽
AAA 1 검도부
BBB 2 유도부
CCC 4 NULL
DDD 3 NULL
EEE 3 유도부

테이블 나누기

이름 학년 이름:클럽 클럽
AAA 1 AAA 검도부
BBB 2 BBB 유도부
CCC 4 EEE 유도부
DDD 3
EEE 3

잘못된 NULL 대책

자주 하는 실수 중의 하나가 컬럼을 NOT NULL을 정의하는 대신에 NULL과 같은 의미가 있는 값을 정의하는 것이다. 기본값으로 NULL의 의미를 가지게 만든다.

CREATE TABLE table_name(...
	age INT NOT NULL DEFAULT -1
...);

언뜻 보면 문제가 없는 전제라고 생각할 수 있지만 함정이 있다.

SELECT * FROM t WHERE age <> 20; # 기본값에 따라 영향이 없음
SELECT * FROM t WHERE age < 20; # 기본값에 따라 영향 받음

여기서 -1이라는 특별한 값을 적용한 것은 IS NULL을 사용해 NULL인지 아닌지를 판단하는 것과 본질적으로 의미는 같다.

이 같은 로컬 규칙을 사용하는 경우는 문서 등으로 사용자에게 알려주지 않으면 실수의 여지가 될 수 있다.

NULL을 피하고자 편의상 NULL이 아닌 NULL과 같은 의미가 있는 기본값을 사용하는 것은 테이블 설계의 질을 향상시키는 커녕 상황을 악화시킬 뿐이다.

COALESCE 함수

정규화를 해 모든 컬럼을 NOT NULL로 하는 것도 중요하지만, 귀찮은 일이며 NULL의 발생을 완전히 막을 순 없다. SQL에 포함된 식을 평가한 결과 NULL이 될 수도 있기 때문이다. 다음 예시를 보면 된다.

  • 행의 개수가 0개인 행의 SUM이나 AVG와 같은 집계함수를 실행했을 때(COUNT 제외)
  • 스칼라 또는 행 서브 쿼리를 실행한 결과 일치하는 행이 없을 때
  • OUTER JOIN 실행 시에 일치하는 행이 없을 때
  • CASE 식에서 ELSE 문이 없고, 어떤 조건에도 해당하지 않을 때
  • NULLIF 식을 평가한 결과 NULL이 됐을 때

SQL에서 바른 결과를 얻으려면 이 경우에 생기는 NULL에 대해서도 적절히 처리하도록 로직을 생각해야 한다.

NULL이 발생할 수 있는 곳을 알 수 있다면 그 대첵으로 사용할 수 있는 함수가 COALESCE 함수다. COALESCE 함수는 인수 중에 가장 처음에 나오는 NULL이 아닌 것을 반환한다.

SELECT continent, COALESCE(SUM(population), 0)
FROM countries GROUP BY continent;

COALESCE 함수는 식을 평가한 결과가 NULL일 경우에 기본값을 설정할 때 편리하다. COALESCE 함수의 사용법은 다이나믹 디폴트라고 한다. 어떤 기본값이 최적인지는 식의 의미에 달려있다.

위의 예제는 SUM이므로 기본값은 0으로 한다. SUM 함수는 테이블이 비어있거나 모든 행이 NULL일 때는 NULL이 된다.

한편 NULL을 가진 컬럼에 대해 COALESCE 함수로 기본값을 정의하는 방식의 사용법은 추천하지 않는다. 단순히 컬럼의 기본값을 정의하는 것뿐이라면 앞서 잘못된 NULL처리 방식과 다르지 않는다. COALSECE 함수는 SQL의 구조상 어쩔 수 없이 NULL이 될 가능성이 있는 집계함수나 스칼라 서브 쿼리의 기본값으로 이용할 때 의미가 있다.

IFNULL 함수도 있지만 SQL 표준이 아니므로 호환성을 생각하면 COALSECE 함수를 쓰자.

또한, LEFT(RIGHT) JOIN으로 ‘일치하지 않는 행을 찾기’의 용도로는 여전히 IS NULL을 사용한 평가가 필요하다.

반대로 NULL을 만드는 NULLIF 함수가 있는데 절대로 사용하지 말 것. 관계형 모델에서 일부러 NULL을 만들 필요가 없다.

빈 문자열의 처리

빈 문자열은 길이가 0일 뿐 실재하는 문자열로 간주한다. 빈 문자열과 NULL은 본래 명확하게 구별해야 한다.

불행하게도 그 대응 수단은 없다.

NULL을 사용해도 좋을 때

사실 DB에서 NULL을 완전히 제거하는 것은 불가능하며 그렇게 해서는 안된다. 현실의 세계를 관계형 모델만으로 표현하는 것은 불가능하므로 SQL의 이러한 성질을 SQL을 모든 상황에 대응할 수 있는 언어로 만들었다.

NULL은 관계형 모델의 근본을 뒤집어 버리지만 어디까지나 관계형 모델에 따라서 테이블을 사용하는 경우에 해당한다.

요약

NULL은 관계형 모델을 파괴하고, 옵티마이저에도 악영향을 미친다. 그러나 RDB(SQL)에서는 NULL을 완전히 배제할 수 없다. SQL은 관계형 모델을 넘어서는 표현력이 있기 때문이다.


Written by@Sunny Son
개발자는 오늘도 뚠뚠

GitHubFacebook