본문 바로가기
kotlin

[Kotlin in Action] 6장: 코틀린 타입 시스템 > 6.1 널과 관련한 이야기

by 권성호 2023. 3. 18.

6.1 널 가능성

  • NPE는 Null 개념이 있는 모든 언어에서 하기 쉬운 실수이며, 했을 때 매우 치명적임
  • 코틀린을 비롯한 최신 언어에서 null에 대한 접근 방법은 가능한 이 문제를 런타임에서 컴파일 시점으로 옮기는 것
  • 널이 될 수 있는지 여부를 타입 시스템에 포함시켜, 컴파일러가 여러 가지 오류를 컴파일 시 미리 감지할 수 있도록 하는 방향
  • 6.1의 포인트
    • 코틀린에서 널이 될 수 있는 타입과 없는 타입의 구분
    • 널이 될 수 있는 타입에 대한 컴파일 시점의 처리들
    • 널 처리 측면에서 자바 코드와 코틀린 코드의 호완성

6.1.1 널이 될 수 있는 타입

  • 코틀린은 자바와 달리 널이 될 수 있는 것과 없는 것을 타입 수준에서 구분한다.
    • {특정 타입}에? 를 붙이면 널이 될 수 있는 타입이라는 의미
  • 타입 수준에서 구분함으로써, 널과 관련된 검증을 런타임이 아니라 컴파일 타임에 수행할 수 있게 되었다.
  • 특정 변수 타입이 널이 될 수 없는 타입으로 선언된 경우, 해당 변수를 사용하는 입장에서 널 처리를 신경쓰지 않아도 되는 이점이 있다.(-> 코드의 가독성 향상)
  • 특정 변수 타입이 널이 될 수 있는 타입으로 선언된 경우, 해당 변수를 사용하는 입장에서 반드시 널 처리를 하도록 강제하여, 널 처리를 안해서 발생하는 문제를 방지할 수 있다.(-> 코드의 안정성 향상)
  • 널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입할 수 없다.
    • 반대는 가능하다.
fun main() {
    var nullableName: String? = null
    var name: String = "haha"
    nullableName = name
    name = nullableName // 1번. 이건 되네???
}

fun func1(nullableName: String?) {
    var name: String = nullableName // 2번. Type mismatch. Required:String Found:String?
}
  • 1번의 경우 널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입했는데 잘 동작했다?!
    • nullableName 이 위에서 한번 null 이 아닌 값으로 초기화되었고 코틀린 컴파일러가 똑똑하게 널이 아니라고 인식한 것 같다.
  • 널이 가능한 타입의 경우 일반적으로 널과 관련된 처리가 코틀린 컴파일러에 의해 강제되어 코드의 안정성을 확보할 수 있지만, 항상 널과 관련된 처리를 하는 것은 코드를 번잡하게 만들 수도 있다.
  • 널이 될 수 있는 타입이더라도 코틀린 컴파일러에게 해당 변수가 널이 아니라는 힌트를 줄 수 있다면, 코틀린 컴파일러는 널과 관련된 처리를 강제하지 않는다.
  • 예를들어 아래 메소드의 마지막 줄은 코틀린 컴파일러에 의해 정상적으로 컴파일된다.
fun strLenSafe(s: String?): Int {
    println(s.length)   // <- Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
    if(s != null) s.length else 0   // <- 정상 동작
}
  • 널 가능성을 다루기 위해 사용할 수 있는 도구가 if 검사뿐이라면 코드가 번잡해지는 일을 피할 수 없을 것이다.
  • 다행히 코틀린은 널이 될 수 있는 값을 다룰 때 도움이 되는 여러 도구를 제공한다.(관련 내용은 6.1.3 부터 다룬다.)
  • 그전에, 널 가능성과 변수 타입의 의미에 대해 짚고 넘어가 보자.

