[Kotlin] 타입 시스템 : (1) Null 가능성

사명기·2020년 6월 23일
1

이번 글의 코드는 Github에 있습니다.


자바에 비해, 코틀린의 타입 시스템은 더 간결하고 가독성 좋은 몇 가지 기능을 제공합니다. 그 중에 대표적으로 널이 될 수 있는 타입(nullable type)읽기 전용 컬렉션이 있습니다. 이번 글에서는 코틀린에서 널이 될 수 있는 값을 어떻게 표기하고 코틀린이 제공하는 도구가 그런 널이 될 수 있는 값을 어떻게 처리하는지 알아보겠습니다.



1. Null 가능성



널 가능성(nullability)은 NullPointerException(이하 NPE) 오류를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성입니다.

코틀린을 비롯한 최신 언어에서 null에 대한 접근 방법은 가능한 한 이 문제를 실행시점에서 컴파일 시점으로 옮기는 것입니다. 널이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 여러 가지 오류를 컴파일 시 미리 감지해서 실행 시점에 발생할 수 있는 예외의 가능성을 줄일 수 있습니다.



1.1 널이 될 수 있는 타입

코틀린과 자바의 첫 번째이자 가장 중요한 차이는 코틀린 타입 시스템이 널이 될 수 있는 타입을 명시적으로 지원한다는 점입니다. 코틀린은 타입 이름 뒤에 물음표(?:Question Mark)를 명시함으로써 널이 될 수 있는 타입을 정의할 수 있습니다. 예를 들어 다음과 같이 사용 할 수 있습니다.

// name은 String 또는 null
val name: String? = null

이제 우리는 nullable한 프로퍼티를 정의할 수 있게 됐습니다. 다음으로는 메서드의 인자로 널이 될 수 있는 타입이 넘어올 때 어떻게 처리할 수 있는지 알아보겠습니다.

class StringCheck {

    // 컴파일 에러
    fun strLenUnsafe(str: String?) = str.length

    fun strLenSafe(str: String?) =
        if (str != null) str.length else 0
}

fun main() {
    val stringCheck = StringCheck()
    println(stringCheck.strLenSafe(null))
    println(stringCheck.strLenSafe("conas"))
    // 실행 결과
    // null
    // 5
}

위 코드에서 strLenUnsafe 메서드는 컴파일 에러가 납니다. 이유는 null이 될 수 있는 변수가 있다면 그에 대해 수행할 수 있는 연산이 제한되기 때문입니다. 즉, 메서드의 인자로 들어온 str이 널이 될 수 있는 타입이라서 length 연산을 할 수 없습니다. 예를 들어 널이 될 수 있는 타입인 변수에 대해 변수.메서드() or 변수.프로퍼티처럼 메서드 또는 프로퍼티를 직접 호출할 수는 없습니다.

따라서 strLenSafe 메서드처럼 파라미터에 대해 null이 아닌지 검사 후에야 내부로 진입할 수 있습니다. 하지만 널 가능성의 체크를 위해 조건문(if문)을 반복적으로 사용한다면 코드가 더러워(?)질 것입니다. 다행히 코틀린은 널이 될 수 있는 값을 다룰 때 도움이 되는 여러 도구를 제공합니다. 다음으로 그 도구 중 하나인 ?. 연산자에 대해 알아보겠습니다.

✏️ Check

  • 널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입할 수 없습니다.
  • 널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 파라미터를 받는 함수에 전달할 수 없습니다.


1.2 안전한 호출 연산자 👉 ?.

코틀린이 제공하는 가장 유용한 도구 중 하나가 안전한 호출 연산자인 ?. 입니다.
?.null 검사와 메서드 호출을 한 번의 연산으로 수행합니다.
예를 들어 str?.toUpperCase()
훨씬 더 복잡한 if(str!=null) str.toUpperCase() else null과 같습니다.

다시 말하자면, 호출하려는 값이 null이 아니라면 ?.는 일반 메서드 호출처럼 작동하고, 호출하려는 값이 null이면 이 호출은 무시되고 null이 결과 값이 됩니다.

