2014년 6월 13일 금요일

10억 달러 짜리 해결책

10억 달러 짜리 실수편에 이어 이번 글에서는 10억 달러 짜리 실수의 해결책에 관해 알아보기로 한다.

지난번 글에서는 비슷한 프로젝트를 C#과 F#으로 짰었는데 F#으로 짠 쪽은 null 체크를 27번 했던 반면 C#으로 짠 쪽은 3036번(오타 아님) 했었다는 사례를 소개했었다. 사실 F#에서 null 체크가 별로 필요치 않은 이유는…간단하다:

F# 타입의 변수/상수에는 null을 대입할 수 없다.

null 키워드가 있기는 있는데, C# 같은 다른 .NET 언어로 만든 타입을 쓸 때만 필요할 뿐 순수하게 F#으로 만든 타입에는 대입하지 못한다. null을 대입하지 못하니까 당연히 null 체크도 필요없는 것이다1. 어찌 보면 좀 컬럼버스의 달걀 같은 얘긴데…

처음 F#을 알았을 때는 어떻게 null 없이 프로그램을 짤 수 있다는 건지 이해가 가질 않았다. 왜냐하면 자바/C#으로 프로그램을 짤 땐 일단 인스턴스를 만들고 프로퍼티를 초기화하고, 또 다른 인스턴스를 만들고 프로퍼티를 초기화하고, … 처럼 하는 방식이 일반적이기 때문이다. 그 과정 중간중간에 인스턴스의 일부 상태가 null로 머물러 있는 것은 당연하고 불가피하다. 예를 들어 아래처럼 말이다:

class Pet {
    public string Name { get; set; }
}

class Person {
    public string Name { get; set; }
    public Pet Pet { get; set; }
}

Pet myCat = new Pet();     // 1
myCat.Name = "야옹이";     // 2
Person me = new Person();  // 3
me.Name = "준영";          // 4
me.Pet = myCat;            // 5

1에서 Pet 인스턴스를 생성하긴 했지만 Name 프로퍼티는 아직 null이다. 3에서 Person 인스턴스를 생성하긴 했지만 NamePet 프로퍼티는 아직 둘 다 null이다. 5까지 거치고 나야 비로소 모든 프로퍼티가 정상적인 값으로 초기화된다. 이상할 것 없어 보이는 이 코드를 아래처럼 F#으로 옮기면,

type Pet() =
    member val Name: string with get, set

type Person() =
    member val Name: string with get, set
    member val Pet: Pet with get, set

let myCat = Pet()
myCat.Name <- "야옹이"
let me = Person()
me.Name <- "준영"
me.Pet <- myCat

거의 똑같이(?) 생겼는데도 컴파일 에러가 뜬다! F#에서는 인스턴스를 생성할 때 프로퍼티가 초기화되지 않은 상태로 남아 있는 것을 허용하지 않기 때문이다. 이를 컴파일이 되게 고치면 아래와 같다:

type Pet() =
    member val Name: string = null with get, set

type Person() =
    member val Name: string = null with get, set
    member val Pet: Pet = Pet() with get, set

let myCat = Pet()
myCat.Name <- "야옹이"
let me = Person()
me.Name <- "준영"
me.Pet <- myCat

프로퍼티를 선언할 때 = 뒤에 디폴트값을 대입해 줌으로써 인스턴스가 어떤 경우든 완전히 초기화된 상태로 생성되는 것을 보장했다. 그런데 컴파일만 될 뿐이지 이 코드는 이전 C# 코드와 다를 것이 없다. 여전히 Name 프로퍼티에 null을 대입하고 있기 때문이다. F# 타입에는 null 대입이 불가능하다고 했는데 컴파일이 되는 이유는 string 타입이 실제로는 .NET의 System.String 타입이기 때문이다. 반면 순수 F# 타입인 Petnull 대입이 불가능하므로 member val Pet: Pet = null with get, set처럼 초기화하면 컴파일 에러가 발생한다.

그렇다면 string 타입에서도 null 대입을 없애려면? 디폴트값으로 ""같은 걸 대입하면 된다:

type Pet() =
    member val Name: string = "" with get, set

