타입 이야기 (Optional과 Soundness Nullable)

프로그래밍을 하다 보면 “타입”에 대한 고민을 할 필요가 생긴다. 우리가 자주 볼 수 있는 Int, Char, Float, Double, String 과 같은것이 모두 타입이다. 일반적으로 int형 타입이라고 하면 정수를 -21억~+21억 까지의 수를 저장할 수 있는 타입이다. 라고 이야기 한다. float, double부동소수점(실수)를 저장할 수 있는 타입이라고 이야기 한다. 그리고 String문자열을 저장할 수 있는 타입 이라고 한다.

타입은 크게 “데이터를 읽는 방법”과 “변수가 가질 수 있는 값”을 정의하는 용도 두가지로 생각할 수 있다. 첫번째는 좀 더 실용적인 부분이며, 두번째는 조금 더 이론적인 부분으로 들어간다.

타입은 이 변수가 가질 수 있는 값의 종류이다. 컴퓨터에 있어서 모든 값은 메모리에 저장된다. 메모리에 저장된 데이터는 그것을 어떻게 읽느냐에 따라서 그 의미가 달라진다. 예를들어, 같은 4바이트 공간에 0x00, 0x00, 0x80, 0xC4 라는 데이터가 있다고 생각해 보자. 이 데이터는 어떻게 읽히느냐에 따라 그 의미가 매우 달라진다. 예를들어, Unsigned Int라고 생각하면 3296722944이며, 그냥 Int라면 –998244352, Float으로 읽으면 1024 라는 숫자가 나온다. 그러므로, 어떤 데이터가 의미를 가지기 위해서는 타입이 필요하다.

타입은 가질 수 있는 값의 집합으로 생각할 수 있다. int 타입을 생각해 보자. int 타입의 변수는 ···, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5 ··· 등 약 2^32개 만큼의 값을 가질 수 있다. 다른 말로는 int는 2^32개의 값을 가진 집합이다. 그 외의 값이 들어간다면 컴파일러는 오류를 표시할 것이다. 타입이 맞지 않기 때문이다. 다시 말해서, 타입이라는 집합안에 존재하지 않는 값을 넣으려 했기 때문이다.

집합에도 무한집합이 있듯, 타입에도 무한집합이 존재한다. String이 그것이다. Int형이나 Double, Float과 같은 타입은 표현할 수 있는 수가 고정되어 있다. 어찌되었던 자신이 가지는 바이트 크기에 고정되어 있다. 하지만 String은 예외이다. String은 (충분한 메모리 공간이 존재한다면) 무한한 값을 가질 수 있다. String을 예시로 들자면 다음과 같을것이다: String = {'a', 'ab', 'abc', 'b', 'bb', ···· }그렇기 때문에 타입을 표현할 수 있는 값으로 보는데 무리가 없다.

이것을 확장하면 숫자 또한 무한집합으로 풀 수 있을것이다. 그런 방법중 하나가 Decimal 이다. Decimal은 (충분한 메모리 공간이 존재한다면) 표현할 수 있는 값에 제한이 없다. 즉, Decimal은 다음과 같은 집합이다: Decimal = {···, -999999999999, -9999999999999.9999999, ····, 0.000000001, ···, 999999999, ··· } 타 언어에서는 이것을 Number 와 같이 표현하기도 한다. Number 타입은 값의 제한 없이 어느 숫자든 저장하고 표현 할 수 있다. 만약 타입을 그저 Int와 같이 메모리의 공간으로 본다면 이상함이 생겼겠지만 타입을 집합으로 본다면 이상할 것이 없다.

타입을 집합으로 보기 시작하면 여러가지가 숨겨져 있던 것들이 보인다. 그 중 이상한 점이 가장 먼저 떠오를 것이다. Java와 같은 객체지향 언어를 만지다 보면 다음과 같은 코드를 자주 만나게 된다:

Integer a = ~~~
if (a == null) throw new Exception()