✏️ Check
안전한 호출의 결과 타입도 널이 될 수 있는 타입입니다.
예를 들어, val result = str?.toUpperCase()이면 result는 널이 가능한 String 자료형이라는 것입니다.(String?)


메서드 호출뿐 아니라 프로퍼티를 읽거나 쓸 때도 안전한 호출을 사용할 수 있습니다. 그리고 객체 그래프에서 널이 될 수 있는 중간 객체가 여럿 있다면 한 식 안에서 안전한 호출을 연쇄해서 함께 사용하면 편할 때가 자주 있습니다. 예제 코드를 보겠습니다. (github: ChainingSafeCall 파일)

class Address(
    val streetAddress: String,
    val zipCode: Int,
    val city: String,
    val country: String
)

class Company(val name: String, val address: Address?)

class Person(val name: String, val company: Company?)

fun Person.countryName(): String {
    val country = this.company?.address?.country
    return if (country != null) country else "Unknown"
}

예제에서 this.company?.address?.country 처럼 안전한 호출을 연쇄해서 사용할 수 있다는 뜻입니다.
하지만 맨 마지막을 보면 country가 널인지 검사해서 정상적으로 얻은 country 값을 반환하거나 null인 경우에는 "Unknown"을 반환하도록 했습니다. 다음에 소개할 연산자를 사용하면 이런 if문도 없앨 수 있습니다.



1.3 엘비스 연산자 👉 ?:

코틀린은 null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 연산자를 제공합니다. 그 연산자를 엘비스(Elvis) 연산자라고 부르며, ?:로 표기합니다.

val result: String = str ?: "default"

이 연산자는 이항 연산자로 좌항을 계산한 값이 널인지 검사합니다. 좌항 값이 널이 아니면 좌항 값을 결과로 하고, 좌항 값이 널이면 우항 값을 결과로 합니다.


이 엘비스 연산자를 사용해 1.2Person.countryName 메서드를 고쳐보면 다음과 같습니다.

fun Person.countryName() = company?.address?.country ?: "Unknown"

3~4줄의 코드를 단 한 줄로 작성할 수 있다는 것이 놀랍지 않나요? (자바였으면 if( ~ != null && ~ !=null && ~ != null) 이런식으로 긴 코드가 됐을텐데... 👍)
그리고 저 메서드를 해석해보자면,
company,address 둘 중 하나라도 null이라면 "Unknown"을 반환하고, null이 없다면 country 값을 반환합니다. (현재 country는 non-nullable 프로퍼티)


✏️ Check
코틀린에서는 return 이나 throw 등의 연산도 식입니다.
따라서 엘비스 연산자의 우항에 return, throw 등의 연산을 넣을 수 있고, 엘비스 연산자를 더욱 편하게 쓸 수 있습니다.
이런 패턴은 함수의 전제 조건을 검사하는 경우 특히 유용합니다.




1.4 안전한 캐스트 👉 as?

as 는 코틀린에서 지원하는 타입 캐스트 연산자입니다. 자바 타입 캐스트와 마찬가지로 대상 값을 as로 지정한 타입으로 바꿀 수 없으면 ClassCastException이 발생합니다. is를 통해 선검사 후에 as로 변환 할 수도 있지만, 이런 과정이 반복되면 번거로워집니다. 따라서 코틀린은 as 뒤에 물음표를 붙인 as?를 지원합니다.

as? 연산자는 as와 마찬가지로 어떤 값을 지정한 타입으로 캐스트합니다.
as는 지정한 타입으로 바꿀 수 없으면 예외를 발생시키는 반면, as?는 타입으로 바꿀 수 없을 경우 null을 반환합니다.


안전한 캐스트를 사용할 때 일반적인 패턴은 캐스트를 수행한 뒤에 엘비스 연산자(?:)를 사용하는 것입니다. 예를 들어 equals를 구현할 때 이런 패턴이 유용합니다.

class Person(val name: String, val age: Int) {

    override fun equals(other: Any?): Boolean {
        val otherPerson = other as? Person ?: return false
        return this.name == otherPerson.name
                && this.age == otherPerson.age
    }

    override fun hashCode(): Int {
        var result = name.hashCode()
        result = 31 * result + age
        return result
    }
}