type Person() =
    member val Name: string = "" with get, set
    member val Pet: Pet = Pet() with get, set

let myCat = Pet()
myCat.Name <- "야옹이"
let me = Person()
me.Name <- "준영"
me.Pet <- myCat

그런데 이 방법도 꼼수이기는 매한가지다. 사용중에 NullReferenceException만 발생하지 않는다 뿐이지 ""Pet()같은 디폴트값은 실제론 쓸모가 없기 때문이다(이름이 ""인 사람이 이름이 ""인 동물을 가지고 있다니!?). 그래서 어차피 제대로 된 값으로 프로퍼티를 재초기화해야 하는데, 이렇게 하면 이전의 디폴트값 초기화는 헛수고가 된다.

null 대입도 없애고 중복 초기화도 막는 제대로 된 방법은 인스턴스를 생성할 때 프로퍼티를 함께 초기화하는 것이다:

type Pet(name) =
    member val Name: string = name with get, set

type Person(name, pet) =
    member val Name: string = name with get, set
    member val Pet: Pet = pet with get, set

let myCat = Pet("야옹이")
let me = Person("준영", myCat)

인스턴스의 생성과 초기화를 하나로 묶는 것은 프로그램이 비정상 상태에 놓이는 순간을 줄이는 아주 중요한 테크닉이다2.

null이 필요없지 않은 이유

사실 null을 모든 경우에 절대 쓰지 말아야 하는 것은 아니다. null이 문제가 되는 본질적인 이유는 변수가 초기화되지 않았음을 나타내는 값과 정확한 값을 결정할 수 없는 값이라는 중의적 의미로 쓰이기 때문이다. 이중 null이 불필요하고 위험한 경우는 전자, 즉 지난번 글에서 언급한 것처럼 아직 초기화되지 않은 변수를 메쏘드에 파라미터로 넘겨줄 때 등이다. 반면 메쏘드 실행 결과로 리턴하는 null은 후자에 속하며, 유용한 경우가 많이 있다.

예를 들어 영어 단어를 입력으로 받아 마지막 글자의 종성이 발음되는지 여부를 bool값으로 돌려주는 메쏘드를 만든다고 해보자. “computer” 같은 단어는 false를 리턴하면 되고, “pencil” 같은 단어는 true를 리턴하면 된다. 그런데 “net” 같은 단어는 “네트”로 읽으면 마지막 종성이 없고, “넷”으로 읽으면 종성이 있기 때문에 판단이 불가능하다. 이런 단어는 truefalse 대신 알 수 없음이란 값을 리턴하는 게 의미상으로 맞을 것이다. 이처럼 연산의 결과를 결정할 수 없는 상태를 리턴할 때는 null이 적합하다.

물론 연산의 결과를 결정할 수 없는 상태를 호출자에게 알려주는 표준화된 방법은 예외를 던지는 것이다. 그렇지만 그런 상황이 너무 자주 발생할 땐 예외가 성능을 떨어뜨리는 주원인이 되기도 한다. 유닛 테스트를 하면서 테스트 케이스를 수천개 이상 한번에 돌려본 분들은 아마 경험해 봤을 것이다. 성공율이 높을 땐 테스트가 빠르게 끝나는데 비해 실패율이 일정 이상 높아질 땐 어느 순간부터 컴퓨터가 엄청나게 버벅거리기 시작한다. 예외 처리에 드는 비용이 null 체크와는 비교도 안되게 크기 때문이다.

F#의 경우 null을 대체하는 목적으로 option 타입이라는 게 존재한다. 이 타입은 다른 F# 타입의 래퍼로 쓰이는데, 래핑한 인스턴스가 유효한 값을 가지고 있으면 Some이 되고, 그렇지 않으면 null에 상응하는 None이 된다.

재미있는 것은 애플의 최신 언어 Swift에서도 F#처럼 변수/상수에 nilnull의 또 다른 이름—을 대입하는 것을 제한해 놓았다는 사실이다. F#처럼 덜 초기화된 인스턴스의 생성도 금지되어 있고, F# option 타입과 같은 목적으로 이름도 비슷한 optional 타입이 존재한다. 차이점이라면 None 대신 nil을 그대로 쓰는 것과 문법이 C# nullable 타입과 유사하다는 정도.

