Kotlin

[Kotlin] Nullability(널 가능성) - 1

sh1mj1 2024. 1. 18. 14:19

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

 

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

코틀린과 다른 최신 언어에서 null 에 대한 접근을 런타임에서 컴파일 타임으로 옮겼다.

그래서 컴파일 시 NPE 여부를 미리 검사하여 NPE 예외 가능성을 줄인다.

 

이번 글은 볼륨이 꽤 되어서 두 개의 글로 나누어서 설명한다.

다음 글: https://sh1mj1-log.tistory.com/212

 

[Kotlin] Nullability(널 가능성) -2

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다. 이전 글에서 이어진다. 이전 글 : https://sh1mj1-log.tistory.com/211 [Kotlin] Nullability(널 가능성) - 1 Kotlin in Action 을 공부

sh1mj1-log.tistory.com

Nullable Type(널이 될 수 있는 타입)

자바와 달리 코틀린에서는 nullable type 을 명시적으로 지원한다.

(자바 8에서도 `Optional` 을 제공하여 null 방지를 할 수 있기는 하지만, 불편하다)

코틀린은 `Nullable` 타입에 대해 메서드를 호출하는 것을 간단히 금지시킬 수 있다.

fun strLen(s: String) = s.length

 이 함수에서는 `s` 에 null 이 들어오면 컴파일 시 오류가 발생한다.

 

fun strLenSafe(s: String?) = ...

 이렇게 타입 뒤에 `?` 를 붙이면 그 타입의 변수/프로퍼티에 null 참조를 저장할 수 있다.

반대로 `?` 가 없으면 null 참조를 저장할 수 없다.

 

// [ERROR] Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
fun strLenSafe(s: String?) = s.length 

val x: String? = null
var y: String = x // [ERROR] Type mismatch: inferred type is String? but String was expected
strLen(x) // [ERROR] Type mismatch: inferred type is String? but String was expected
  • nullable 타입 변수에 대해 `변수.메서드()` 처럼 메서드를 직접 호출할 수 없다.
  • nullable 값을 not-null 타입 변수에 대입할 수 없다.
  • nullable 타입 값을 not-null 타입의 파라미터를 받는 함수에 전달할 수 없다.

`if` 검사를 통해 null 값 다루기

fun strLenSafe(s: String?): Int =
    if (s != null) s.length else 0

nullable 함수는 함수 바디에서 null-check 를 하고 연산을 수행하면 된다.

이렇게 하면 코드가 번잡하다.

코틀린에서는 nullalbe 값을 다룰 때 도움이 되는 여러 도구를 제공한다.

타입의 의미

nullalbe 관련 도구를 보기 전에, 먼저 타입의 의미를 생각해보자.

타입은 classfication(분류)로, 어떤 값이 가능한지와 . 그 타입에 대해 수행할 수 있는 연산의 종류를 결정한다. - 위키피디아

자바의 `double` 타입과 `String` 타입의 변수에는 null 이라는 두 종류의 값이 들어갈 수 있다.

하지만 자바의 `instanceOf` 연산자는 null 은 `String` 이 아니라고 한다.

또한 null 에 대해 호출할 수 있는 메서드도 많지 않다.

`String` 타입의 변수가 있을 때, null-check 을 추가로 하기 전에는 그 변수에 대해 어떤 연산을 수행하지 못한다.

 

즉, 자바의 타입 시스템은 null 을 제대로 다루지 못한다.

자바에서 NPE 다루는 방법

애노테이션으로 해결

자바에서도 NPE 문제를 해결하는데 도움이 되는 도구가 있다.

`@Nullable` 이나 `@NotNull` 처럼 애노테이션을 사용해 값의 nullable 여부를 표시할 수 있다.

하지만 이는 표준 자바 컴파일 절차가 아니기 때문에 일관성을 보장하기 어렵다.

또한 일일이 애노테이션을 추가하는 것은 번잡하다.

자바 8 의 Optional

`Optional` 은 어떤 값이 정의되거나 정의되지 않을 수 있음을 표현하는 타입이다.

null 을 코드에서 쓰지 않고, 자바 8 의 `Optional` 타입의 null 을 감싸는 Wrapper 타입을 활용할 수 있다.

하지만 이 해법에도 단점이 있다.

  • 코드가 더 지저분해진다.
  • wrapper 가 추가되어 런타임 성능이 저하된다.
  • 전체 에코 시스템에서 일관성있게 활용하기 어렵다.

그렇기 때문에 코틀린의 타입 시스템이 null 을 다루기에 더 편리하다.

코틀린에서는 not-null 타입과 nullable 타입을 구분한다.

이로써

  • 각 타입의 값에 대해 어떤 연산이 가능할지 명확히 이해할 수 있다.
  • 런타임에 예외를 발생시킬 수 있는 연산을 미리 판단할 수 있다.
  • 따라서 그런 연산을 아예 금지시킬 수 있다.