6.1.2 타입의 의미

  • 위키피디아에 의하면, 타입은 어떤 값들이 가능한지와 그 타입에 대해 수행할 수 있는 연산의 종류를 결정한다.
  • 이런 정의를 자바 타입 중 몇 가지에 적용해 보자
  • double
    • 들어갈 수 있는 값: 64비트 부동소수점 수
    • 수행할 수 있는 연산: 일반 수학 연산
    • 따라서 double 타입의 변수가 있고 그 변수에 대한 연산을 컴파일러가 통과시킨 경우 런타임에서 성공적으로 실행될 거라는 확신이 가능하다.
  • String
    • 들어갈 수 있는 값: 문자열 또는 널
    • 수행할 수 있는 연산
      • 문자열이 들어갔을 때: 문자열 관련 연산
      • 널이 들어갔을 때: 문자열 관련 연산이 불가함
    • 타입 String에 대해 들어갈 수 있는 값과 수행할 수 있는 연산이 런타임에 결정되기 때문에, 컴파일러가 String에 대한 연산을 통과시킨 경우 런타임에 성공적으로 실행될 거라는 확신이 없다.
    • 따라서, 자바 타입 시스템과 컴파일러에게 의존하는 것이 아니라 개발자 개개인에 의해 널 처리가 이루어져야 한다.
  • 널이 될 수 있는 타입과 없는 타입을 구분하면 각 타입의 값에 대해 어떤 연산이 가능할지 명확해지고, 실행 시점에 예외를 발생시킬 수 있는 연산을 컴파일 시점에 판단할 수 있다.

6.1.3 안전한 호출(Safe-Call) 연산자: .?

  • safe-call(.?) 은 null 검사와 메서드 호출을 한 번의 연산으로 수행한다.
  • 아래 두 가지 구분은 같은 의미를 가진다.
    s?.toUpperCase()	// s 가 null 이 아니면 toUpperCase 메소드를 수행, null 이면 null 을 반환
    if(s != null)
        s.toUpperCase()
  • safe-call 호출의 결과 타입도 널이 될 수 있는 타입이다.
fun func2(s: String?) {
    val toUpperCase: String? = s?.toUpperCase()
}

fun func3(s: String?) {
    val toUpperCase: String = s?.toUpperCase()  // <- Type mismatch. Required:String Found:String?
}

fun func4(s: String) {
    val toUpperCase: String = s?.toUpperCase()
}
  • safe-call 호출 결과 널이 될 수 있다면, 널이 될 수 있는 타입으로 받아줘야 한다.
  • 아래 예시와 같이 safe-call을 연쇄적으로 사용할 수도 있다.
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 countryName(): String {
        val country = this.company?.address?.country	// <- 연쇄적 safe-call
        return if (country != null) country else "Unknown"	// <- 요것도 깔끔하게 작성하고싶다!!!
    }
}
  • 연쇄적 safe-call 이 없었다면 객체 그래프를 탐색하는 과정에서 모두 if 문을 통해 null 검사를 수행했을 것이다.
  • countryName 메서드의 마지막 줄을 보면 if 문이 사용되고 있는데, 이것도 간결하게 작성할 수 있다.

6.1.4 엘비스 연산자:?:

  • 코틀린은 널이 가능한 타입에서 널 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 엘비스 연산자(?:)를 제공한다.
  • 엘비스 연산자를 사용해 위 코드를 개선하면 아래와 같다.
    fun countryName(): String {
        val country = this.company?.address?.country
        // return if (country != null) country else "Unknown"
        return country ?: "Unknown"
    }
  • 코틀린에서 return & throw 등의 연산도 식이기 때문에, 엘비스 연산자의 우항에 올 수 있고, 이로 인해 엘비스 연산자를 더욱 효과적으로 사용할 수 있게 된다.
    @Transactional(readOnly = true)
    override fun loadUserByUsername(emailOrNickname: String): UserDetails {
        val account = accountRepository.findByEmail(emailOrNickname)    // <- 이메일로 먼저 찾아보고
            ?: accountRepository.findByNickname(emailOrNickname)        // <- 없으면, 닉네임으로 찾아보고
            ?: throw UsernameNotFoundException(emailOrNickname)         // <- 그래도 없으면 예외를 던진다

        return UserAccount(account)
    }

6.1.5 안전한 캐스트: as?

  • 코틀린에서도 자바 타입 캐스트와 마찬가지로 대상 값을 as로 지정한 타입으로 바꿀 수 없으면 ClassCastException 이 발생한다.
