Kotlin 널 안정성 (Null Safety)

배경

java.lang.NullPointerException는 Java 개발자가 자주 접하는 예외로 친숙할 것이다. 객체를 참조하려고 할때 그 객체가 null인 경우에 throw로 예외가 발생하는 예외이다. 구체적으로는 String 타입의 변수에 null를 대입해 두고, 그 변수에 대해서 length 메소드를 호출했을 경우에 NullPointerException(이하, NPE)가 발생하게 된다.

null는 값이 없을 때 사용된다. 예를 들어, 지정된 ID를 가지는 유저가 존재하지 않을 때에 findUserById와 같은 메소드가 User클래스의 인스턴스를 반환하는 대신에 null을 반환하게 된다.

이러한 관점에서 null 편리하게 작동헌다고 할 수 있다. 그러나 이 null로 인해서 개발자들은 보고 싶지 않은 예외인 NPE와 만나게 된다. 제대로 된 null 체크한다면, 구체적으로 if 조건문으로 null이 아닌 경우를 확인하면 회피할 수 있는데, 왜 이를 하지 못하는 것일까?

null를 반환하지 않다는 것을 아는 메소드의 반환값에 대해서는 null 체크는 하지 않는 것이 일반적일 것이다. 여기서 중요한 것은 스펙적으로 null를 반환할 수 없는 메소드가 있어도, Java의 코드에서는 이를 보증할 수 없다. Java에서 변수와 메소드의 반환 값은 언제 어디서나 null 발생할 수 있기 때문이다. 즉, null 체크를 해야 하는 것과 null 체크가 불필요한 메소드가 섞여 있기 때문에, 실수로 NPE를 발생시킬 수도 있는 것이다.

Java에서 null에 대한 대처 방안

null이 있을지도 모르는 것과 null 아닌 경우를 구별하는 방법은 몇 가지 있다.

메소드 시그니쳐 알림

원시적인 방법이다. null를 돌려줄 가능성이 있는 메소드의 시그니쳐를 줘서 개발자에게 주의하게 하도록 한다. 예를 들어 getNameOrNull 같은 이름의 메소드이다. 메소드명을 보면 null반환될지도 모른다는 것을 깨닫게 된다.

정적 분석 도구

메소드에 어노테이션을 달고 정적 분석 도구로 지적하는 방법이다. getName 메소드가 null를 반환할지도 모르는 경우에는 @Nullable String getName() {...}와 같이 작성하고, null를 반환하지 않을 경우에 반환하지 않은 경우에는 @NotNull String getName() {...}라고 작성한다.

타입으로 표현

존재하지 않을 가능성이 있는 값을 표현하기 위해서 null을 대신에 새롭게 정의한 타입을 사용하는 방법아다. 구체적으로는 Java SE 8에서 도입된 java.util.Optional 클래스이다. 값이 없다면 Optional#empty으로 반환되는 객체를 사용하고, 값이 존재하면 해당 값을 Optional#of의 인자에 전달하여 래핑한다. Optional 타입은 존재하지 않을 수 있는 값과 절대적으로 존재하는 값을 구별하기 위해 다양한 유용하고 쉬운 메소드를 제공한다.

Kotlin의 null 안전

정적 분석 도구와 Optional를 사용하는 것은 매우 좋은 방법이다. 그러나 여전히 Java에서 변수와 메소드의 반환 값은 언제 어디서나 null이 될 수 있다. 아래 예제 코드는 @NonNullOptional 타입을 사용했는데도 불구하고 여전히 NPE가 발생한다.

예제: null은 여전히 발생한다.

import org.springframework.lang.NonNull;

import java.util.Optional;

public class Foo {
    // Java이다.
    @NonNull
    Optional<String> getName() {
        return null;
    }

    public static void main(String[] args) {
        Foo foo = new Foo();
        foo.getName().toString();
    }
}

Output:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.util.Optional.toString()" because the return value of "Foo.getName()" is null
	at Foo.main(Foo.java:15)

그래서 Kotlin의 null 안전 기능이 등장하게 되었다. Kotlin에서는 null이 가능한 값(이하 Nullable)과 null 될 수 없는 값(이하 NotNull)의 구별을 언어 내장의 기능으로서 제공한다.

Optional를 사용하는 방법과는 달리 새로운 인스턴스 생성과 GC가 필요 없기 때문에 그 만큼의 오버헤드가 없고, Android 등의 리소스가 한정된 환경에서 유리할 것으로 보인다.

기본 사용법

이미 앞에서 변수를 초기화하고 다시 대입하는 것에 대해서 설명하고 사용을 하였다. 변수 aString 타입이다. 여기에서는 형식을 명시하고 있지만 생략해도 문제는 없다. var키워드에 의해 변경 가능한 변수로 선언되어 있기 때문에 “Goodbye"를 다시 대입할 수 있다. 그러나 그 다음 라인에서 null을 할당하는 부분에서 컴파일 오류가 발생한다. 변수 aNotNull로 선언되어 있기 때문에 null을 대입하면 컴파일러가 허용하지 않는다! 이는 변수 a는 항상 null이 아니라고 안심하고 사용할 수 있다는 것을 의미한다.