런타임에서는 nullable 타입과 not-null 타입의 객체는 같다. 즉, nullable 타입이 Wrapper 타입이 아니다.

컴파일 타임에서 모든 검사를 수행하기 때문에 nullable 타입을 처리하는데 별도의 런타임 부가 비용이 들지 않는다.

safe call operator (?.)

safe call operator(`?.`)  null-check 와 메서드 호출을 한 번의 연산으로 수행한다.

s?.toUpperCase()
// == 
if (s != null) s.toUpperCase() else null

 위 두 코드라인은 같다. 

`s` 가 not-null 이면, `?.` 는 일반 메서드 호출처럼 작동한다.

`s` 가 null 이면, 호출은 무시되고 null 이 리턴값이 된다.

그러므로 safe call 의 결과 타입도 nullable 타입이다.

 

nullable 프로퍼티를 다루기 위해 safe call 사용

class Employee(val name: String, val manager: Employee?)
fun managerName(employee: Employee): String? = employee.manager?.name

val ceo = Employee("Da Boss", null)
val developer = Employee("Bob Smith", ceo)
assert(managerName(developer) == ceo.name)
assert(managerName(ceo) == null)

 프로퍼티를 읽거나 쓸 때도 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 Person.countryName(): String {
    val country = this.company?.address?.country
    return if (country != null) country else "Unknown"
}

val person = Person("Dmitry", null)
assert(person.countryName() == "Unknown")

코틀린에서는 null-check 가 들어간 호출이 연달아 있는 경우에도 if 문을 연쇄하지 않아도 된다.

이렇게 더 간결하게 null-check 가 가능하다.

Elvis(엘비스) 연산자(`?:`)

위 코드에서 마지막 `return if` 문도 간결하게 수정이 가능하다.

코틀린은 null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 엘비스 연산자(`?:`)를 제공한다.

 

엘비스 연산자로 null 값 다루기

fun foo(s: String?): String = s ?: ""

 그렇다면 위 `countryName` 함수도 한 줄로 고칠 수 있다.

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

 

`throw` 와 엘비스 연산자 함께 사용하기

fun printShippingLabel(person: Person) {
    val address = person.company?.address ?: throw IllegalArgumentException("No address") // 주소가 없으면 예외 던짐
    with(address) { // 여기서는 address 가 not-null
        println(streetAddress)
        println("$zipCode $city, $country")
    }
}

val address = Address("Elseter. 47", 80687, "Munich", "Germany")
val jetbrains = Company("JetBrains", address)
val person = Person("Dmitry", jetbrains)
printShippingLabel(person)
// print
/*
Elseter. 47
80687 Munich, Germany
*/

코틀린에서는 `return`, `throw` 연산도 식(expression)이므로 엘비스 연산자의 우항에도 이를 넣을 수 있어 편리하다.

Safe cast(`as?`)

이전에 명시적 타입 캐스트 연산자 `as` 에 대해 알아보았다.(이전 글)

자바와 타입 캐스트처럼 코틀린에서도 `as` 로 지정한 타입으로 바꿀 수 없으면 `ClassCaseException` 이 발생한다.

`as` 로 캐스팅 전에 `is` 를 통해 미리 변환 가능한지 체크할 수도 있다.

하지만 이는 번잡하다.

 

`as?` 연산자는 어떤 값을 지정한 타입으로 캐스트하고 변환할 수 없으면 null 을 리턴한다.

safe cast 를 사용할 때 일반적인 패턴으로는 safe cast 수행 후, 엘비스 연산자(`?:` ) 를 사용하는 것이다.

 

safe call 을 이용해 `equals` 구현하기

class Person(val firstName: String, val lastName: String) {
    override fun equals(other: Any?): Boolean {
        val otherPerson = other as? Person ?: return false
        return otherPerson.firstName == firstName && otherPerson.lastName == lastName
    }

    override fun hashCode(): Int =
        firstName.hashCode() * 37 + lastName.hashCode()
}

val p1 = Person("Dmitry", "Jemrov")
val p2 = Person("Dmitry", "Jemrov")
assertTrue(p1 == p2)
assertFalse(p1.equals(42))

 

이렇게 safe call, safe cast, 엘비스 연산자는 유용하기 때문에 자주 보일 것이다.

not-null assertion(널 아님 단언): `!!`

가끔 위와 같은 코틀린의 null 처리 지원을 활용하는 대신, 직접 컴파일러에게 어떤 값이 not-null 이라고 알려주고 싶을 때도 있다.

not-null assertion 은 코틀린에서 nullable 값을 다룰 때 사용할 수 있는 가장 단순하면서 둔한 도구이다.

`!!` 로 사용하면 어떤 값이든 not-null 값으로 강제로 바꿀 수 있다.