1.5 널 아님 단언 👉 !!

널 아님 단언(not-null assertion)은 어떤 값이든 널이 될 수 없는 타입으로 강제로 바꿀 수 있습니다.
!!로 표현하며 실제 null에 대해 !!을 적용하면 NPE가 발생합니다. 근본적으로 !!는 컴파일러에게 "나는 이 값이 null이 아님을 잘 알고 있다. 내가 잘못 생각했다면 예외가 발생해도 감수하겠다"라고 말하는 것입니다.


널 아님 단언문을 잘못 사용하면 NPE 발생시킬 위험도 있지만, 더 나은 해법인 경우도 있습니다. 어떤 함수가 값이 널인지 검사한 다음에 다른 함수를 호출한다고 해도 컴파일러는 호출된 함수 안에서 안전하게 그 값을 사용할 수 있음을 인식할 수 없습니다. 하지만 이런 경우 호출된 함수가 언제나 다른 함수에서 널이 아닌 값을 전달받는다는 사실이 분명하다면 굳이 널 검사를 다시 수행하고 싶지는 않을 것입니다. 이럴 때 널 아님 단언문을 쓸 수 있습니다. 극단적인 예를 들어보자면 아래와 같습니다.

val list = arrayListOf<String?>()

fun isValid() = list[0] != null

fun doSomething(){
    val value = list[0]!!
    // ...
}

if(isValid()) {
	doSomething()
}

이미 isValid 메서드에서 list[0]에 대해 널 검증을 했지만, 만약 doSomething 메서드에서 !!을 쓰지 않았다면 value는 널 가능한 타입이 됩니다. 이렇게 이미 검증을 하고 사용하는 경우엔 !!을 통해 non-nullable 프로퍼티로 사용할 수 있습니다.

✏️ 참고
!!을 연쇄로 사용할 경우 한 줄에 다 쓰지 말도록 하는 것이 좋습니다.
만약 person.company!!.address!!.country 여기서 NPE가 발생할 경우, 예외의 스택 트레이스로 해당 줄에서 예외가 발생했는지는 알 수 있지만 어떤 식에서 발생했는지는 확인할 수 없기 때문입니다. (company가 null이라서 예외가 발생했는지, address가 null이라서 예외가 발생했는지)



1.6 let 함수

지금까지는 널이 될 수 있는 타입의 값에 어떻게 접근하는지에 대해 주로 살펴봤습니다. 하지만 널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기려면 어떻게 해야 할까요?
그런 호출은 안전하지 않기 때문에 컴파일러는 그 호출을 허용하지 않습니다. 코틀린은 이런 경우 사용할 수 있는 함수가 let 함수입니다.

let 함수를 사용하면 널이 될 수 있는 식을 더 쉽게 다룰 수 있습니다. let 함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해서 결과가 널인지 검사한 다음에 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한꺼번에 처리할 수 있습니다. 예를 보겠습니다.

fun sendEmail(email: String) {
    // ...
}

val email: String? = ""

email?.let { sendEmail(it) }

sendEmail 메서드에서 email을 받는데 이것은 널이 될 수 없는 파라미터입니다.
하지만 아래에 선언한 email은 nullable한 프로퍼티죠. 이 email 프로퍼티를 sendEmail 메서드의 파라미터로 전달하면 Type mismatch 에러가 발생합니다.

let 함수는 자신의 수신 객체를 인자로 전달받은 람다에게 넘깁니다. 널이 될 수 있는 값에 대해 안전한 호출 구문을 사용해 let을 호출하되 널이 될 수 없는 타입의 인자로 받는 람다를 let에 전달합니다. 이렇게하면 널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 값으로 바꿔서 람다에 전달하게 됩니다.



1.7 나중에 초기화할 프로퍼티

객체 인스턴스를 일단 생성한 다음에 나중에 초기화하는 프레임워크가 많습니다. 예를 들어 제이유닛에서는 @Before로 어노테이션된 메서드 안에서 초기화 로직을 수행해야만 합니다.