그런데 여기까지 읽은 분들중에는 “저번 글에서는 메쏘드 리턴값으로 null을 리턴하면 안된다고 해놓고, 지금은 유용하다고 하고, 앞뒤가 안맞는 것 아니냐?”고 할 수도 있을 것 같다. 바로 그 점이 언어 차원에서 non-nullable/nullable 타입을 구별할 수 있는 구문을 제공해야 하는 이유다. 예를 들어 Swift에서는

let possibleNumber = "123"
let convertedNumber = possibleNumber.toInt()

처럼 문자열을 정수로 바꾸려고 할 때 toInt() 메쏘드가 optional 타입을 리턴한다. 따라서 이런 메쏘드들만 리턴값에 대해 nil 체크를 해주면 된다. optional 타입이 아닌 메쏘드는 nil을 절대 리턴하지 않기 때문에 당연히 리턴값을 nil 체크할 필요도 없다. 반면 자바나 C#은 많은 경우 호출한 메쏘드가 null을 리턴하는지 여부를 알 수 없다. 따라서 null 체크를 훨씬 빈번하게 해주어야 한다.

기존 언어에서의 해결책

언어 차원에서 null 대입을 금지시킬 방법이 없는 언어로는 딱히 해결책이 없는 것 같다. 굳이 꼽는다면 null 체크를 좀 더 편하게 하는 방법 정도가 있겠는데, 이것은 다음에 좀 더 자세히 소개하도록 하겠다.

실제로 C#에서는 “non-nullable” 레퍼런스 타입을 도입하려고 꽤 오랫동안 연구가 이루어졌었다. 한 예로 C# 계열의 실험적인 언어였던 Spec#에서는 변수를 선언할 때 타입명 뒤에 !를 붙여서 해당 인스턴스에 null을 대입할 수 없음을 명시하도록 했었다. 언뜻 괜찮은 아이디어 같은데…C#에는 채택되지 않았다. 아마도 !의 남용으로 소스가 지저분해지는 게 한가지 이유가 아닐까 싶다. 이미 기본 레퍼런스 타입을 “nullable”로 정한 다음에는 “non-nullable” 타입을 도입하기가 만만치 않다.

한가지 생각해 본 것은 변수를 선언할 때마다 일일히 붙일 것이 아니라 아예 타입을 만들 때

class Foo! {
    ...
}

Foo foo = null; // 컴파일 에러

처럼 이름 뒤에 !를 붙여서 null 대입을 금지시키는 것이다. null을 대입할 필요가 있는 일부 경우에만 기존 C# nullable 타입처럼 타입명 뒤에 ?를 붙인다:

class Foo! {
    ...
}

Foo? foo = null; // OK

이렇게 하고 나면 Swift와도 꽤 유사해진다(어차피 Swift도 C# 문법을 일부 차용한 것이니까). 따라서 나머지 용법은 Swift의 것을 그대로 가져오면 될 것 같다. ^^

그런데 이 타입을 도입할 경우 또 다른 문제가 생기는데—타입 파라미터의 한정어로 non-nullable 레퍼런스 타입을 표현할 방법이 현재로선 없다든지—얘기가 너무 길어지므로 여기선 생략.


  1. 사실은 배열을 만들 때처럼 메모리 공간이 null로 채워지는 경우가 있어서 F# 타입이라도 null 체크를 완전히 없앨 순 없다.
  2. 거꾸로 말하면 C/C++/C#/자바처럼 인스턴스의 비정상 상태를 허용하는 언어는 그만큼 프로그램이 비정상적으로 될 여지를 많이 남기게 된다고 할 수 있다.

댓글 없음:

댓글 쓰기

댓글을 입력하세요. 링크를 걸려면 <a href="">..</a> 태그를 쓰면 됩니다. <b>와 <i> 태그도 사용 가능합니다.

게시한지 14일이 지난 글에는 댓글이 등록되지 않습니다. 날짜를 반드시 확인해 주세요.