실제 null 에 대해 `!!` 를 사용하면 NPE 가 발생한다.

 

not-null assertion 사용하기

fun ignoreNulls(s: String?): String = s!!

ignoreNulls(null) // [런타임 ERROR] java.lang.NullPointerException

`!!` 는 근본적으로 컴파일러에게 "나는 이 값이 not-null 이라는 것을 알고 있어. 내가 잘못 생각했다면 런타임에 예외가 발생해도 감수하겠어"  라고 말하는 것이다.

보통은 이를 사용하지 않는 것이 좋다.

정말 가끔은 not-null assertion 사용이 더 나을 때도 있다.

스윙 액션에서 not-null assertion 사용하기

class CopyRowAction(val list: JList<String>): AbstractAction() {
    override fun isEnabled(): Boolean = 
        list.setlectedValue != null
    override fun actionPerformed(e: ActionEvent) {
        val value = list.selectedValue!! // actionPerformed 는 isEnabled 가 true 인 경우에만 호출됨
        // value 를 클립보드에 복사하는 동작
    }
}

위는 스윙이라는 UI 프레임워크의 액션 클래스이다.

액션 API 는 `isEnabled` 가 `true` 인 경우, `actionPerformed` 를 호출해준다고 가정하자.

이런 경우 `actionPerformed` 의 바디 안에서는 `selectedValue` 에 대해 null-check 를 하는 safe call 을 할 필요가 없다.

not-null assertion 예외의 stack trace(스택 트레이스)

`!!` 를 null 에 대해 사용해서 발생하는 예외의 스택 트레이스에는

어떤 파일의 몇번째 줄인지에 대한 정보는 들어있지만, 어떤 식에서 예외가 발생했는지에 대한 정보는 없다.

즉, 어떤 값이 null 이었는지 확실히 알 수 없다. 

그러므로 아래처럼 여러 `!!` 단언문을 한 줄에 함께 쓰지 말자.

person.company!!.address!!.country // 이렇게 한 줄에 !! 쓰지 말자

 

일반적으로 `!!` 연산자 사용을 피해야 한다. 

이는 코틀린 커뮤니티 전체에서 널리 승인되고 있다.

대부분의 팀이 `!!` 연산자를 아예 사용하지 못하게 하는 정책을 갖고 있다.

이는 예외가 발생할 때 어떤 설명도 없는 제네릭 예외(generic exception)가 발생한다.

의미없는 nullability 를 피하자

nullability 는 어떻게든 처리해야 하므로, 추가 비용이 발생한다.

꼭 필요한 경우가 아니면, nullability 를 피하자.

아래는 의미없는 nullability 를 피하는 여러 해법이다.

  • 클래스에서 nullability 에 따라 여러 함수를 만들어서 제공할 수 있다.
    • ex: `List<T>` 의 `get` 과 `getOrNull` 함수
  • 어떤 값이 클래스 생성 이후에 확실하게 설정된다는 보장이 있다면, `lateinit` 프로퍼티 혹은 `notNull Delegate` 를 사용하자.
  • null 대신에 빈 컬렉션을 리턴하자. 컬렉션 처리 시 원소가 없다는 것을 나타내려면 빈 컬렉션을 사용하자.
  • null enum 과 `None` enum 값은 완전히 다른 의미이다.
    • null enum 은 별도로 처리해야 한다.
    • `None` enum 은 필요한 경우에 사용하는 쪽에서 추가해서 활용할 수 있다.

let 함수

`let` 함수를 safe call operator 와 함께 사용하면,

원하는 식을 연산하고 그 결과를 null-check 한 후에 그 결과를 변수에 넣는 작업을 한꺼번에 간단히 처리할 수 있다.

이전 글에서 let 과 safe call operator 를 사용하는 것을 간단히 다루었다.

 

nullable 값을 not-null 값만 인자로 받는 함수에 넘기기

fun sendEmailTo(email: String) {
    println("구현 $email")
}

val email: String? = "someEmail@kotlin.org"
sendEmailTo(email) // [ERROR] Type mismatch: inferred type is String? but String was expected

// 단순 if 문
if (email != null) sendEmailTo(email) // print /* 구현 someEmail@kotlin.org */

// nullable 타입, not-null 값을 ?.let
email?.let { email -> sendEmailTo(email) } // print /* 구현 someEmail@kotlin.org */
// 혹은 아래처럼
// email?.let { sendEmailTo(it) }

// 실제 null 에 대해 ?.let
val emailNull = null
emailNull?.let { sendEmailTo(it) } // let 의 바디는 unreachable code

 아주 긴 식이 있고 그 값이 not-null 일 때 수행해야 하는 로직에 대해 `let` 을 쓰면 편하다.

`let` 을 쓰면  긴 식의 결과를 저장하는 변수를 만들 필요가 없다.