fun main() {
    val s: String = "fsdfds"
    val person = s as Person    // <- ClassCastException 발생!
}
  • as? 연산자는 어떤 값을 지정한 타입으로 캐스트 하는데, 변환할 수 없으면 널을 반환한다.
  • 안전한 캐스트를 사용할 때 일반적인 패턴은 캐스트를 수행한 뒤에 엘비스 연산자를 사용하는 것이다.
  • 예를 들어 equals를 구현할 때 이런 패턴이 유용하다.
class Person(
    val name: String,
){
    override fun equals(other: Any?): Boolean {
        val otherPerson = other as? Person ?: return false	// <- 안전한 캐스트 + 엘비스 연산자 조합
        return otherPerson.name == this.name
    }
}

6.1.6 널 아님 단언:!!

  • .?, as?,?: 연산은 코틀린 코드에서 널 처리를 위해 자주 등장한다.
  • 하지만 때로는 코틀린의 널 처리 지원을 활용하는 대신 직접 컴파일러에게 어떤 값이 널이 아니라는 사실을 알려주고 싶은 경우가 있다.
    • 값이 널이 아니라고 확신하는 상황에서, 널 처리로 인해 코드가 더 복잡해 질 수도 있으니까..
  • 널 아님 단언(!!) 연산은 코틀린에서 널이 될 수 있는 타입이지만 그 상황에서는 널이 아니라고 확신할 수 있을 때, 코틀린 컴파일러에게 널이 아니라고 알려주는 연산이다.
  • 널 아님 단언을 사용한 변수가 널이라면 코틀린 컴파일러는 이를 잡지 못하고 런타임에 NPE 가 발생한다.
    • NPE 가 발생할 때, 자바와는 달리 사용하는 쪽에서 발생하지 않고!! 를 선언한 곳에서 발생한다.

6.1.7 let 함수

  • 일반적으로, 널을 허용하지 않는 타입을 인자로 받는 메소드에 널이 가능한 변수를 넘길 수 없다.
  • 따라서, 함수 호출 전에 if(s!= null)과 같은 널 검사를 해야 한다.
  • 하지만 let와 safe-call을 조합해서 사용하면 보다 간결한 코드를 작성할 수 있다.
fun main() {
    var email: String? = "kaka@akak.com"
    sendEmailTo(email)  // <- Type mismatch. Required: String Found: String?
    email?.let { sendEmailTo(it) }
}

fun sendEmailTo(email: String) {
    println("send to ${email}")
}

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

  • 널이 불가한 타입으로 선언하고 싶지만, 생성자 안에서 널이 아닌 값으로 초기화할 방법이 없는 경우가 있을 수 있다.
    • 코틀린에서 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 한다.
    • 게다가 프로퍼티 타입이 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 초기화해야  한다.
    • 그런 초기화 값을 제공할 수 없으면 널이 될 수 있는 타입을 사용할 수 밖에 없다.
    • 하지만 널이 될 수 있는 타입을 사용하면 모든 프로퍼티 접근에 널 검사를 넣거나!! 연산자를 써야 한다.(-> 코드가 더 복잡해진다)
  • 이러한 문제를 해결하기 위해 코틀린은 특정 프로퍼티를 나중에 초기화할 수 있도록 허용해 준다.
  • lateinit 변경자를 붙이면 프로퍼티를 나중에 초기화할 수 있다.
  • 나중에 초기화하는 프로퍼티는 항상 var여야 한다.
  • lateinit으로 선언된 프로퍼티를 초기화하기 전에 접근하면 런타임에 "lateinit property ~~ has not been initailized" 예외가 발생한다.
fun main() {
    lateinit var lateinitVar: String
    println(lateinitVar)    // <- 런타임에 lateinit property lateinitVar has not been initialized
}
  • lateinit을 사용하면 변수가 초기화 되었는지 컴파일타임에 확인해주지 않고 런타임에 예외가 발생한다.
  • 따라서, lateinit 을 사용한 변수에 대해 초기화 후 사용해야 한다는 원칙은 컴파일러가 잡아주는 것이 아니라 개발자가 신경 써야 한다.
  • 이는 실수의 여지를 허용하는 것이고, 따라서 가급적 lateinit 은 사용하지 않는 게 좋아 보인다.

