022. Nullability

Nullability

Nullability, 직역하면 Null이 될 수 있는 가능성이라고 볼 수 있다.

코틀린에서 Nullability는 하나의 기능으로 JVM 체계의 끔찍한 악몽인 NullPointerException을 회피할 수 있게 해준다.

줄여서 NPE라고 불리는 이 오류는 주로 An error has occurred: java.lang.NullPointerException, + @의 형태로 오류 메시지가 출력되며, 또 다른 버전으로는 Unfortunately, the application X has stopped, + @의 형태로도 출력된다.

코틀린을 비롯한 최근에 만들어진 언어들은 런타임에서 체크되는 NPE를 어떻게하면 컴파일타임에서 체크할 수 있도록 하여, 많은 NPE의 발생 가능성을 제거할 수 있게 되었다.

Nullable - Null로 지정될 수 있는 유형들

Java와 코틀린의 가장 중요한 차이점 중 하나는 nullable 타입에 대한 명시적인 지원 여부일 것이다.

이는 코드상에서 null이 허용되는 프로퍼티와 허용되지 않는 프로퍼티를 표현할 수 있는 방법의 유무를 뜻한다.

아래 Java 코드를 보자.

1
2
3
int strLen(String s) {
return s.length();
}

위의 strLen()함수는 NPE로부터 안전할까?

만약 파라미터로 주어지는 s가 null이라면 NPE를 발생시키게 될 것이다.

위 함수를 코틀린으로 다시 표현해보자.

1
fun strLen(s: String) = s.length

만약 strLen() 함수를 호출하면서 넘기는 파라미터가 nullable한 변수라면 컴파일러가 아래의 오류를 발생 시키게 된다.

1
2
>>> strLen(null)
ERROR: Null can not be a value of a non-null type String

코틀린에서 파라미터의 타입이 String으로 선언되었다는 것은 파라미터로 넘어오는 값이 String 객체여야 함을 명시한 것으로 null을 인정하지 않는 형태이다.

따라서 런타임까지 가지 않고 컴파일타임에서 오류를 노출할 수 있는 것이다.

만약 strLen() 함수가 null도 처리하려면 파라미터 타입을 아래와 같이 변경하면 된다.

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

코틀린에서는 모든 타입 뒤에 ?를 추가하여 해당 변수가 null을 참조할 수 있음을 명시할 수 있다.

예를 들어 String?, Int?, CustomType? 등으로 표현하게 된다.

거듭해서 강조하자면, ?가 붙지 않은 타입은 null을 참조할 수 없으며, 이는 모든 타입이 null로 표현되지 않는 이상 기본적으로 non-null 상ㅌ애임을 반증한다.

만약 nullable 타입의 값을 사용하려 한다면, 아래 예제처럼 해당 값으로 수행할 수 있는 동작이 제한된다.

1
2
3

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

추가적으로 non-null 타입의 변수에 null을 할당하려해도 오류 메시지가 출력된다.

1
2
3
>>> val x: String? = null
>>> var y: String = x
ERROR: Type mismatch: inferred type is String? but String was expected

당연히, non-null 타입의 파라미터에 nullable한 타입의 변수를 넘기는 것도 불가능하다.

1
2
>>> strLen(x)
ERROR: Type mismatch: inferred type is String? but String was expected

이러한 제한사항들을 통해 우리는 무엇을 할 수 있을까? 가장 중요한 것은 해당 값이 null인지 아닌지 검증하는 것이다.

개발자는 if문 등을 이용한 비교를 수행하여 nullable한 타입이 non-null임을 컴파일러에게 보장하여 처리할 수 있다.

1
2
3
4
5
6
7
8
9
10
fun strLenSafe(s: String?): Int =
if (s != null) s.length
else 0

/* 출력 결과 */
>>> val x: String? = null
>>> println(strLenSafe(x))
0
>>> println(strLenSafe("abc"))
3

타입의 의미

Java의 String 타입에 대해 생각해보자.

String 타입의 변수는 String 객체를 저장할수도, ""로 감싸진 문자열을 저장할 수도 있지만 null을 저장할 수도 있다.