Integer는 숫자이다. 특히 이 경우에는 메모리 공간과 연관되어, 4Byte 정수를 뜻한다. 그러나 이 Integer는 Null을 내포할 수 있다. 집합으로 생각해라 했더니, 갑자기 이상한 값이 끼어든 것이다. 타입을 집합 입장에서 본다면 분명 오류이다. Integer는 정수만을 가지는 집합이기 때문에, 만일 Null이 들어가야 한다면 실제로는 새로운 타입이 필요하다. 이 문제를 해결하기 위해 나온 대표적인 방법이 Optional과 nullable Type (?) 이다.

물음표 (Nullable Type)

우선 Nullable Type을 보자. Nullable Type은 타입 뒤에 물음표(?)를 붙임으로서 “이 집합에는 null이 포함되어 있음”을 알려준다. 이것은 Algebraic Data Type 관점에서 보면 Sum 타입으로 생각할 수 있다. 예를들어 Integer? 타입은 Integer? = Integer | Null 타입(집합)으로 생각할 수 있다.

Algebraic Data Type(ADT)이 친근하지 않은 독자들을 위해 잠시 설명을 하고 가겠다. ADT는 타입을 대수적으로 보는 방법이다. 타입을 SumProduct를 통해서 보는 것이다. “타입은 집합이다”라는것을 잃으면 뒤의 내용이 읽기 힘들다. Product 타입은 타입의 곱이다. 다음의 Java 코드가 있다고 하자. ProductTypeint aint b로 구성 되어 있다. ProductType에 int 타입 변수가 두개 있기 때문에, 표현할 수 있는 값의 수는 (2^32) * (2^32) = 2^64가 된다. 쉽게 생각하면 총 8 byte를 표현할 수 있기 때문에 2^64 만큼의 값을 표현할 수 있다고 생각할 수도 있다. 즉, Product 타입은 여러 타입을 곱하여 표현할 수 있는 값을 확장하는 방법이다. 이것은 Tuple, Class, Record, Struct 등 여러곳에서 알게모르게 적용되어 쓰였다.

class ProductType {
    int a;
    int b;
}

그러나 Sum 타입은 생소할 수 있다. 일반적인 언어에서는 Enum에서 주로 나타난다. Sum 타입은 집합에 들어갈 수 있는 값의 수를 하나 늘려준다. 마치 Or과 같은 역할을 한다. 예를들어, 다음 Enum을 생각해 보자. RGB enum은 Red, Green, Blue라는 세가지 값을 가진다. 그러므로, 다음과 같이 표현할 수 있다: RGB = Red | Green | Blue . 이 관점에서 보면 타입에 들어갈 수 있는 값이 하나씩 늘어났음을 볼 수 있다. 다시 말해서, Red or Green or Blue 인 것이다. Integer와 같은 타입도 결국은 Sum 타입으로 풀어낼 수 있다. Integer = -2147483648, ···, -1, 0, 1, ···, 2147483647 의 집합인 것이다. (메모리상의 데이터와 타입의 이론적 부분을 분리해서 봐야 한다.) 만약 Integer에 2147483648 이라는 숫자가 필요했다면 oneMoreInteger = Integer | 2147483648 와 같이 표현 할 수 있다.

enum RGB {
	Red, Green, Blue
}

다시 처음으로 돌아가서, Java의 Integer를 보자. Java의 Integer는 정수 + Null 을 가질 수 있다. 즉, Integer = Integer | Null 이다. 이것은 명백한 문제이다. 같은 집합인데 다른 집합을 가진다. 그래서 이것은 개발자로 하여금 혼란을 야기한다. Null이 집합속에 있음에도 숨겨져있기 때문이며, Integer 타입이 정확한 집합을 표현하지 못하는 문제를 가진다.

그래서 Kotlin, Flutter 등의 개발자는 Nullable (Soundness null)이라는 개념을 들고왔다. Integer = Integer | Null이 될 수 없기 때문에 언어에 새로운 타입을 넣은 것이다. 그것이 바로 물음표(?)로 표현되는 타입이다. 표현은 간단하다. Integer? = Integer | null이다. HttpRequest? = HttpRequest | null 이다. 이것은 어느 객체에나 바로 적용할 수 있다. Null이 Sum 타입으로서 표현되기 때문에, 언어(컴파일러) 차원에서 더 많은 개입을 할 수 있다. 예를들어, 객체에 접근할 때 Null을 체크하지 않는다면 오류를 발생할 수도 있다. Type으로서 Null을 표현하기 때문에 가능하다.

