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
인스턴스를 생성하긴 했지만 Name
과 Pet
프로퍼티는 아직 둘 다 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# 타입인 Pet
은 null
대입이 불가능하므로 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” 같은 단어는 “네트”로 읽으면 마지막 종성이 없고, “넷”으로 읽으면 종성이 있기 때문에 판단이 불가능하다. 이런 단어는 true
나 false
대신 알 수 없음이란 값을 리턴하는 게 의미상으로 맞을 것이다. 이처럼 연산의 결과를 결정할 수 없는 상태를 리턴할 때는 null
이 적합하다.
물론 연산의 결과를 결정할 수 없는 상태를 호출자에게 알려주는 표준화된 방법은 예외를 던지는 것이다. 그렇지만 그런 상황이 너무 자주 발생할 땐 예외가 성능을 떨어뜨리는 주원인이 되기도 한다. 유닛 테스트를 하면서 테스트 케이스를 수천개 이상 한번에 돌려본 분들은 아마 경험해 봤을 것이다. 성공율이 높을 땐 테스트가 빠르게 끝나는데 비해 실패율이 일정 이상 높아질 땐 어느 순간부터 컴퓨터가 엄청나게 버벅거리기 시작한다. 예외 처리에 드는 비용이 null
체크와는 비교도 안되게 크기 때문이다.
F#의 경우 null
을 대체하는 목적으로 option
타입이라는 게 존재한다. 이 타입은 다른 F# 타입의 래퍼로 쓰이는데, 래핑한 인스턴스가 유효한 값을 가지고 있으면 Some
이 되고, 그렇지 않으면 null
에 상응하는 None
이 된다.
재미있는 것은 애플의 최신 언어 Swift에서도 F#처럼 변수/상수에 nil
—null
의 또 다른 이름—을 대입하는 것을 제한해 놓았다는 사실이다. 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 레퍼런스 타입을 표현할 방법이 현재로선 없다든지—얘기가 너무 길어지므로 여기선 생략.
댓글 없음:
댓글 쓰기
댓글을 입력하세요. 링크를 걸려면 <a href="">..</a> 태그를 쓰면 됩니다. <b>와 <i> 태그도 사용 가능합니다.
게시한지 14일이 지난 글에는 댓글이 등록되지 않습니다. 날짜를 반드시 확인해 주세요.