문제는 코틀린처럼 nullable인 해당 변수에 대한 제한사항을 컴퍼일러가 알려줄 수 없다는 것이다.

이는 개발자의 실수를 야기하여 NPE를 발생시키는 원인이 된다.

이제 코틀린에서 다양한 연산자 및 키워드를 이용해 이러한 실수들을 어떻게 줄여나갈 수 있는지 파악해보자.

참고 위키피디아의 Data type 문서

참고 오라클의 Data type 문서

안전한 호출을 위한 ?. 연산자

?. 연산자는 코틀린의 가장 강력한 연산자 중 하나이다.

이 연산자를 이용하면 null 여부에 대한 검증과 메소드의 호출을 동시에 진행할 수 있다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
fun printAllCaps(s: String?) {
val allCaps: String? = s?.toUpperCase()
println(allCaps)
}

/* 출력 결과 */
>>> printAllCaps("abc")
ABC
>>> printAllCaps(null)
null

파라미터 s가 nullable인 상태에서 s?.toUpperCase()를 호출하면 s가 null이 아닐때 toUpperCase() 메소드를 호출하고, null일 경우 null로 초기화해준다.

?. 연산자는 메소드의 호출뿐만 아니라 프로퍼티에 대한 접근에서도 사용할 수 있다.

아래 코드를 살펴보자.

1
2
3
4
5
6
7
8
9
10
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)
>>> println(managerName(developer))
Da Boss
>>> println(managerName(ceo))
null

출력 결과를 통해 Employee객체의 manager 프로퍼티가 null이 아닌 경우 해당 값을 반환하도록 처리한 예제임을 알 수 있다.

만약 여러 프로퍼티에 대해 nullable 타입이 존재하는 경우 ?.도 여러번 사용하여 안전하게 호출하는 것이 더 편리하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
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)
>>> println(person.countryName())
Unknown

엘비스 연산자 ?:

코틀린은 프로퍼티의 기본값을 null 대신 다른 값으로 지정할 수 있는 엘비스 연산자 ?:가 있다.

사용 방법은 간단하다. 아래 코드를 보자.

1
2
3
fun foo(s: String?) {
val t: String = s ?: ""
}

엘비스 연산자가 파라미터로 넘어온 nullable s가 null일 경우 ""t를 초기화해준다.

위의 strLenSafe 예제를 엘비스 연산자를 이용해 좀 더 간결하게 쓸 수 있다.

1
2
3
4
5
6
7
8
9
10
11
// fun strLenSafe(s: String?): Int =
// if (s != null) s.length
// else 0
fun strLenSafe(s: String?): Int = s?.length ?: 0

/* 출력 결과 */
>>> val x: String? = null
>>> println(strLenSafe(x))
0
>>> println(strLenSafe("abc"))
3
1
2
3
4
5
6
7
fun strLenSafe(s: String?): Int = s?.length ?: 0

/* 출력 결과 */
>>> println(strLenSafe("abc"))
3
>>> println(strLenSafe(null))
0

또 다른 예제인 countryName() 메소드도 줄여보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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"
// }
fun Person.countryName() = company?.address?.country ?: "Unknown"

/* 출력 결과 */
>>> val person = Person("Dmitry", null)
>>> println(person.countryName())
Unknown

또 하나의 예제를 개선해보자.

Person 객체가 있고 소속 회사의 주소가 있다면 주소를 출력하는 코드에 엘비스 연산자를 적용하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 printShippingLabel(person: Person) {
val address = person.company?.address ?: throw IllegalArgumentException("No address")
with (address) {
println(streetAddress)
println("$zipCode $city, $country")
}
}

/* 출력 결과 */
>>> val address = Address("Elsestr. 47", 80687, "Munich", "Germany")
>>> val jetbrains = Company("JetBrains", address)
>>> val person = Person("Dmitry", jetbrains)
>>> printShippingLabel(person)
Elsestr. 47
80687 Munich, Germany
>>> printShippingLabel(Person("Alexey", null))
java.lang.IllegalArgumentException: No address

안전한 캐스팅을 위한 as? 연산자

코틀린의 캐스팅은 Java와 마찬가지로 적절한 타입의 캐스팅이 아니면 ClassCastException을 발생시킨다.

