Null
사전에서 찾아보면 '아무런 가치가 없는 것'을 뜻하는 형용사다. 한글로는 발음대로 '널'이라고 쓰며, 그냥 한 글자만 쓰기 뭣하면 '널값'이라고 쓰기도 한다.
컴퓨터 프로그래밍에서 자주 만나볼 수 있는 값이자, 온갖 버그를 일으키는 만악의 근원으로 찍혀 있는 값이기도 하다.
0과는 다르다. 0이라는 것은 정수든 부동소수점이든 0이라는 '값'을 뜻하지만 Null은 그냥 아무 값이 없는 값을 뜻한다.
비슷한 뜻을 가진 명사인 'nil'을 사용하는 언어도 있다. Go, Lua, LISP 같은 언어들이 nil을 사용한다. 파이썬은 None을 사용한다.
굉장히 남용하기 쉽다. 예를 들어 어떤 함수가 포인터 혹은 객체의 결과값을 돌려줘야 하는데 내부에서 오류가 일어났다든가, 처리에 실패했다든가 해서 돌려줄 값이 없다면? 그냥 편하게 null을 돌려주면 된다. 객체 유형의 변수를 정의했는데 실제 변수에 들어가는 값은 이후 처리를 통해 만들어진다. 그럼 변수 초깃값으로 뭘 주지? 그냥 null을 넣자. 이래저래 뭐 넣거나 돌려줄 게 마땅치 않으면 속 편하게 null로 해결하면 된다.
관계형 데이터베이스에도 null 값이 쓰인다. 필드에 들어갈 값이 없거나 불분명한 경우 null을 사용할 수 있다. 당연히 SQL에도 null을 쓸 수 있다.
10억 달러짜리 실수
프로그래밍 언어에서 너무 남용하다 싶을 정도로 많이 쓰는 게 null 값이지만 수많은 버그의 원흉이기도 하다. 오죽하면 알골 언어를 디자인하면서 널 값을 처음으로 고안했던 Null의 아버지 토니 호어가 이를 두고 'The Billion Dollar Mistake(10억 달러짜리 실수)'라고 할 정도다. 알골을 선언할 때 아직 변수에 들어갈 값이 없는 상황을 좀 쉽게 해결하자는 마음에 널 참조를 허용했는데 이 때문에 수십 년 동안 일어난 문제가 'billion dollar' 수준으로 손해가 될 만큼 엄청나다는 것을 뜻하는 말.
문제는 주로 이 값이 들어가 있는 변수를 사용하는 쪽에서 종종 벌어진다. 예를 들어 함수를 사용하는 쪽에서 함수가 null 값을 돌려줬는지 제대로 체크하지 않고 값을 쓰면 오류가 터진다. 변수 역시도 null 상태인 녀석을 그냥 썼다가 역시 오류가 터지는데, C처럼 포인터의 자유도가 높은 언어라면 그야말로 무슨 일이 터질지 모르는 시한폭탄을 들고 있는 것이나 마찬가지다.[1] 객체지향 언어로 프로그래밍하는데 클래스 객체 변수를 선언만 해 놓고 초깃값을 null로 했는데 코드의 다른 곳에서 아직 null 상태인 객체의 메서드나 필드를 부르면? 이 역시 오류 확정이다. 자바 프로그래머라면 아마도 런타임에서 NullPointerException 예외를 지긋지긋하게 볼 것이고 다른 언어도 이와 비슷한 null 참조 오류 때문에 디버깅 하느라 머리가 깨진다. 프로그래머가 아닌 사용자도 잘 쓰던 프로그램이 갑자기 오류를 토해내면서 다운되는 바람에 작업을 날려먹는 일이 있을 텐데 오류 메시지를 살펴보면 대부분 null 참조 문제다.
Null 참조 문제를 컴파일 단계에서 잡아내면 좋지만 대부분 컴파일은 그냥 되고 런타임에서 오류가 터진다. Null 여부를 꼼꼼하게 처리하면 되는 거잖아, 하고 생각할 수 있지만 프로그램의 덩치가 커질수록 그렇게 꼼꼼하게 체크하는 게 쉬운 일도 아니며, 함수 하나 쓸 때마다, 변수 하나 쓸 때마다 일일이 if 문으로 null 여부를 확인하는 것도 쓸데없는 코드가 너무 많아진다. 특히 남이 만든 함수나 라이브러리를 사용할 때 문서화가 제대로 안 되어 있으면 멋모르고 썼다가 버그와 오류가 발생한다. 그래서 null 값을 고안했던 토니 호어가 'The Billion Dollar Mistake'라고 한 것이다.
널 안전성
가장 확실한 해결책은 null 값 자체를 못 쓰게 만드는 것이지만 그게 또 쉬운 문제는 아니다. 말 그대로 '값 없는 값'이 필요할 때가 은근 많기 때문이다. 앞서 언급한 것처럼 결과값을 돌려줘야 할 함수가 오류가 났다던가 해서 돌려줄 값이 없을 경우, 할 수 있는 방법은 두 가지다. Null 값을 돌려주거나 아니면 예외를 일으키거나. 그런데 예외는 꽤 비용이 드는 문제다. 함수를 쓰는 쪽에서 예외 처리를 하려면 상당한 양의 코드가 필요하기 때문에 함수 하나 하나 부를 때마다 예외처리를 하는 건 거의 불가능하다. 또한 진짜로 값이 없는 상황이 있을 수 있다. 문자열이라면 빈 문자열을 넣는 방법이 있을 텐데, 정수형 데이터라면? 0이나 -1을 사용하는 방법도 있겠지만 그것도 의미가 있는 데이터일 때는 난감해진다. 최근의 언어들은 null 문제를 줄이기 위해 널 안전성에 신경을 쓰고 있다. 전략은 대략 다음과 같다.
- 변수에 null을 대입하지 못하게 한다. 쉽게 말해 원천봉쇄. 불가피한 경우에만 명시적으로 변수를 정의할 때 null 값을 쓸 수 있다고 명시한다. 이런 변수를 nullable(null+able)이라고 한다. 예를 들어 코틀린은 데이터 유형 뒤에 ? 기호를 붙여줘야, 예를 들어
var foo: String?
처럼 해야 nullable로 쓸 수 있다. - null 값인 경우 참조를 하지 않는 연산자를 제공한다. 예를 들어 코틀린이나 Dart는
foo?.doSomething()
코드를 실행시키면 foo 객체가 null이 아닐 때에만 doSomething() 메서드를 실행시킨다. - 또한 null 값인지 여부에 따라 처리를 다르게 하는 연산자를 통해 null 여부 체크를 위한 코드가 길어지지 않게 도와주기도 한다. Dart라면
var String foo = bar ?? 'default';
코드는 bar가 null이 아니면 bar의 값이 foo에 대입되지만 bar가 null이라면 ?? 연산자 뒤에 있는 'default' 문자열이 foo에 대입된다.
코틀린, Swift, Dart와 같은 언어들은 null 값을 허용하지만 각자의 방법으로 널 안전성을 지원한다. Dart도 널 안전성을 지원하긴 하지만 좀 반쪽짜리인 느낌이 있는데, 1.22부터는 변수를 기본으로 non-nullable로 하는 것을 비롯해서 코틀린 수준의 널 안전성을 제공한다고 예고했으며, 실제로 Flutter 2와 함께 Dart 1.22를 정식 발표하면서 널 안전성 지원에 나섰다.
Null을 쓰지 않으려면
프로그래밍 언어가 널 안전성을 지원하는지 여부와는 별개로, 프로그래머도 될 수 있으면 null을 쓰지 않으려고 노력할 필요가 있다. 물론 null을 피해가려면 프로그래밍이 좀 더 까다로워지지만 앞서 언급한 null 때문에 생기는 문제점을 줄일 수 있고 좀 더 견고한 코드를 만들 수 있다.
Null을 대체할 값을 쓴다
문자열 형식이라면 쉬운 편이다. 빈 문자열("")을 null 대신 사용하면 웬만하면 통한다. 숫자는 좀 까다로운데, 0도 의미가 있기 때문이다. 이럴 때에는 프로그램이 정상 상태일 때 변수가 가질 수 있는 값의 범위가 어디 사이인지를 보고 그 범위를 넘는 값을 null 대신 사용하는 방법이 있다. 예를 들어 음수일 경우가 없는 변수라면 -1을 null 대신 쓸 수 있다. 하지만 양수 음수 다 의미가 있을 때에는 다른 방법을 찾아야 한다. 컬렉션은 간단한 편이다. 빈 문자열과 마찬가지로 빈 컬렉션을 사용할 수 있다. 다만 펌웨어처럼 아주 적은 메모리를 가지고 쥐어짜다시피 프로그래밍을 해야 한다면 빈 컬렉션도 메모리가 아깝기 때문에 null을 쓸 수밖에 없을 것이다.
예외 처리
요즈음 언어들은 대부분 try-catch 방식으로 예외를 잡아내는 처리를 할 수 있다. Null이 많이 쓰이는 곳 중에 하나는 함수 반환값으로, 정상적으로 기능을 수행해서 결괏값을 돌려줄 수 없을 때 null을 돌려주는 식이다. 이럴 때 null 값을 주는 게 아니라 예외를 던지고, 함수를 호출한 쪽에서 필요하다면 try-catch 방식으로 예외를 잡는 식으로 null을 안 쓰고 피해갈 수 있다. 코드는 좀 더 복잡해지지만 그냥 null만 돌려주는 식이라면 왜 그렇게 됐는지를 알 수 없는 반면[2] 예외를 던질 때에는 예외 클래스, 혹은 메시지를 통해 어떤 이유로 문제가 생겼는지를 함수를 호출한 쪽에 알려줄 수 있다는 장점이 있다.[3] 함수 하나 부를 때마다 예외 처리 루틴으로 감싸서 대비하는 게 피곤하다는 게 함정이긴 하지만, 견고한 코드를 만들고 싶다면 들일 수밖에 없는 품이기도 하다.
Null을 쓸 일을 안 만든다
할 수만 있다면 당연히 이게 가장 좋은 방법이다. Null을 쓸 일이 많다는 것은 그만큼 코드가 견고하지 못하다는 뜻이다. 값을 검증해야 할 때 꼼꼼하게 검증하고, 외부 패키지의 nullable한 함수를 호출해서 반환값을 받았다면 반드시 값을 검증하거나 예외 처리를 거친 다음 그 값을 사용하는 게 좋다. 편하다고 변수나 함수 반환값으로 null 값을 쓰기보다는 이를 대신할 수 있는 값을 사용하거나[4] 함수라면 문제가 있을 때 예외를 던지는 식으로 처리하는 게 좋다.
문서화
위와 같은 방법으로 null을 쓰지 않으려면 중요한 게 또 하나 있는데, 바로 문서화다. 예를 들어 어떤 함수를 만들었다면 반환값에 대한 설명을 문서로 작성해야한다. 특히 오류가 발생해서 비정상적인 반환값을 돌려주거나, 예외를 던질 때에는 비정상적인 반환값이 무엇이고 어떤 때에 그런 값을 돌려주는지, 어떤 상황에서 예외가 일어나는지를 문서화 해야 이를 쓰는 쪽에서 올바르게 사용할 수 있다. 이를 모르는 상태에서 대책 없이 함수를 불렀다가는 또다른 문제를 일으킬 수 있기 때문이다. 요즈음 나오는 프로그래밍 언어들은 코드 안의 주석을 문서로 만들어주는 기능을 지원하므로 귀찮더라도 코드 안에 주석을 꼼꼼하게 달아주는 노력이 필요하다. 안 그러면 나중에 가면 코드를 작성한 자기 자신도 코드를 보면서 이게 뭐지? 하고 어리둥절해 하는 일이 다반사다.
각주
- ↑ 사실 C는 그나마 null로 초기화하는 게 낫다. 포인터 변수를 선언했는데 null 초기화조차도 안 하면 무슨 쓰레기값이 들어 있을지 모르기 때문에 이걸 참조했다가는 대형 사고가 터질 수도 있다. 특히 프로그램별 메모리 공간 격리가 제대로 안 되어 있는 MS-DOS 같은 구형 운영체제에서는 이 때문에 쓰레기값이 들어 있는 포인터를 잘못 참조하는 바람에 다른 프로그램의 메모리 공간을 침범하거나, 심지어 운영체제가 사용하는 공간을 점유하거나 해서 시스템 전체를 맛 가게 만들거나 다른 프로그램의 데이터까지 날려먹거나 하는 일도 비일비재했다.
- ↑ 한 가지 함수라고 해도 처리 과정에 문제가 생기는 원인은 여러 가지일 수 있다.
- ↑ 예를 들어 클래스 기반 객체 지향 언어라면 예외 클래스가 여러 가지 있고 직접 만들 수도 있으므로 예외를 던질 때 매개변수가 문제인지, 네트워크 문제인지와 같은 식으로 문제의 원인에 따라 예외 클래스를 선택해서 던질 수 있다. 물론 귀찮다고 자바의 경우라면 RuntimeException만 던지는 식으로 하면 의미가 없어진다.
- ↑ 예를 들어 정상적이라면 자연수값이어야 하는 변수라면 null 대신 -1을 사용하거나, 문자열이라면 빈 문자열을 사용하거나 하는 식이다.