6.1.9 널이 될 수 있는 타입 확장

  • 널이 될 수 있는 타입에 대해 매번 ?., ?: 등과 같은 처리를 해주는 것은 생각보다 복잡할 수 있다.
  • 따라서, 이러한 처리를 확장함수를 정의해 숨겨 코드를 좀 더 간결하게 작성할 수도 있다.
fun main() {
    var s: String? = "sss"
    s?.isBlank()        // <- safe-call 사용
    s.isNullOrBlank()   // <- safe-call 사용안함
}

@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrBlank(): Boolean {
    contract {
        returns(false) implies (this@isNullOrBlank != null)
    }

    return this == null || this.isBlank()
}

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

  • 코틀린에서 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있다.
  • 따라서 타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용하면 이름 끝에 물음표가 없더라도 T가 널이 될 수 있는 타입니다.
fun <T> printHashCode(t: T) {
    println(t?.hashCode())  // <- t 가 null 이 될 수 있으므로 안전한 호출을 써야만 한다.
    println(t.hashCode())   // <- 하지만, 코틀린 컴파일러는 안전한 호출 없이도 이 구문을 허용한다.
                            // <- 사실, 이는 널이 가능한 변수에 대해 별도 확장함수를 정의한것
}

fun main() {
    printHashCode(null)
}
  • 타입 파라미터가 널이 아님을 확실히 하려면 널이 될 수 없는 타입 상한을 지정해야 한다.
fun <T: Any> printHashCode(t: T) {
    println(t.hashCode())  // <- 이제 T 는 널이 될 수 없는 타입이다.
}

fun main() {
    printHashCode(null) // <- Null can not be a value of a non-null type TypeVariable(T)
}
  • 타입 파라미터는 널이 될 수 있는 타입을 표시하려면 반드시 물음표를 타입 이름 뒤에 붙여야 한다는 규칙의 유일한 예외다.

6.1.11 널 가능성과 자바

자바의 널과 관련한 어노테이션

  • 코틀린은 자바 코드에서 사용된 여러 널 가능성 관련 어노테이션을 알아본다.
    • JSR-305 표준(javax.annotation 패키지)
    • 안드로이드(android.support.annotation 패키지)
    • 젯브레인스(org.jetbrains.annotation 패키지)
  • 이런 널 가능성 관련 어노테이션이 자바 코드에 있다면 코틀린은 이를 인지해 널 관련 처리를 컴파일 타임에 검증할 수 있다.

자바의 널 관련 어노테이션이 없는 타입의 경우 코틀린은 이를 플랫폼 타입으로 인지한다.

  • 플랫폼 타입이란, 코틀린 입장에서 해당 변수 타입이 널 허용인지, 불가인지 구분하지 못하기 때문에 구분과 검증에 대한 책임을 개발자한테 넘긴다는 의미로 해석할 수 있다.
  • 따라서, 플랫폼 타입 변수를 다루는 개발자는 항상 널 가능성을 염두에 두고 개발해야 한다.
  • 코드의 안정성 측면에서, 자바의 모든 타입을 널 가능 타입으로 해석하면 될 텐데 왜 플랫폼 타입을 도입했을까?
    • 자바의 모든 타입을 널 가능 타입으로 선언하게 되면 부가 어노테이션이 없는 자바 코드를 사용하는 코틀린 코드에는 모두 널 관련 검증이 포함되어야 한다.
    • 이는 코드를 복잡하게 만든다.
    • 따라서, 안정성과 생산성의 트레이드오프 상황에서 자바와 호완 되는 부분에 널 가능성 확인에 대한 책임은 개발자에게 넘기는 것이 생산성 측면에서 더 낫다는 판단을 한 것 같다.

코틀린에서 자바 클래스를 상속받아 메소드를 오버라이딩 하면, 그 시점에 메소드의 파라미터와 반환 타입을 널이 될 수 있는 타입으로 선언할지 널이 될 수 없는 타입으로 선언할지 결정해야 한다.

 

 

 

 

댓글