이때 적절한 타입이 존재하는 지 확인하기 위해 검증로직을 추가하곤 하지만, as? 연산자를 이용해 좀 더 간단하게 처리할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person(val firstName: String, val lastName: String) {

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

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

/* 출력 결과 */
>>> val p1 = Person("Dmitry", "Jemerov")
>>> val p2 = Person("Dmitry", "Jemerov")
>>> println(p1 == p2)
true
>>> println(p1.equals(42))
false

위의 코드 패턴을 사용하면 (Any 타입이므로) 어떤 타입의 파라미터가 넘어오더라도 Person 클래스 타입 여부를 검증할 수가 있다.

절대로 Null이 아님을 표시하는 연산자 !!

코틀린은 !! 연산자를 사용해 컴파일러에게 해당 변수가 무조건 null이 아님을 명시해준다.

컴파일러에게 null이 아니다! 라고 알려주는 것이지, 실제로 non-null이 보장되는 것이 아니므로 NPE가 발생할 가능성이 생긴다.

아래 예제 코드는 컴파일타임에선 오류가 없지만 런타임에서 오류가 발생한다.

1
2
3
4
5
6
7
8
9
fun ignoreNulls(s: String?) {
val sNotNull: String = s!!
println(sNotNull.length)
}

/* 출력 결과 */
>>> ignoreNulls(null)
Exception in thread "main" kotlin.KotlinNullPointerException
at <...>.ignoreNulls(07_NotnullAssertions.kt:2)

결과적으로 이 연산자는 개발자가 임의로 Null에 대한 처리를 할 것이고, Null을 허용할 준비가 되어있다고 해석할 수 있다.

아래 CopyRowAction 클래스를 보자.

CopyRowAction 클래스는 현재 선택한 행의 값을 복사하는 클래스로, 행의 선택여부를 검증한 후 복사를 수행한다.

이는 해당 코드가 로직상 non-null임을 보장할 수 있기때문에 !! 연산자를 사용해 코드를 매우 간결하게 작성하였음을 파악할 수 있다.

1
2
3
4
5
6
7
8
9
class CopyRowAction(val list: JList<String>) : AbstractAction() {
override fun isEnabled(): Boolean =
list.selectedValue != null

override fun actionPerformed(e: ActionEvent) {
val value = list.selectedValue!!
// copy value to clipboard
}
}

let 함수

let 함수를 이용하면 nullable에 대한 표현을 좀 더 쉽게 처리할 수 있다.

안전한 호출을 위한 연산자들과 함께 사용하게 되면 결과값에 대해 null인지 검증 한 후, 변수에 저장할 수 있게 된다.

아래 코드를 보자.

1
fun sendEmailTo(email: String) { /*...*/ }

sendEmailTo() 함수는 파라미터로 null을 넘길 수 없는 상태이다.

1
2
3
>>> val email: String? = ...
>>> sendEmailTo(email)
ERROR: Type mismatch: inferred type is String? but String was expected

따라서 명시적인 검증이 없다면 위의 오류 메시지를 받게 된다.

1
if (email != null) sendEmailTo(email)

위와 같이 검증하면 될까?

let 함수를 이용해 좀 더 안전하고 간단하게 작성해보자.

let 함수는 호출되는 객체를 람다 표현식으로 변환하여 취급하며, 이 람다 표현식을 안전한 호출 연산자와 결합하여 표현할 수 있다.

아래 코드는 let 함수를 이용해 email이 null이 아닌 경우에만 메일을 보내도록 표현한 코드이다.

1
email?.let { email -> sendEmailTo(email) }

좀 더 짧게 쓰고 싶다면 람다의 특성을 이용해 it으로 표현하면 된다.

1
let { sendEmailTo(it) }

아래 출력 결과를 통해 let 함수의 동작을 확인해보자.

1
2
3
4
5
6
7
8
9
10
fun sendEmailTo(email: String) {
println("Sending email to $email")
}

/* 출력 결과 */
>>> var email: String? = "yole@example.com"
>>> email?.let { sendEmailTo(it) }
Sending email to yole@example.com
>>> email = null
>>> email?.let { sendEmailTo(it) }

프로퍼티의 늦은 초기화 (=한글판 기준, 나중에 초기화할 프로퍼티)

많은 프레임워크들이 객체의 인스턴스를 생성한 후 호출되는 메소드들을 통해 초기화 작업을 진행한다.

예를 들어 Android는 생성자가 아닌 onCreate() 메소드에서 Activity의 초기화가 진행된다.

Android의 JUnit 프레임워크도 @Before 어노테이션이 부여된 메소드에서 초기화를 수행해야 한다.

하지만 non-null 타입의 프로퍼티들을 생성자의 초기화 로직 없이 그대로 둘 수 없으므로, nullable로 타입을 바꾸어야 하고, !! 연산자를 쓰거나 null에 대한 검증을 수행해야 각 프로퍼티에 접근할 수 있을 것이다.

그 외에 특별한 방법으로만 초기화할 수 있는데

위에서 언급한 Android JUnit을 통해 늦은 초기화, Late-initialized의 개념에 대해 파악해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyService {
fun performAction(): String = "foo"
}

class MyTest {
private var myService: MyService? = null

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

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

만약 위의 코드에서 특정 프로퍼티에 많은 접근을 수행해야 한다면, 이에 대한 처리를 그 접근만큼 처리해야하므로, 이는 좋은 코드가 아니다.

이 문제는 늦은 초기화를 이용해 해결해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyService {
fun performAction(): String = "foo"
}
class MyTest {
private lateinit var myService: MyService

@Before fun setUp() {
myService = MyService()
}
@Test fun testAction() {
Assert.assertEquals("foo", myService.performAction())
}
}

myService 프로퍼티에 lateinit 키워드를 추가하여, 타입이 non-null로 변경된 것을 확인할 수 있다.

단, lateinit 키워드가 붙은 프로퍼티들은 생성자 외부에서 접근하여 값을 변경할 수 있어야 하기 때문에 항상 var, 변수로 선언되어야 한다.

만약 lateinit 키워드가 붙은 프로퍼티가 초기화되기전에 접근하는 경우 lateinit property myService has not been initialized 메시지가 출력되며 예외가 발생한다.

Nullable 타입의 확장

null에 대한 처리법 중 nullable 타입에 대해 확장 기능을 정의하는 것이 있다.

특정 메소드의 호출 전 변수의 non-null을 보장하는 대신 함수가 null을 처리전에 변수가 null이 아닌 것을 보장하는 대신, 함수가 null을 처리하도록 하는 것이다.

위와 같은 형태의 동작은 확장 함수에서만 가능하며, 객체의 인스턴스를 통해 동작하므로 객체의 인스턴스가 null인 경우엔 사용할 수 없다.

예를 들어 코틀린의 문자열 표준 라이브러리의 isEmpty()isBlank() 함수를 고려해보자.

isEmpty()는 해당 문자열이 ""와 같이 비어있는 지를 검증하며, isBlank() 함수는 공백 문자만 가지고 있을 경우나 비어있는 경우를 검증한다.

이를 이용해

만약 특정 문자열이 주어졌을 때, 해당 문자열이 null이거나 공백 문자만으로 되어있을 때 특정문구를 처리하는 verifyUserInput() 함수를 구현해보자.

1
2
3
4
5
6
7
8
9
10
11
fun verifyUserInput(input: String?) {
if (input.isNullOrBlank()) {
println("Please fill in the required fields")
}
}

/* 출력 결과 */
>>> verifyUserInput(" ")
Please fill in the required fields
>>> verifyUserInput(null)
Please fill in the required fields

위의 코드에서 input은 nullable이지만 isNullOrBlank()는 확장함수이므로 별도의 safe-call 처리없이도 호출이 가능하다.

위에서 사용한 verifyUserInput() 메소드는 아래와 같이 정의되어 있다.

1
2
fun String?.isNullOrBlank(): Boolean = 
this == null || this.isBlank()

호출한 문자열이 null이면 바로 true를 반환하여 특정 문구를 출력하게끔하고, null이 아니더라도 공백으로만 되어있다면 특정 문구를 출력하게끔한다.

즉, null이 아니면서 의미있는 (=공백이 아닌) 문자열로 구성되어있는 지 검증할 수있는 기능을 가진다.

타입 파라미터의 Nullability

기본적으로 코틀린의 모든 타입의 함수와 클래스는 nullable이다.

따라서 아래 코드에서 T로 파라미터 타입이 선언되더라도 non-null이 보장되는 것이 아니고,

반대로 말해서 T?로 작성되지 않더라도 파라미터 타입이 null인 경우가 발생할 수 있다는 뜻이다.

1
2
3
4
5
6
7
fun <T> printHashCode(t: T) {
println(t?.hashCode())
}

/* 출력 결과 */
>>> printHashCode(null)
null

printHashCode() 메소드에 전달된 파라미터를 통해 컴파일러는 T가 null임을 추론하였을 것이며, T?가 아님에도 null을 유지할 수 있다.

타입 파라미터에 대해 non-null을 보장하려면 아래와 같이 t.hashCode()로 non-null이 보장된 상태의 동작을 호출함으로서, nullable한 타입 파라미터를 제한할 수 있다.

1
2
3
4
5
6
7
8
9
fun <T: Any> printHashCode(t: T) {
println(t.hashCode())
}

/* 출력 결과 */
>>> printHashCode(null)
Error: Type parameter bound for `T` is not satisfied
>>> printHashCode(42)
42

Nullability과 Java

여태까지 코틀린에서의 null 검증과 nullable, non-null에 대해 살펴보았다.

Java와 완벽하게 호환된다는 코틀린에서의 null에 대한 연산자는 Java에서는 어떻게 동작하는 지 확인해보자.

플랫폼 타입

플랫폼 타입은 코틀린이 해당 타입이 Null인지 아닌지 알 수 없는 타입을 말한다.

코틀린에서 플랫폼 타입을 사용하려면 Nullability에 어떤 문제가 발생하는 지 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
// In Java
public class Person {
private final String name;

public Person(String name) {
this.name = name;
}

public String getName() {
return name;
}
}

코틀린의 컴파일러는 위의 Person#getName()메소드를 호출해서 반환되는 name 변수가 null인지 null이 아닌지 알 수가 없다.

따라서 이를 코틀린에서 사용하려면 별도의 처리과정이 필요하다.

만약 별도의 처리 없이 호출한다면 name이 null인 경우 아래의 오류 메시지가 출력될 것이다.

1
2
3
4
5
6
7
8
fun yellAt(person: Person) {
println(person.name.toUpperCase() + "!!!")
}

/* 출력 결과 */
>>> yellAt(Person(null))
java.lang.IllegalArgumentException: Parameter specified as non-null
is null: method toUpperCase, parameter $receiver

이를 방지하려면 Person#getName()의 결과가 null일수도 있음을 상정하고 이에 대한 safe-call 로직을 추가해야 한다.

1
2
3
4
5
6
7
fun yellAtSafe(person: Person) {
println((person.name ?: "Anyone").toUpperCase() + "!!!")
}

/* 출력 결과 */
>>> yellAtSafe(Person(null))
ANYONE!!!

코틀린에서는 플랫폼 타입의 변수들을 선언할 수 없지만, Java의 플랫폼 타입을 가져다 쓰는 경우 발생할 수 있으며, IDE에서 아래와 같은 오류를 출력해줄 것이다.

1
2
>>> val i: Int = person.name
ERROR: Type mismatch: inferred type is String! but Int was expected

String! 표기법은 코틀린 컴파일러가 플랫폼 타입을 표현하는 방법으로, 일반적으로 사용할 수 없는 문법이다.

상속

Java의 메소드를 코틀린에서 오버라이딩하는 경우, nullable 혹은 non-null로 명시적으로 표현해야한다.

아래 StringProcessor의 예제를 보자.

1
2
3
interface StringProcessor {
void process(String value);
}

위의 인터페이스를 코틀린에서 사용하는 경우 두 가지 구현이 가능해진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class StringPrinter : StringProcessor {
override fun process(value: String) { // Non-null
println(value)
}
}

class NullableStringPrinter : StringProcessor {
override fun process(value: String?) { // Nullable
if (value != null) {
println(value)
}
}
}