하지만 코틀린에서 클래스 안의 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화하지 않고 특별한 메서드 안에서 초기화할 수는 없습니다. 코틀린에서는 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 합니다. 게다가 프로퍼티 타입이 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 그 프로퍼티를 초기화해야 합니다. 그런 초기화 값을 제공 할 수 없으면 널이 될 수 있는 타입을 사용할 수 밖에 없습니다.

class MyService {
   fun performAction(): String = "foo"
}

class MyServiceTest {

   private var myService:MyService? = null

   @BeforeEach
   fun setUp() {
       myService = MyService()
   }

   @Test
   fun test() {
       assertEquals("foo",myService!!.performAction())
   }
}

위의 코드를 보면, myService 프로퍼티에 null을 넣어 놓고 setUp에서 진짜 초깃값을 지정합니다. 이렇게 사용하면 myService를 사용 할 때마다 !! 또는 ?를 사용해야 합니다.

이를 해결하기 위해 myService 프로퍼티를 나중에 초기화(late-initialized)할 수 있습니다. lateinit 변경자를 붙이면 프로퍼티를 나중에 초기화할 수 있습니다.
위 코드를 lateinit을 사용해 고쳐보겠습니다.

class MyService {
   fun performAction(): String = "foo"
}

class MyServiceTest {

   private lateinit var myService:MyService

   @BeforeEach
   fun setUp() {
       myService = MyService()
   }

   @Test
   fun test() {
       assertEquals("foo",myService.performAction())
   }
}

나중에 초기화하는 프로퍼티는 항상 var여야 합니다.




1.8 널이 될 수 있는 타입 확장

널이 될 수 있는 타입에 대한 확장 함수를 정의하면 null 값을 다루는 강력한 도구로 활용할 수 있습니다. 어떤 메서드를 호출하기 전에 수신 객체 역할을 하는 변수가 널이 될 수 없다고 보장하는 대신, 직접 변수에 대해 메서드를 호출해도 확장 함수인 메서드가 알아서 널을 처리해줍니다. 이런 처리는 확장 함수에서만 가능합니다. 일반 멤버 호출은 객체 인스턴스를 통해 디스패치 되므로 그 인스턴스가 널인지 여부를 검사하지 않습니다.

예를 들어 코틀린 라이브러리에서 String을 확장해 정의된 함수들이 있습니다. 아래는 그 예입니다. (String.kt 파일)

위를 보면 isEmpty 메서드는 String 타입에,
isNullOrEmptyisNullOrBlank 메서드는 String? 타입에 대해 확장 함수로 구현되어 있습니다.
이는 isEmpty 메서드는 널이 될 수 없는 String 타입에만 사용할 수 있으며, isNullOrEmptyisNullOrBlank 메서드는 널이 될 수 없는 타입과 널이 될 수 있는 타입 모두 사용 가능하다는 뜻입니다.
그리고 이런 확장 함수는 널이 될 수 있는 프로퍼티에 대해 안전한 호출을 할 필요가 없어집니다.
예를 보겠습니다.

fun verifyUserInput(input: String?) {
   
   // input?.isNullOrBlank() 이렇게 안전한 호출을 할 필요가 없다.
   if (input.isNullOrBlank()) {
       println("User Input Error")
   }
   // ...
}


1.9 타입 파라미터의 널 가능성

코틀린에서 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있습니다. 널이 될 수 있는 타입을 포함하는 어떤 타입이라도 타입 파라미터를 대신할 수 있습니다. 따라서 타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용하면 이름 끝에 물음표가 없더라도 T가 널이 될 수 있는 타입입니다.

따라서 타입 파라미터가 널이 아님을 확실히 하려면 널이 될 수 없는 타입 상한을 지정해야 합니다. 아래의 두번째 메서드에서 T:Any 처럼 말이죠.

// t는 Any? 타입이 된다.
// 따라서 t가 null일 수 있으므로 안전한 호출을 해야한다.
fun <T> printHashCode(t:T) {
    println(t?.hashCode())
}

// T는 널이 될 수 없는 타입이므로 안전한 호출을 할 필요가 없다.
fun <T:Any> printHashCode(t:T) {
    println(t.hashCode())
}


📖 Reference

  • 도서: Kotlin in Action

0개의 댓글