널 포인터 논쟁에서 자주 놓치는 전제들을 정리하고, 암묵적/명시적 초기화 및 다른 패러다임을 통해 널 문제를 완화하는 방법과 그 비용·절충을 논한다.
URL: https://www.gingerbill.org/article/2026/01/11/mitigating-the-billion-dollar-mistake/
Title: 10억 달러짜리 실수를 완화하기
2026-01-11
이 글은 다음 글의 연속이다: 정말 10억 달러짜리 실수였을까?.
원문 글에 대해 여러 소셜 미디어 사이트에 달린 수많은 댓글을 읽고 나서, 훨씬 더 많은 부분을 명확히 해야겠다고 생각했다.
내가 명확히 하고 싶었던 핵심 요점은 다음과 같다:
NULL 포인터를 “발명”하지 않았더라도, 몇 년 안에 누군가 다른 사람이 만들었을 것이라고 생각한다. 프로그래밍 세계가 어차피 피할 수 있었던 “실수”라고 보지 않는다.많은 댓글 작성자들이 Java/C#/Python 등의 언어 경험과, 그 언어들에서의 널 포인터 예외(NPE) 문제를 바탕으로 불만을 제기했다. 내가 보기엔 사람들이 종종 잊는 점이 있는데, 그런 언어에서는 C/Go/Odin처럼 포인터가 명시적인 것이 아니라 사실상 _거의 모든 것_이 포인터라는 점이다. 모든 것이 포인터라면, 잘못된(유효하지 않은) 포인터를 맞닥뜨릴 가능성이 기하급수적으로 높아진다. 그리고 관리형(가비지 컬렉션) 언어의 경우, 그 유효하지 않은 포인터는 십중팔구 널 포인터일 것이다. 그래서 나는 그런 언어에서 null 포인터가 문제가 되는 것을 이해할 수 있다.
하지만 내가 말하려던 요지를 여전히 놓친 것 같다고 생각한다. 즉, 그런 언어에서 null이 존재하는 이유는 명시적인 초기화 값 없이도 변수를 선언할 수 있기 때문이다:
Object value; // Java에서 Object는 내부적으로 포인터다
// 이는 C에서 다음과 동등하다
Object *value_ptr = NULL;
Java 같은 언어에서 이런 선언이 가능하기 때문에, 이 설계 결함을 완화하려면 선택지는 세 가지다:
null 포인터를 허용한다(그리고 그냥 감당한다)Optional<Object> 같은 것)null이 발생할 수 없다고 가정하려면, maybe 타입 같은 것들과 함께 모든 요소/값을 어디서든 명시적으로 초기화하도록 요구한다불행히도 Java 같은 기존 언어는 이런 문제들을 제대로 해결하기 어렵지만, 그와 비슷한 스타일을 지향하는 새로운 언어들은 해결할 수 있을 것이다. 한 가지 문제는 Java 같은 언어가 maybe/option/optional 타입을 너무 늦게 추가했고, 게다가 기본 동작이 아니라는 점이다.
첫 번째 접근은 현재의 현상 유지이고, 두 번째는 암묵적 값 선언을 유지하되 더 많은 체크를 추가하며, 세 번째는 명시적 값 선언을 요구한다.
maybe 타입을 기본 포인터/참조 타입으로 강제하면 두 가지 가능성이 생긴다:
null인지 매번 검사하도록 요구한다.null인지 검사하고 그 null을 식 트리 위로 전파한다.버전 1은 대략 이런 모습이다:
if let x {
// 이 경우 x는 `null`이 아니라고 가정한다
print(x.y)
}
하지만 사용성(ergonomics) 측면의 고통 때문에, 결국 언래핑(unwrapping) 같은 케이스로 이어질 수 있는데, 이는 사실상 NPE와 다를 바 없다:
print(x.unwrap().y)
적어도 unwrap이 있으면 패닉이 발생할 수 있다는 점이 조금 더 명확하긴 하다. 하지만 Odin의 or_return처럼 조기 반환(early-out)으로 처리할 수도 있다:
print((try x).y) // x가 nil이면 반환
버전 2는 좀 더 이상한데, null이라는 개념을 제거하지 않고 식 트리 위로 더 전파시킨다.
a?.b?.c
// 이는 다음과 동등하다
a != null ? (a.b != null ? (a.b.c != null ? a.b.c : null) : null) : null
첫 번째 접근은, 특히 거의 _모든 것_이 포인터/참조인 언어에서는 사용하기가 비인체공학적이다. 그리고 null에서 패닉만 내는 언래핑을 추가하면, 단계만 늘린 NPE를 사실상 재발명한 셈이다. 두 번째 접근은 기본값으로 삼기엔 버그를 유발하기 쉽다고 주장하고 싶다. null이 어디서 발생했는지, 스택 위로 그냥 전달되었을 뿐이라면 쉽게 알 수 없기 때문이다2.
그래서 대부분의 사람들은 null 포인터를 완화하는 세 번째 접근이 “자명하고” “사소한” 방법이라고 생각한다: 모든 값/요소를 어디서든 개별적으로 명시 초기화하는 것.
내가 흔히 본 말 중 하나는, 내가 “요점을 놓쳤다”는 것이었다. 널 안정성(null safety)은 흔한 잘못된 메모리 접근을 막는 것이라기보다는, 포인터가 가질 수 있는 상태를 타입 시스템 자체에서 명확히 하는 것—즉 널이 될 수 없는지, 아니면 어쩌면 널일 수도 있는지—에 관한 것이라는 주장이다. 나는 그걸 이미 알고 있었고, 사람들이 글에서 그걸 이해하지 못한 것이 기괴하다고까지 느낀다3.
내가 전달하려고 했던 요점(그리고 많은 사람들이 무시하거나 이해하지 못한 것)은, 모든 요소를 어디서든 명시적으로 초기화하도록 요구하는 접근에는 비용과 트레이드오프가 따른다는 것이다. 이를 “해결책”으로 제시하는 대부분의 사람들은 비용이 없다고 생각하거나, 비용이 있더라도 가치가 있다고 생각한다. 전자는 그냥 틀렸고, 후자는 내가 애초에 글에서 초점을 맞춘 대상이다. 당신이 그렇게 답하고 있다면, 사실 비용을 충분히 이해하지 못하고 있는 것이다. 이 말이 어떤 이들에겐 “거만하게” 들릴 수 있다는 건 알지만, 그렇게 하려는 의도는 없다. 내가 주장하는 바는 일반적인 관점/통념과 거리가 멀기 때문에 내 입장을 설명하려고 노력했다. 왜 누군가 “비주류” 견해를 가진 사람의 말을 듣겠는가? 현실의 다른 영역에서도 “비주류” 견해는 대개 틀린 경우가 많으니, 프로그래밍에도 그 휴리스틱을 적용하는 것이 자연스럽다. 나는 사람들이 내게 동의하길 바라는 게 아니라, 실제로 이해한 뒤에 댓글을 달아주길 바란다.
하지만 시스템 프로그래머로서 나는 항상 메모리를 다룬다. 그리고 널 포인터는 내가 상대해야 하는 잘못된 메모리 종류 중 가장 드물며, 다른 종류들은 타입 시스템으로도 처리되지 않았고, 널 문제를 해결한다고 해서 처리되는 것도 아니다. 그렇다고 “Y로 X를 해결할 수 없으니 둘 다 해결하지 말자”는 말이 아니다. 그것들은 서로 다른 문제이며, 경험적으로 심각도도 다르고 완화 방법도 다르다는 말이다. 언어를 설계한다면 어느 쪽도 해결하려고 하지 말라는 게 아니라, 둘 다 잘못된 메모리의 한 형태이긴 하지만, 문제를 완화하는 해법은 종류가 완전히 다르다는 것이다4.
Java 같은 관리형 언어에서는 _모든 요소를 어디서든 명시 초기화_하는 비용이 언어의 다른 비용에 비해 너무 작아서, 솔직히 그 접근은 괜찮다. 하지만 내가 설계하고 만든—Odin 같은—언어에서는, 비-제로 초기화(non-zero initialization)의 비용이 규모가 커질수록 극도로 비싸진다.
이 단순/순진한 접근은 의사 C로 쓰면 다음과 같다:
Object *x; // ERROR: 초기화되어야 함
Object *x = new_with_constructor(Object); // 올바른 접근
하지만 포인터를 여기저기 많이 쓰면, 초기화는 훨씬 복잡해지고, 비선형적으로 커진다.
사람들은 널이 될 수 없는 포인터(non-nullable pointer)를 표현할 필요가 있다고 주장하며, 앞서의 접근 1(체크 강제) 또는 이 명시적 접근이 사실상 유일한 방법이라고 본다. 물론 컴파일러에게 “이 포인터는 절대 널이 아니다”라고 가정하게 할 수는 있다(예: __attribute__((nonnull)) 또는 int foo(char const bar[static 1])). 하지만 이는 타입 시스템 상의 보장이 아니라, 컴파일러에게 NULL이 아니라고 가정하라고 말하는 것일 뿐이다. 이 두 접근 밖에서는 비-널 가능성을 제공할 수 없다.
이것이 내가 개별-요소 사고방식과 그룹-요소 사고방식 사이에서 말하고자 했던 전체 요점이다. 개별-요소 사고방식은 이런 식으로 개별 요소를 생각하는 데 잘 맞는다. 그래서 개별 요소로 사고하는 비용이 누적되어 큰 비용이 될 수 있다는 점을 잘 생각하지 않는다. 나는 프로그램의 많은 시간이 개별 요소의 소멸자/Drop 트레이트에서 소비되는 프로젝트에 있어 봤다. 그 소멸자들이 하는 일은, 사실 대량으로(trivially in bulk) 처리할 수 있었던 사소한 작업뿐인데도 말이다. 대부분의 사람들은 이런 것을 “비용”으로 보지 않거나, 이 접근법에 트레이드오프가 있다고 보지 않는다. 그저 “원래 이렇게 하는 것”으로 여긴다.
또 다른 측면은, 명시적 초기화를 포인터/참조를 포함하는 타입에만 적용하는 게 아니라 모든 타입에 적용하면, 타이핑이 덜 편하고 시각적 잡음이 늘 수 있다는 점이다5:
Foo f = {};
Bar b = {};
grab_data(&f, &b);
이런 지속적인 문법적 잡음은 피곤할 수 있고, 실제로 무슨 일이 벌어지는지에서 시선을 빼앗는다. Odin에서 내가 사용했던 암묵적 제로 초기화(implicit zero initialization)는 매우 잘 작동했다. 많은 이들이 혼란스러울 거라 예상하겠지만, 그렇지 않다. 신뢰할 수 있고, 사용하면 자연스러워진다.
Odin의 창시자이자 주 설계자로서, Odin 디자인의 많은 부분은 C에서 나와 많은 사람들이 겪었던 문제를 고치되, C의 전반적인 _느낌_에서 너무 멀어지지 않도록 하는 데 있었다. Odin에도 기본적으로 nil 포인터가 있지만, 언어의 수많은 기능과 구문 덕분에 실제로는 매우 드문 문제다.
C에서 NULL 포인터가 생기는 이유 중 하나는 제대로 된 배열 타입이 없기 때문이다. Odin에는 제대로 된 배열 타입이 있으며, 배열을 암묵적으로 포인터로 강등시키지 않는다. Odin에는 슬라이스(slices)가 있어서 포인터와 포인터 산술이 필요했던 많은 경우를 대체한다. 그리고 배열 타입(슬라이스 포함)은 경계 검사(bounds check)를 하기 때문에, C에서 포인터를 배열처럼 취급하면서(길이가 있을 수도 없을 수도 있음) 생기던 많은 문제를 이미 해결한다.
Odin에는 태그드 유니온(tagged unions)과 다중 반환값도 있다. 태그드 유니온은 처음 글에 불평하던 사람들이 보기엔 “당연한” 것일 수 있지만, 태그드 유니온의 사용이 반드시 nil 포인터 문제를 해결하기 위해서만 존재하는 것은 아니다.
Odin의 Maybe는 maybe/option 타입의 한 예로, 내장된(discriminated) 유니온이며 정의는 다음과 같다:
Maybe :: union($T: typeid) { T }
그리고 Odin의 union 설계상, 유니온이 단 하나의 변형(variant)만 가지며 그 변형이 포인터 유사(pointer-like) 타입이라면, 명시적 태그를 저장하지 않는다. 포인터 유사 값의 nil 상태가 유니온의 nil 상태를 함께 나타낸다. 즉 size_of(Maybe(^T)) == size_of(^T)가 된다.
C가 NULL 포인터 문제를 겪는 또 다른 이유는 프로시저 매개변수를 선택적(optional)이라고 표현할 방법이 없기 때문이다. C에는 매개변수 기본값도 없고, 타입 시스템으로 이를 표현할 방법도 없다. C의 타입 시스템은 너무 빈약하고 약하다. 그래서 사람들이 불행히도 이를 위해 포인터를 사용하곤 하는데, NULL을 쓸 수 있기 때문이다.
하지만 Odin 코드에서 Maybe가 nil 포인터를 나타내기 위해 쓰이는 경우는—외부 코드와의 연동이나, 프로시저의 선택적 매개변수 정도를 제외하면—드물다. 이는 nil 포인터 자체가 필요한 경우가 꽤 드물기 때문이다. 그 이유는 여러 가지다:
_로 무시할 수도 있다p := &x가 p: ^T; p = &x보다 훨씬 흔하다.하지만 Odin에서 nil 포인터가 문제가 되는 경우가 드문 가장 큰 이유 중 하나는 다중 반환값 때문이다. 이런 방식으로 쓰이는 다중 반환값은 다른 언어의 Result 타입 같은 것과 유사하다(하지만 의미론적으로 동일하진 않다)6. 프로시저가 포인터를 반환할 때, 그 포인터는 절대 nil이 아니라고 가정되거나, 그 유효성을 나타내는 다른 값(보통 불리언이나 enum)과 함께 반환된다:
allocate_something :: proc(...) -> (^T, mem.Allocation_Error) {
...
}
그리고 or_* 구문들(or_return, or_break, or_continue, or_else), defer, 이름 있는 반환값을 결합하면, 많은 문제들이 아예 발생하지 않는다:
ptr, err := allocate_something()
if err != nil {
return err
}
// becomes
ptr := allocate_something() or_return
Odin은 Maybe/Result 구문보다 다중 반환값을 중심으로 설계되었지만, 이 접근은 실제로는 같은 종류의 문제들을 해결한다.
사람들이 “타입 시스템에서 강제되지 않는다”라고 말하기 전에, 이것이 어디서 비롯됐는지 기억하자. Odin은 명시적인 초기화 값 없이도 변수를 암묵적으로 선언할 수 있게 한다. 그리고 Odin 설계자로서, 나는 이를 강제하는 것이(개별 요소 vs 그룹 요소 사고방식에서 보듯) 비용이 매우 크며, C의 원래 프로그래밍 방식과도 거리가 멀다고 생각한다. 이것이 사람들을 설득하지 못할 거라는 건 알지만, 결국 다른 사람처럼 생각하게 만들려는 시도와 비슷하다. 그런 건 쉽지 않고, 애초에 항상 가능한 것도 아니다. 그리고 이것은 단순한 “미적 취향” 문제가 아니다. 아주 작은 설계 결정이 거대한 아키텍처적 결과를 낳아, 프로젝트가 성장할수록 수많은 성능 문제와 유지보수 비용으로 이어진다.
널 포인터 예외(NPE)는 내가 “실패 시 패닉/트랩”이라고 분류하는 언어 구성요소 범주에 속한다. 흥미로운 점은, 이 범주에는 다른 사례가 많이 있는데도, 많은 사람들은 어떤 이유나 편향 때문에 NPE와는 다르게 접근한다는 것이다.
대표적인 예는 정수 나눗셈의 0으로 나누기다. 직관적으로, 정수를 0으로 나누면 결과가 무엇이 되어야 한다고 생각하는가?
x/0 == 0)x/0 == ~0)x/0 == x)나는 대부분의 사람이 “트랩”이라고 말할 거라고 본다. 현대 하드웨어(예: ARM64, RISC-V) 중 상당수는 트랩하지 않고, 더 흔한 x86 계열 아키텍처만 트랩하기 때문이다. Odin은 현재7 이 가정 때문에 0으로 나누기를 “트랩”으로 정의하고 있지만, 기본 동작을 바꾸는 것도 검토해 왔다. Odin은 프로그래머가 전역 단위나 파일 단위로, 0으로 나누기(그리고 그에 따른 0으로 %)의 동작을 원하는 대로 제어할 수 있게 한다.
하지만 Pony, Coq, Isabelle 등 일부 언어는 정수의 0으로 나누기를 실제로 0으로 정의한다. 이는 정리 증명기에 도움이 될 수 있기 때문이다.
하지만 프로덕션 코드 관점의 다른 질문이 있다. (특히 Java 같은 언어에서) NPE에 대한 주요 반대 논거 중 하나는 크래시를 유발한다는 점이다. 그렇다면 0으로 나누기에서도 이런 일이 일어나길 원하는가? 아니면 모든 정수 나눗셈을 명시적으로 처리하도록 강제하거나, 크래시를 막기 위해 0 같은 예측 가능/유용한 값으로 기본 처리하길 원하는가?
“실패 시 패닉”의 또 다른 흔한 예는 런타임 경계 검사다. x[i]가 범위를 벗어나면 대부분의 언어는 그냥 패닉한다. 범위 밖 접근을 막기 위해 모든 배열 접근에서 Maybe(T)를 반환하는 언어는 찾기 어렵다. OCaml 같은 언어조차 그렇게 하지 않는다.
NPE, 0으로 나누기(트랩하는 경우), 런타임 경계 검사는 모두 이런 “실패 시 패닉”의 예인데도, 사람들은 같은 종류의 문제로 취급하지 않는 경우가 많다.
솔직히 말해, 아니다. C 같은 언어 초보자에게 NULL 포인터 관련 문제가 흔할 수 있다는 건 이해한다. 하지만 그들은 다른 문제도 산더미처럼 겪는다. 그러나 프로그래밍에 더 능숙해질수록, 그런 종류의 문제는 솔직히 가장 덜한 문제다.
이 논의의 많은 부분은 기술적인 것이라기보다, 근본적으로 서로 다른 관점에 대한 오해라고 생각한다. 어떤 이들이 자신의 “기술적 의견”이라 여기는 것들 중 많은 것은 사실 미적 판단일 뿐이다. 분명히 하자면, 미적 판단은 나쁜 것이 아니지만, 반드시 기술적인 것은 아니다.
하지만 나는, 대부분의 사람들이 “실패 시 패닉” 범주에 대해 의견을 일관되게 적용하지 않는다고 주장하고 싶다. NPE도 다르지 않다. 그저 “10억 달러짜리 실수”라는 이름이 존재해서이거나, 혹은 그들이 더 자주 마주치기 때문에 더 큰 문제처럼 보일 뿐이다.
나는 많은 사람들이 모든 요소를 어디서든 개별적으로 명시 초기화하는 접근을 “자명한 해법”으로 보는 것을 안다. 마치 손쉽게 딸 수 있는 낮게 열린 과일(low-hanging fruit)처럼 보이기 때문이다. 어릴 때 나는, 특히 허리 아래에 있는 낮게 열린 과일은 따지 말라고 들었다. 쉽게 따일 것처럼 보여도, 어떤 것은 따이지 않은 이유가 있기 때문이다. 그렇다고 그 과일을 따야 한다거나 따지 말아야 한다는 뜻이 아니라, 트레이드오프를 고려해야 한다는 뜻이다.
만약 당신이 작업 중이거나 개발 중인 언어에서 모든 요소를 어디서든 개별적으로 명시 초기화하는 비용이 가치 있다고 진심으로 생각한다면, 좋다! 다만 그 접근의 트레이드오프를 최소한 알고는 있어야 한다. Odin에서는, 경험적으로 문제를 완화하는 다른 방법들과 비교했을 때, 그 비용을 치를 가치가 없다고 판단했다.
나쁜 비판 대부분은 글을 읽지 않았거나, 몇 문단 이상 읽지 않은 사람들에게서 나왔다. 그래서 이 코멘트를 아주 분명하게 말하고 싶었다.↩︎
이 점은 내가 많은 언어에서 예외를 오류 처리로 사용하는 것을 좋아하지 않는 이유 중 하나다. 무엇이 어디서 던져졌는지/발생했는지가 분명하지 않고, 가능한 한 마지막 순간까지 무시하는 관행을 조장한다. 이 문제는 값 전파 실험 파트 2에서 논의했다.↩︎
나는 타입 시스템이 무엇을 하는지와 그 이점을 이해하고 있으며, 약간의 검토도 없이 내 지식(또는 그 부족)을 가정하는 것은 조금 모욕적이다.↩︎
다른 잘못된 메모리 주소의 경우, 선형/어파인(affine) 부분구조 타입 시스템과 수명(lifetime) 의미론이 도움이 될 수 있다(예: Rust). 하지만 그것들은 언어 사용성 및 제약 측면에서 또 다른 비용을 수반한다. 언어 설계는 어렵다.↩︎
타이핑이 프로그래밍의 병목이 아니라는 것은 알지만, 코드를 읽는 것이 아니라 훑어볼 때 시각적 잡음은 큰 문제다. 나는 문법적 잡음에 휩쓸리기보다 패턴을 보고 싶다.↩︎
결과 타입이 일종의 합(sum) 타입이고, 다중 반환값은 곱(product) 타입에 더 가깝다는 것은 안다. 하지만 언어마다 사용 방식과 표현 방식이 다르고, 실제로는 같은 종류의 문제에 대해 이 방식이 잘 작동한다. FP 훈계는 제발 하지 말아달라.↩︎
글을 쓰는 시점에서, 기본값으로 트랩이 더 나은지 0이 더 나은지 확신이 없다. 하지만 Odin 컴파일러는 네 가지 옵션 모두를 허용한다. 부동소수점에서 0으로 나누면 “Inf”가 되고, 실제로 그게 그렇게 큰 문제가 아닌 경우도 많다. 그렇다면 정수 0으로 나누기가 왜 그렇게 나빠야 할까?↩︎