이렇게 물음표 타입을 사용하지 않았을때는 개발자도, 컴파일러도, 언어도, 심지어는 다른 라이브러리도 이 값이 Null을 가지는지 마는지 알 수가 없었다. 그래서 개발자는 매번 공식 개발 문서를 읽어보거나, 방어적 코딩을 해야 했다. 결국 그것들은 모두 비용으로 다가와서 개발 속도를 저해하거나 신경쓸 부분을 만들어 냈다. 그러다가 한번 놓치면 NPE로 인해서 프로그램이 멈춰 버렸다. 그러나 물음표를 사용함으로서 이것이 Null을 가지고 있음을 표현하고, 그에 따른 대응을 할 수 있게 만들었다.

Optional

또 다른 방법으로는 Optional이 있다. Nullable type (물음표 타입)은 언어적 차원에서 문법으로 들어와야 한다. 즉, 기존에 잘 사용되던 언어에서는 추가하기 힘들다. 추가하려면 언어의 문법을 바꿔야 하며, 그로 인해 하위호환성 문제가 생기게 된다. 당연하게도 이전 코드에는 모두 물음표가 존재하지 않을 것이다. Null이 내포되어 있는 상황에서도 Null이 없는것으로 판단 될 것이다. 이것을 고치려면 기존의 코드를 모두 뜯어 고쳐야 한다. 이 정도면 하위호환성을 버리고 새로운 언어를 만드는게 나을것이다. 그러나, 하위호환성을 포기하고 새로운 언어라고 발표하는것은 쉬운 결정이 아니다.

그래서 들고 나온게 Optional<T>이다. Optional<T> 또한 자세히 들어보면 두가지의 값을 표면에 가지는 타입이다 Null (Empty)와 제너릭으로 주어지는 T 이다. 즉, 또 다른 형태의 Optional<T> = T | Null (Empty)인 것이다. 대신 이 경우에는 객체를 이용해서 표현한다. Rust와 같이 Enum에 Variant를 넣을 수 있다면, 다음과 같이 표현할 수도 있다

enum Optional<T> {
	None,
	Some(T)
}

결국 원래 타입이 Null을 가질 수 있음을 인정하고, 위와 같이 새로운 타입을 만들어낸 것이다. 이를 통해서 Null에 대한 처리를 직관적으로 할 수 있다. 심지어 Optional의 경우에는 Monad와 연관하여 보다 편리한 연산을 진행할 수도 있다. Optional이 사실상 Maybe 모나드와 같은 역할을 하기 때문이다.

사실 근본적인 문제는 해결되지 않았다. 별도의 검증이 없다면 T 자체는 Nullable일 수도 있다. 그러나 Optional을 사용할때 Lift(Wrapping)을 할 때 값을 검증하고 개발자도 값이 null이면 Optional.Empty를 주도록 신경을 쓰면서 오류가 발생될 수 있는 가능성을 감소시킨 것이다.

Reification

위와 같이 숨어있는 개념을 수면 위로 들어내는것을 reification이라고 한다. 한국어로는 “구체화”라고 한다. 두 방법 모두 숨어있는 Null 이라는 존재를 표면 위로 들어낸 것이다.

구체화(Reification)는 비단 Null에서만 쓰이지 않는다. 원래 Integer를 리턴하는 함수가 Exception을 발생할 수 있다고 생각해 보자. 그러면 실제 해당 함수의 리턴 타입은 Integer | Exception 이다. 즉, 새로운 타입이 필요할 것이다. ThrowableInteger = Integer | Exception 과 같은 무언가가 필요하고, 해당 함수의 리턴 타입은 ThrowableInteger가 되어야 할 것이다. 이때 Try 또는 Result 와 같은것을 사용하면 숨어있던 Exception이 표면위에 올라오게 된다.

만약 Exception, Null이 모두 리턴 가능한 함수라면 Integer | Null | Exception과 같은 타입으로도 표현할 수도 있을 것이다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

댓글을 작성하기 위해 아래의 숫자를 입력해 주세요. *Captcha loading…