예제: NotNull 변수

var a: String = "Hello"
a = "Goodbye"
a = null // 여기서 컴파일 에러가 발생한다.

그렇다면 null을 대입할 수 있는 Nullable 변수는 어떻게 선언해야 하나? 간단하다. 단순히 타입 뒤에 ?를 붙일 뿐이다. 아래 예제를 보면, 변수 b의 타입 에 String?으로 되어 있다. 이는 “null이 대입 가능한 String 타입"라는 것을 의미한다. 두번째 라인에 null를 대입하고 있지만, 컴파일이 가능하다. 이 경우애는 변수 b의 타입에 물음표(?)를 생략 할 수 없다. 왜냐하면 생략하게 되면 "Hello"String?대신 String으로 추론되기 때문이다.

예제: Nullable 변수

var b: String? = "Hello"
b = null

Kotlin은 NullableNotNull을 명확하게 구별하는 것으로 보인다. NotNull의 변수에는 null이 들어가지 않기 때문에, 이를 참조시에는 NPE가 일어나지 않기 때문에 안전하다. 그렇다면 Nullable 변수에는 null 들어갈 수 있으므로 NPE가 발생하는 것이 아닌가? 그래서 아래와 같이 일부로 NPE를 일으켜 보자.

예제: NPE를 발생시키자!

val str: String? = null
str.length // 여기서 컴파일 에러가 발생한다.

String? 변수인 snull을 대입하여 초기화하고 있고, 이 s 변수에 length 메소드를 호출하여 NPE을 일으키려하고 있다. 그러나 실제로 컴파일 시에 오류가 발생한다. Kotlin은 NPE를 일으키고 싶지 않으므로 NPE의 가능한 작업을 컴파일를 할 시에 에러를 발생시킨다. Nullable에 대한 메소드, 프로퍼티에 대한 참조는 금지되어 있다.

그러나 현실 문제, Nullable의 멤버에 항상 접근을 할 수 없게 된다면, 이는 결코 사용될 수 없을 것이다. 물론 접근할 방법이 없는 것은 아니다. 그 방법은 null을 체크하면 된다!

아래와 같이 변수 snull인지 아닌지 확인하면 보장되는 범위(if 블록 내)에서 sNotNull로 처리 할 수 ​​있다. s"Hello"가 대입되어 있는 상태에 실행하면 "5"가 출력된다.

예제: null를 체크하면 NotNull된다.

if(str != null) {
  println(str.length)
}

Nullable의 편리한 기능

Kotlin의 Nullable을 사용하는데 필요한 지식은 앞에 내용으로 충분할 수 있다. 하지만 null체크를 하는 코드를 쓰는 것은 지루하고 번거로운 작업이므로 Kotlin은 Nullable을 편리하게 사용할 수 있는 기능을 제공하고 있다.

안전 호출 (Safe Call)

앞에서는 Nullable의 메소드를 호출하기 위해 null 체크를 하였다. 이를 간결하게 작성하게 해주는 안전 호출(Safe Call)이라는 것이 있다.

아래의 예제에서는 String? 타입의 변수 slength 메소드를 안전하게 호출하고 있다. 일반적인 메소드 호출과 다른 점은 점(.) 앞에 물음표(?)를 넣는 것이다. 이렇게 하면 안전 호출이 되어 Nullable 메소드를 안전하게 즉, NPE를 발생하지 않게 호출할 있게 된다.

예제: 안전 호출

val str: String? =  null

// 안전 호출하는 방식
val length1: Int? = str?.length

// null 체크 방식
val length2: Int? = if(str != null) str.length else null

만일 snull 있었다면는 메소드를 호출하지 않고 null 반환한다. 첫번째의 안전 호출 방식과 두번째의 null 체크 방식은 동일하다.

안전 호출은 메소드 체이닝(Method chaining)으로 작성하고 싶은 경우 등에서는 특히 효과적이다. foo()?.bar()?.baz()와 같이 작성했을 경우, 도중에 null이어도 안전 호출이 체이닝되어 최종적으로 null이 반환될 뿐이다.

예제: 안전 호출에서 메소드 체이닝

// 안전 호출 방식
val result1 = foo()?.bar()?.baz()

// null 체크 방식
val foo = foo()
val result2 = if(foo != null) {
    val bar = foo.bar()
    if(bar != null) bar.baz() else null
} else {
    null
}

기본값

기본값이 null 이었을 때 사용할 값을 쉽게 지정할 수 있다. Nullable 뒤에 엘비스 연산자(?:)와 기본값에 대해 설명한다. 아래 예제를 보도록 하자. s?.length는 안전 호출으로 문자열의 길이 null를 반환한다. null이 반환되면 기본값으로 0이 되도록 지정한다. null이 아니라면 기본값은 메소드로 호출되어 반환된 값이 대입된다.

예제: 기본값

val s: String? = "Hello"

// 엘비스 연산자 사용
val length1: Int = s?.length ?: 0

// null 체크 방식
val length: Int? = s?.length
val length2: Int = if(length != null) length else 0

null이 아닌것을 강조하는 !! 연산자 (not-null assertion operator)

