왜 nullable 타입인가요?
왜 nullable 타입인가요?
Dart 코드에서 null은 변수에 값이 없음을 나타내요. 그런데 어떤 언어는 null을 아예 허용하지 않죠. 두 접근 방식이 모두 왜 잘 작동하는지 알려드릴게요.
출처: 원문
본문
몇 주 전에 우리는 null 오류를 피하는 데 도움을 주는 주요 생산성 기능인 Dart null safety beta를 발표했어요. null 값을 이야기하다 보니, /r/dart_lang 서브레딧에서 한 사용자가 최근 이렇게 물었어요:
그런데 우리는 왜 아직도 null 값을 갖고 있고, 원하는 걸까요? 왜 완전히 없애지 않죠? 저는 지금 Rust도 만져 보고 있는데, Rust에는 null이 전혀 없어요. 그래서 null 없이 사는 게 가능해 보이거든요.
저는 이 질문이 정말 좋아요. 왜 null을 완전히 없애지 않을까요? 이 글은 그 스레드에 제가 답한 내용을 확장한 버전이에요.
짧은 답은 이래요. 네, null 없이 사는 건 전적으로 가능하고, Rust 같은 언어가 그렇게 해요. 하지만 프로그래머들은 실제로 null을 쓰고 있죠. 그래서 없애기 전에, 왜 쓰는지 이해해야 해요. null이 있는 언어에서 우리가 null을 쓸 때, 그것은 보통 무엇을 하고 있을까요?
알고 보면 null은 주로 값의 *부재(absence)*를 나타내는 데 쓰여요. 그리고 그건 정말 유용하죠. 어떤 사람은 중간 이름이 없어요. 어떤 우편 주소에는 아파트 호수가 없어요. 어떤 몬스터는 처치해도 떨어뜨리는 보물이 없어요.
그런 경우에 우리는 "이 변수는 X 타입의 값을 가지거나, 전혀 값이 없을 수도 있어요"라고 표현하고 싶어요. 그렇다면 그걸 어떻게 모델링할까요?
한 가지 옵션은 변수가 기대 타입의 값이나 마법의 값 null을 담을 수 있다고 말하는 거예요. 값이 null일 때 그것을 사용하려 하면 런타임 실패가 나죠. 이것이 null safety 이전의 Dart가 하던 방식이고, SQL이 그렇고, Java가 비원시 타입에 대해 그렇게 하고, C#이 클래스 타입에 대해 그렇게 해요.
하지만 런타임에 실패하는 건 별로예요. 사용자가 버그를 경험하게 되니까요. 프로그래머인 우리는 사용자보다 먼저 그런 실패를 찾고 싶어해요. 사실, 우리가 프로그램을 실행하기도 전에 버그를 찾을 수 있다면 더할 나위 없겠죠. 그렇다면 타입 시스템이 이해할 수 있는 방식으로 값의 부재를 어떻게 모델링할까요? 다시 말해, "잠재적으로 부재할 수 있는 값"과 "확실히 존재하는 값"에 서로 다른 정적 타입을 어떻게 부여할까요?
두 가지 주요 해결책이 있어요:
- option 또는 maybe 타입을 사용한다
- nullable 타입을 사용한다
해결책 1: Option 타입
이것이 ML과 ML에서 파생된 대부분의 함수형 언어(Rust, Scala, Swift 포함)가 하는 방식이에요. 값이 확실히 있다는 걸 알면 우리는 그냥 기본 타입을 사용해요. int라고 쓰면 "여기에 정수가 확실히 있어요"라는 뜻이죠.
잠재적으로 부재할 수 있는 값을 표현하려면 기본 타입을 option 타입으로 감싸요. 그래서 Option<int>는 정수일 수도 있고 아무것도 아닐 수도 있는 값을 나타내요. 0개 또는 1개의 항목을 담을 수 있는 컬렉션 타입과 비슷해요.
타입 시스템 관점에서 보면 int와 Option<int> 사이에는 직접적인 관계가 없어요. 이들을 서로 다른 타입으로 취급하면 잠재적으로 부재할 수 있는 Option<int>를 실제 int를 기대하는 곳에 실수로 넘길 수 없어요. 또한 Option<int>는 정수 연산을 하나도 지원하지 않으므로 정수인 것처럼 실수로 사용할 수도 없어요. List<int>에 산술 연산을 할 수 없는 것처럼 Option<int>에도 산술 연산을 할 수 없죠.
기본 타입의 존재하는 값(예: 3)에서 option 타입의 값을 만들려면 Some(3)처럼 option을 구성해요. 값이 부재할 때 option 타입을 만들려면 None()처럼 쓰면 돼요.
Option<int>에 저장된 잠재적으로 부재할 수 있는 정수를 사용하려면 먼저 값이 있는지 확인해야 해요. 있다면 option에서 정수를 꺼내 사용하면 돼요. 컬렉션에서 값을 읽어 내는 것과 같죠. option 타입이 있는 언어는 대개 멋진 패턴 매칭 문법도 제공해서, 값이 있는지 확인하고 있다면 사용하는 우아한 방법을 줘요.
해결책 2: Nullable 타입
다른 옵션(있잖아요)은 Kotlin, TypeScript, 그리고 이제 Dart가 하는 방식이에요. Nullable 타입은 유니온 타입의 특수한 경우예요.
(참고: 여기서 이름 때문에 정말 헷갈리기 시작해요. Option 타입 — 위에서 ML과 그 친구들이 하는 것 — 은 대수적 데이터 타입의 특수한 경우예요. 대수적 데이터 타입의 다른 이름은 "discriminated unions"이에요. 그런데 이름에 "union"이 들어감에도 "discriminated unions"는 "union types"와 꽤 달라요. Phil Karlton이 말했듯, 컴퓨터 과학에는 어려운 문제가 두 가지뿐이에요: 캐시 무효화와 이름 짓기.)
option 타입 접근 방식과 비슷하게, 확실히 존재하는 값을 나타내는 데 기본 타입을 사용해요. 그래서 int는 다시 정수가 확실히 있다는 뜻이에요. 잠재적으로 부재할 수 있는 정수를 원하면 대신 int? nullable 타입을 사용해요. 작은 물음표는 본질적으로 int | Null 같은 유니온 타입을 쓰는 것의 문법적 설탕이에요.
option 타입과 마찬가지로 nullable 타입은 기본 타입과 같은 연산을 지원하지 않아요. 타입 시스템은 nullable int에 산술 연산을 시도하는 걸 허용하지 않아요. 안전하지 않으니까요. 마찬가지로 nullable 정수를 실제 정수를 요구하는 곳에 넘길 수도 없어요.
다만 타입 시스템은 option 타입보다 조금 더 유연해요. 타입 시스템은 유니온 타입이 그 분기들의 슈퍼타입임을 이해해요. 다시 말해 int는 int?의 서브타입이에요. 즉 확실히 존재하는 정수는 "어쩌면 존재할 정수"를 기대하는 곳에 넘길 수 있어요. 안전하니까요. 이것은 업캐스트로, String을 Object를 받는 함수에 넘길 수 있는 것과 같아요. Dart는 반대 방향 — nullable에서 non-nullable로 — 만 금지해요. 그것은 다운캐스트라 실패할 수 있으니까요.
nullable 타입의 값이 있고 그 자리에 실제 값이 있는지 null인지 확인하고 싶을 때는 C나 Java에서 자연스럽게 하던 것처럼 명령형으로 값을 확인해요:
foo(int? i) {
if (i != null) {
print(i + 1);
}
}
그러면 언어는 흐름 분석(flow analysis)을 사용해 프로그램의 어느 부분이 그런 검사 뒤에 보호되는지 판단해요. 그 분석은 변수가 null이 아닐 때만 코드에 도달할 수 있다고 결정하므로, 그 영역 안에서는 타입 시스템이 변수의 타입을 non-nullable로 조여(tighten) 줘요. 그래서 여기서는 if 문 안에서 i를 타입 int로 취급해요.
어떤 해결책을 언어가 택해야 할까요?
그럼 Dart 팀에서 null을 더 안전하게 다루도록 언어를 만들기로 결정할 때, 해결책 1과 2 중에서 어떻게 골라야 할까요? 사용자를 관찰하는 것에서 시작할 수 있어요. 사용자들은 부재 값을 확인하는 코드를 어떻게 쓰고 싶어할까요? 함수형 언어에서는 패턴 매칭이 주요 제어 흐름 구조 중 하나라서 사용자들은 그것에 매우 익숙해요. 그런 스타일에서는 option 타입과 패턴 매칭을 쓰는 게 자연스러워요.
C에서 파생된 명령형 언어에서는 제 이전 예제 같은 코드가 null을 확인하는 관용적 방법이에요. 흐름 분석과 nullable 타입을 사용하면 그 익숙한 코드가 올바르고 안전하게 작동해요. 사실 Dart에서는 기존 코드 대부분이 이미 새로운 타입 시스템으로 정적으로 null safe한 것을 발견했어요. 새로운 흐름 분석이 이미 작성된 코드를 올바르게 분석하니까요.
(어떤 면에서 이건 놀라운 일이 아니에요. 대부분의 코드는 null 처리에 관해 이미 동적으로 올바르거든요. 그렇지 않다면 계속 크래시가 났을 거예요. 작업의 상당 부분은 단지 타입 시스템을 똑똑하게 만들어 그 코드가 이미 올바르다는 걸 보게 하는 것이고, 그래서 사용자의 주의를 그렇지 않은 몇몇 부분에 끌리는 거예요.)
그래서 우리의 목표가 친숙함과 사용자 편안함을 극대화하는 것(언어 설계에서 중요한 기준이죠)이라면, 우리 언어의 제어 흐름 구조가 우리를 위해 깔아 놓은 길을 따라가면 돼요.
부재와 존재 표현하기
option 타입과 nullable 타입이 어떻게 표현되는지의 차이에 기반해 이 질문에 접근하는 더 깊은 방법이 있어요. 그 표현 차이가 우리에게 몇 가지 핵심 트레이드오프를 강요하고, 그것이 우리를 한쪽으로 기울게 할 수 있어요.
첫 번째 접근 방식에서는 option 타입의 값이 기본 값과 구별되는 런타임 표현을 가져요. Dart에서 option 타입을 선택했다고 가정하고, option을 만들어 Object로 업캐스트한다고 해볼게요:
var optionalInt = Some(3);
Object obj = optionalInt;
print(obj is int); // false
마지막 줄에 주목하세요. Option<int> 값은 존재하는 경우에도 기본 타입의 값과 같은 종류가 아니에요. Some(3)과 3은 서로 구별되는, 구분 가능한 값이에요.
nullable 타입은 그렇게 작동하지 않아요:
var nullableInt = 3 as int?;
Object obj = nullableInt;
print(obj is int); // true
Nullable 타입은 정적 타입 시스템에 존재하지만, 값의 런타임 표현은 기본 타입을 사용해요. "nullable 3"이 있으면 런타임에서는 그냥 숫자 3이에요. 어떤 nullable 타입의 부재 값이 있으면 런타임에서는 그냥 외로운 마법의 값 null이에요.
값이 nullable 타입인지 물어볼 수 있어요:
print(obj is int?);
하지만 is int? 표현식은 다음과 동일해요:
print(obj is int || obj is Null);
중첩 option
option 타입의 값은 기본 타입과 다르기 때문에 중요한 능력을 줘요: Option 타입은 중첩될 수 있어요.
어떤 네트워크 서비스가 정수 ID가 담긴 요청을 받으면 리소스 문자열을 준다고 해볼게요. 어떤 리소스는 존재하지 않아서 그 ID에 대해 서버가 데이터 없이 응답할 수 있어요. 네트워크를 치는 건 느리므로, 우리는 이미 수행한 요청의 결과를 로컬에 캐시하고 싶어요.
null safety 이전의 Dart에서는 맵을 이렇게 쓸 수 있었어요:
Map<int, String> cache;
그래서 어떤 ID에 대해 네트워크 요청을 하기 전에 cache 맵의 subscript 연산자로 리소스의 ID를 조회해요. 그 연산자는 Map에서 키가 없으면 null을 반환하도록 정의돼요. 하지만 키가 존재하면서 null 값과 연결돼 있을 수도 있어요. 조회해서 null을 받았다면, 두 가지를 의미할 수 있어요:
- 키가 맵에 없었어요. 아직 요청을 안 한 것이므로 서버에 리소스를 요청해야 해요.
- 키가 존재하면서
null과 연결돼 있었어요. 이미 서버에 요청했고 리소스가 없다는 걸 찾아서 캐시에 저장한 거예요. 그 결과를 사용하고 서버에 다시 질의하지 않아야 해요.
시스템 전체에 단일 null 값만 있기 때문에 이 두 경우를 구분할 수 있는 런타임 표현이 없어요. 이게 바로 Map 클래스에 별도의 containsKey() 메서드가 있는 이유예요. 그 API가 두 경우를 구분하는 방법을 제공하죠.
이제 Dart가 option 타입을 중심으로 만들어졌다면 캐시는 이렇게 생겼겠죠:
Map<int, Option<String>> cache;
그리고 subscript 연산자는 optional 값을 반환할 거예요:
class Map<K, V> {
Option<V> operator [](K key) => ...
...
}
우리의 Map<int, Option<String>>의 경우 반환 타입은 Option<Option<String>>이 돼요. 중첩에 주목하세요! 이제 캐시에서 키를 조회하면 몇 가지 다른 결과를 얻을 수 있어요:
Some(Some(string))은 리소스가 서버에 실제로 있었고, 지금 캐시에 있다는 뜻이에요.Some(None())은 서버에 요청했고 리소스가 없어서, 리소스가 존재하지 않는다는 사실을 캐시했다는 뜻이에요.None()은 캐시에 이 ID가 전혀 없다는 뜻이에요.
마지막 두 경우를 구분할 수 있는 이유는 option이 항상 자기 기본 값을 약간의 추가 상태로 감싸기 때문이에요. 런타임에서 몇 겹인지 판단하고 각각 개별적으로 벗겨낼 수 있어요.
Nullable 타입은 명시적인 런타임 표현이 없으므로 암묵적으로 평평해져요(flatten). 그래서 int?와 int??는 타입 시스템에 동등한 타입이고, 런타임에서도 동등한 값 집합을 가져요. 그래서 option 타입 팬들이 그것을 "더 표현력 있다"고 말하는 거예요. option 타입이 nullable 타입보다 더 많은 종류의 값을 표현할 방법을 주니까요.
Nullable 치환
"표현력"을 생각하는 또 다른 방법은 사용자가 실제로 표현하고 싶은 것을 표현하는 데 드는 노력이 얼마나 되는지예요. 사용자가 더 적은 절차를 거치고 목표에 도달할 수 있으면 언어가 더 표현력 있다고 해요.
nullable 타입이 별도의 표현을 갖지 않는 장점은 값이 non-nullable에서 nullable 맥락으로 훨씬 쉽게 흘러갈 수 있다는 거예요. 선택적 정수 매개변수를 받는 함수가 있다고 해볼게요. option 타입이라면 시그니처는 이렇게 생겼겠죠:
takesMaybeInt(Option<int> optionalInt) {}
알려진 정수로 이 함수를 호출하려면 먼저 option으로 감싸야 해요:
takesMaybeInt(Some(3));
Nullable 타입에서는 표현 차이가 없으므로 기본 타입의 값을 직접 넘길 수 있어요:
takesMaybeInt(3);
이 유연성은 타입 시스템 어디에서나 얻을 수 있어요. nullable 타입을 반환하는 메서드를 non-nullable 타입을 반환하도록 오버라이드할 수 있어요. List<int>를 List<int?>를 원하는 함수에 넘길 수도 있어요.
그래서 nullable 타입은 중첩할 수 없고 여러 구별되는 '부재' 종류를 표현할 수 없는 대신, 축복받은 단일 null 개념을 훨씬 쉽게 다루게 해 줘요.
Dart를 위한 nullability
Dart는 사람들이 이미 if 문으로 런타임의 부재 값을 확인하는 명령형 언어예요. 또한 자체 런타임 표현을 가진 특별한 null 값이 이미 있는 객체 지향 언어이기도 해요. 그래서 해결책 2, nullable 타입이 우리에게는 자연스러운 답이었어요. 그것은 사용자가 익숙한 종류의 코드를 쓰게 해 주고, 런타임이 이미 값을 표현하는 방식을 활용해요.
Dart의 nullability에 대해 더 알고 싶다면 Dart null safety 문서의 Where to learn more 섹션을 확인하세요.
퍼즐 조각 중 하나는 null이에요.
더 알아보기
- Dart null safety — sound null safety의 전반적인 모습과 사용법을 확인해 보세요.
- Null safety 이해하기 — 타입 시스템과 흐름 분석이 어떻게 맞물리는지 심층적으로 살펴보세요.
- Option 타입 — 함수형 언어의 접근 방식을 더 알아보세요.