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
이 될 수 있다. 아래 예제 코드는 @NonNull
과 Optional
타입을 사용했는데도 불구하고 여전히 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 등의 리소스가 한정된 환경에서 유리할 것으로 보인다.
기본 사용법
이미 앞에서 변수를 초기화하고 다시 대입하는 것에 대해서 설명하고 사용을 하였다. 변수 a
는 String
타입이다. 여기에서는 형식을 명시하고 있지만 생략해도 문제는 없다. var
키워드에 의해 변경 가능한 변수로 선언되어 있기 때문에 “Goodbye"를 다시 대입할 수 있다. 그러나 그 다음 라인에서 null
을 할당하는 부분에서 컴파일 오류가 발생한다. 변수 a
는 NotNull
로 선언되어 있기 때문에 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은 Nullable
과 NotNull
을 명확하게 구별하는 것으로 보인다. NotNull
의 변수에는 null
이 들어가지 않기 때문에, 이를 참조시에는 NPE가 일어나지 않기 때문에 안전하다. 그렇다면 Nullable
변수에는 null
들어갈 수 있으므로 NPE가 발생하는 것이 아닌가? 그래서 아래와 같이 일부로 NPE를 일으켜 보자.
예제: NPE를 발생시키자!
val str: String? = null
str.length // 여기서 컴파일 에러가 발생한다.
String?
변수인 s
에 null
을 대입하여 초기화하고 있고, 이 s
변수에 length
메소드를 호출하여 NPE을 일으키려하고 있다. 그러나 실제로 컴파일 시에 오류가 발생한다. Kotlin은 NPE를 일으키고 싶지 않으므로 NPE의 가능한 작업을 컴파일를 할 시에 에러를 발생시킨다. Nullable
에 대한 메소드, 프로퍼티에 대한 참조는 금지되어 있다.
그러나 현실 문제, Nullable
의 멤버에 항상 접근을 할 수 없게 된다면, 이는 결코 사용될 수 없을 것이다. 물론 접근할 방법이 없는 것은 아니다. 그 방법은 null
을 체크하면 된다!
아래와 같이 변수 s
가 null
인지 아닌지 확인하면 보장되는 범위(if 블록 내)에서 s
를 NotNull
로 처리 할 수 있다. s
에 "Hello"
가 대입되어 있는 상태에 실행하면 "5"
가 출력된다.
예제: null
를 체크하면 NotNull
된다.
if(str != null) {
println(str.length)
}
Nullable의 편리한 기능
Kotlin의 Nullable을 사용하는데 필요한 지식은 앞에 내용으로 충분할 수 있다. 하지만 null
체크를 하는 코드를 쓰는 것은 지루하고 번거로운 작업이므로 Kotlin은 Nullable을 편리하게 사용할 수 있는 기능을 제공하고 있다.
안전 호출 (Safe Call)
앞에서는 Nullable의 메소드를 호출하기 위해 null
체크를 하였다. 이를 간결하게 작성하게 해주는 안전 호출(Safe Call)이라는 것이 있다.
아래의 예제에서는 String?
타입의 변수 s
의 length
메소드를 안전하게 호출하고 있다. 일반적인 메소드 호출과 다른 점은 점(.
) 앞에 물음표(?
)를 넣는 것이다. 이렇게 하면 안전 호출이 되어 Nullable 메소드를 안전하게 즉, NPE를 발생하지 않게 호출할 있게 된다.
예제: 안전 호출
val str: String? = null
// 안전 호출하는 방식
val length1: Int? = str?.length
// null 체크 방식
val length2: Int? = if(str != null) str.length else null
만일 s
가 null
있었다면는 메소드를 호출하지 않고 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
에 익숙한 개발자들에게는 let
이 Optional
의 map
, flatMap
, ifPresent
의 3가지 역할이라고 생각하면 이해하기 쉬울 것이다.