마지막으로 소개하는 것은 !! 연산자이다.

!! 연산자는 Nullable 변수를 강제로 NotNull로 변환을 해준다. 아래 예제에서는 String?타입읩 변수 s!!연산자에 의해 강제 String으로 변환하였다.

예제: 강제로 NotNull으로 변환

val str: String? = "Hello"
println(str!!.length) // => 5

이 예제는 아무 문제 없이 잘 실행되었다. 그러나 아래 예제는 런타임 예외가 발생한다. null인 경우에 !!연산자를 사용하면 NullPointerException가 발생한다.

예제: 런타임 예외 발생

val str: String? = null
println(s!!.length())

null의 경우에 예외가 발생한다. 이는 결국 지금까지와 동일하다. !!연산자를 사용하고 싶으면 null 체크, 안전 호출, 기본값 사용을 고려해야 한다. 아무래도 부득이한 경우에만 !!연산자를 사용하도록 하자. 그리고 이에 대해 주석을 남겨 사용한 이유나 경위를 기록해 두는 것도 좋을 것이다.

그리고 !!연산자를 사용하고 싶은 또라는 예를 보도록 하자. 요소에 null를 허용하는 아래 예제에서 null제거하지 않고 NotNull 요소만 새로운 목록(List<T>)을 받아오는 함수를 원한다면 아래와 같이 구현할 수 있다.

예제: filterNotNull 구현

fun <T> filterNotNull(list: List<T?>): List<T> =
    list.filter { it != null }
        .map { it!! }

fun main() {
    val a: List<String?> = listOf("kimkc", null, "devkuma")
    val b: List<String> = filterNotNull(a)
    println(b) // => ["kimkc", "devkuma"]
}

list.filterNotNull(a)에 의해서 요소가 null 아닌 경우 필터링 된다. 그러나 목록의 타입은 여전히 List<T?>으로 되어 있다. 그래서 그 다음으로 map { it!! }으로 강제로 요소의 형식을 T?에서 T으로 변환한다.

실제로는 !!연산자를 사용하지 않고 구현할 수 있으며, 아래와 같이 filterNotNull 컬렉션의 표준 메소드로 제공되고 있다.

예제: filterNotNull 컬렉션의 표준 메소드 사용

val a: List<String?> = listOf("kimkc", null, "devkuma")
val b: List<String> = a.filterNotNull()
println(b) // => ["kimkc", "devkuma"]

표준 확장 함수 let

Kotlin의 표준 라이브러리는 모든 유형에 대해 let이라는 확장 함수(정확하게는 public inline 지정되어 있지만 편의상 생략)를 제공한다.

코드: 표준 확장 함수 let

fun <T, R> T.let(f: (T) -> R): R = f(this)

let는 리시버(receiver)가 되는 객체(this)에 인자로 함수(f)를 적용하고 있다. 사용 예는 아래와 같다.

예제: let의 사용 예

5.let {
  println(it * 3) // => 15
}

함수 리터럴에 걸친 유일한 인자(암시적 변수 it)는 let 수신자와 동일한 객체이므로 이 경우 it에는 5가 있다.

그렇다면 이 간단한 함수는 어떤 도움이 됩니까? 정확히, Nullable에 NotNull 인자를 다루는 함수를 적용할 때 유용하다. 구체적으로 알아보도록 하자.

아래 예제에서는 NotNull Int를 인자로 갖는 success 함수를 정의하였다. Nullable 변수 a를 이 함수의 인수로 전달하고 싶은데, Nullable과 NotNull의 차이가 있어 전달할 수 없다. success는 함수이며, Int 메소드(또는 확장 함수)가 아니므로 안전 호출도 사용할 수 없다. 이렇게 되면 if문으로 null 체크를 하여 Nullable를 안전하게 NotNull로서 전달할 수 밖에 없다.

예제: NotNull을 받는 함수를 적용하고 싶다.

fun success(n: Int): Int = n + 1

// Nullable한 변수
val a: Int? = 3
val b: Int? =
    if (a != null) success(a)
    else null

println(b) // => 4

이럴 경우에 let를 사용하면 된다. let는 모든 유형의 확장 함수이므로 a?.let {...}와 같이 안전 호출을 할 수 있다. 그리고 let 인자가 되는 함수(“코드: 표준 확장 함수 let“에서 f에 해당)을 받는 인수는 수신자와 동일한 유형의 NotNull 이다. 수신자가 null일 때는 let확장 함수는 호출할 수 없기 때문에 이에 적합하다.

그래서 let 사용하도록 변경하면 success를 적용 부분을 아래와 같이 다시 작성이 할 수 있다.

예제: let을 사용하면 깔끔하게

val b: Int? = a?.let { success(it) }

이를 더 더 간결하게 아래와 같이 작성할 수 있다.

예제: 함수 참조로 더 깔끔하게

val b: Int? = a?.let(::success)

Java에서 이미 Optional에 익숙한 개발자들에게는 letOptionalmap, flatMap, ifPresent의 3가지 역할이라고 생각하면 이해하기 쉬울 것이다.

참조




최종 수정 : 2021-11-30