이번 글의 코드는 Github에 있습니다.
자바에 비해, 코틀린의 타입 시스템은 더 간결하고 가독성 좋은 몇 가지 기능을 제공합니다. 그 중에 대표적으로 널이 될 수 있는 타입(nullable type)과 읽기 전용 컬렉션이 있습니다. 이번 글에서는 코틀린에서 널이 될 수 있는 값을 어떻게 표기하고 코틀린이 제공하는 도구가 그런 널이 될 수 있는 값을 어떻게 처리하는지 알아보겠습니다.
널 가능성(nullability)은 NullPointerException(이하 NPE) 오류를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성입니다.
코틀린을 비롯한 최신 언어에서 null에 대한 접근 방법은 가능한 한 이 문제를 실행시점에서 컴파일 시점으로 옮기는 것입니다. 널이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 여러 가지 오류를 컴파일 시 미리 감지해서 실행 시점에 발생할 수 있는 예외의 가능성을 줄일 수 있습니다.
코틀린과 자바의 첫 번째이자 가장 중요한 차이는 코틀린 타입 시스템이 널이 될 수 있는 타입을 명시적으로 지원한다는 점입니다. 코틀린은 타입 이름 뒤에 물음표(?:Question Mark)를 명시함으로써 널이 될 수 있는 타입을 정의할 수 있습니다. 예를 들어 다음과 같이 사용 할 수 있습니다.
// name은 String 또는 null
val name: String? = null
이제 우리는 nullable한 프로퍼티를 정의할 수 있게 됐습니다. 다음으로는 메서드의 인자로 널이 될 수 있는 타입이 넘어올 때 어떻게 처리할 수 있는지 알아보겠습니다.
class StringCheck {
// 컴파일 에러
fun strLenUnsafe(str: String?) = str.length
fun strLenSafe(str: String?) =
if (str != null) str.length else 0
}
fun main() {
val stringCheck = StringCheck()
println(stringCheck.strLenSafe(null))
println(stringCheck.strLenSafe("conas"))
// 실행 결과
// null
// 5
}
위 코드에서 strLenUnsafe
메서드는 컴파일 에러가 납니다. 이유는 null이 될 수 있는 변수가 있다면 그에 대해 수행할 수 있는 연산이 제한되기 때문입니다. 즉, 메서드의 인자로 들어온 str
이 널이 될 수 있는 타입이라서 length 연산을 할 수 없습니다. 예를 들어 널이 될 수 있는 타입인 변수에 대해 변수.메서드() or 변수.프로퍼티
처럼 메서드 또는 프로퍼티를 직접 호출할 수는 없습니다.
따라서 strLenSafe
메서드처럼 파라미터에 대해 null이 아닌지 검사 후에야 내부로 진입할 수 있습니다. 하지만 널 가능성의 체크를 위해 조건문(if문)을 반복적으로 사용한다면 코드가 더러워(?)질 것입니다. 다행히 코틀린은 널이 될 수 있는 값을 다룰 때 도움이 되는 여러 도구를 제공합니다. 다음으로 그 도구 중 하나인 ?.
연산자에 대해 알아보겠습니다.
✏️ Check
- 널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입할 수 없습니다.
- 널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 파라미터를 받는 함수에 전달할 수 없습니다.
?.
코틀린이 제공하는 가장 유용한 도구 중 하나가 안전한 호출 연산자인 ?.
입니다.
?.
은 null 검사와 메서드 호출을 한 번의 연산으로 수행합니다.
예를 들어 str?.toUpperCase()
는
훨씬 더 복잡한 if(str!=null) str.toUpperCase() else null
과 같습니다.
다시 말하자면, 호출하려는 값이 null이 아니라면 ?.
는 일반 메서드 호출처럼 작동하고, 호출하려는 값이 null이면 이 호출은 무시되고 null이 결과 값이 됩니다.
✏️ Check
안전한 호출의 결과 타입도 널이 될 수 있는 타입입니다.
예를 들어,val result = str?.toUpperCase()
이면 result는 널이 가능한 String 자료형이라는 것입니다.(String?
)
메서드 호출뿐 아니라 프로퍼티를 읽거나 쓸 때도 안전한 호출을 사용할 수 있습니다. 그리고 객체 그래프에서 널이 될 수 있는 중간 객체가 여럿 있다면 한 식 안에서 안전한 호출을 연쇄해서 함께 사용하면 편할 때가 자주 있습니다. 예제 코드를 보겠습니다. (github: ChainingSafeCall 파일)
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"
}
예제에서 this.company?.address?.country
처럼 안전한 호출을 연쇄해서 사용할 수 있다는 뜻입니다.
하지만 맨 마지막을 보면 country
가 널인지 검사해서 정상적으로 얻은 country
값을 반환하거나 null인 경우에는 "Unknown"을 반환하도록 했습니다. 다음에 소개할 연산자를 사용하면 이런 if문도 없앨 수 있습니다.
?:
코틀린은 null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 연산자를 제공합니다. 그 연산자를 엘비스(Elvis) 연산자라고 부르며, ?:
로 표기합니다.
val result: String = str ?: "default"
이 연산자는 이항 연산자로 좌항을 계산한 값이 널인지 검사합니다. 좌항 값이 널이 아니면 좌항 값을 결과로 하고, 좌항 값이 널이면 우항 값을 결과로 합니다.
이 엘비스 연산자를 사용해 1.2
의 Person.countryName
메서드를 고쳐보면 다음과 같습니다.
fun Person.countryName() = company?.address?.country ?: "Unknown"
3~4줄의 코드를 단 한 줄로 작성할 수 있다는 것이 놀랍지 않나요? (자바였으면 if( ~ != null && ~ !=null && ~ != null)
이런식으로 긴 코드가 됐을텐데... 👍)
그리고 저 메서드를 해석해보자면,
company
,address
둘 중 하나라도 null이라면 "Unknown"을 반환하고, null이 없다면 country 값을 반환합니다. (현재 country는 non-nullable 프로퍼티)
✏️ Check
코틀린에서는 return 이나 throw 등의 연산도 식입니다.
따라서 엘비스 연산자의 우항에 return, throw 등의 연산을 넣을 수 있고, 엘비스 연산자를 더욱 편하게 쓸 수 있습니다.
이런 패턴은 함수의 전제 조건을 검사하는 경우 특히 유용합니다.
as?
as
는 코틀린에서 지원하는 타입 캐스트 연산자입니다. 자바 타입 캐스트와 마찬가지로 대상 값을 as
로 지정한 타입으로 바꿀 수 없으면 ClassCastException
이 발생합니다. is
를 통해 선검사 후에 as
로 변환 할 수도 있지만, 이런 과정이 반복되면 번거로워집니다. 따라서 코틀린은 as
뒤에 물음표를 붙인 as?
를 지원합니다.
as?
연산자는 as
와 마찬가지로 어떤 값을 지정한 타입으로 캐스트합니다.
as
는 지정한 타입으로 바꿀 수 없으면 예외를 발생시키는 반면, as?
는 타입으로 바꿀 수 없을 경우 null
을 반환합니다.
안전한 캐스트를 사용할 때 일반적인 패턴은 캐스트를 수행한 뒤에 엘비스 연산자(?:
)를 사용하는 것입니다. 예를 들어 equals를 구현할 때 이런 패턴이 유용합니다.
class Person(val name: String, val age: Int) {
override fun equals(other: Any?): Boolean {
val otherPerson = other as? Person ?: return false
return this.name == otherPerson.name
&& this.age == otherPerson.age
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + age
return result
}
}
널 아님 단언(not-null assertion)은 어떤 값이든 널이 될 수 없는 타입으로 강제로 바꿀 수 있습니다.
!!
로 표현하며 실제 null에 대해 !!
을 적용하면 NPE가 발생합니다. 근본적으로 !!
는 컴파일러에게 "나는 이 값이 null이 아님을 잘 알고 있다. 내가 잘못 생각했다면 예외가 발생해도 감수하겠다"라고 말하는 것입니다.
널 아님 단언문을 잘못 사용하면 NPE 발생시킬 위험도 있지만, 더 나은 해법인 경우도 있습니다. 어떤 함수가 값이 널인지 검사한 다음에 다른 함수를 호출한다고 해도 컴파일러는 호출된 함수 안에서 안전하게 그 값을 사용할 수 있음을 인식할 수 없습니다. 하지만 이런 경우 호출된 함수가 언제나 다른 함수에서 널이 아닌 값을 전달받는다는 사실이 분명하다면 굳이 널 검사를 다시 수행하고 싶지는 않을 것입니다. 이럴 때 널 아님 단언문을 쓸 수 있습니다. 극단적인 예를 들어보자면 아래와 같습니다.
val list = arrayListOf<String?>()
fun isValid() = list[0] != null
fun doSomething(){
val value = list[0]!!
// ...
}
if(isValid()) {
doSomething()
}
이미 isValid
메서드에서 list[0]에 대해 널 검증을 했지만, 만약 doSomething
메서드에서 !!
을 쓰지 않았다면 value는 널 가능한 타입이 됩니다. 이렇게 이미 검증을 하고 사용하는 경우엔 !!
을 통해 non-nullable 프로퍼티로 사용할 수 있습니다.
✏️ 참고
!!
을 연쇄로 사용할 경우 한 줄에 다 쓰지 말도록 하는 것이 좋습니다.
만약person.company!!.address!!.country
여기서 NPE가 발생할 경우, 예외의 스택 트레이스로 해당 줄에서 예외가 발생했는지는 알 수 있지만 어떤 식에서 발생했는지는 확인할 수 없기 때문입니다. (company가 null이라서 예외가 발생했는지, address가 null이라서 예외가 발생했는지)
let
함수지금까지는 널이 될 수 있는 타입의 값에 어떻게 접근하는지에 대해 주로 살펴봤습니다. 하지만 널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기려면 어떻게 해야 할까요?
그런 호출은 안전하지 않기 때문에 컴파일러는 그 호출을 허용하지 않습니다. 코틀린은 이런 경우 사용할 수 있는 함수가 let
함수입니다.
let
함수를 사용하면 널이 될 수 있는 식을 더 쉽게 다룰 수 있습니다. let
함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해서 결과가 널인지 검사한 다음에 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한꺼번에 처리할 수 있습니다. 예를 보겠습니다.
fun sendEmail(email: String) {
// ...
}
val email: String? = ""
email?.let { sendEmail(it) }
sendEmail
메서드에서 email을 받는데 이것은 널이 될 수 없는 파라미터입니다.
하지만 아래에 선언한 email은 nullable한 프로퍼티죠. 이 email 프로퍼티를 sendEmail
메서드의 파라미터로 전달하면 Type mismatch
에러가 발생합니다.
let 함수는 자신의 수신 객체를 인자로 전달받은 람다에게 넘깁니다. 널이 될 수 있는 값에 대해 안전한 호출 구문을 사용해 let을 호출하되 널이 될 수 없는 타입의 인자로 받는 람다를 let에 전달합니다. 이렇게하면 널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 값으로 바꿔서 람다에 전달하게 됩니다.
객체 인스턴스를 일단 생성한 다음에 나중에 초기화하는 프레임워크가 많습니다. 예를 들어 제이유닛에서는 @Before
로 어노테이션된 메서드 안에서 초기화 로직을 수행해야만 합니다.
하지만 코틀린에서 클래스 안의 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화하지 않고 특별한 메서드 안에서 초기화할 수는 없습니다. 코틀린에서는 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 합니다. 게다가 프로퍼티 타입이 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 그 프로퍼티를 초기화해야 합니다. 그런 초기화 값을 제공 할 수 없으면 널이 될 수 있는 타입을 사용할 수 밖에 없습니다.
class MyService {
fun performAction(): String = "foo"
}
class MyServiceTest {
private var myService:MyService? = null
@BeforeEach
fun setUp() {
myService = MyService()
}
@Test
fun test() {
assertEquals("foo",myService!!.performAction())
}
}
위의 코드를 보면, myService
프로퍼티에 null을 넣어 놓고 setUp
에서 진짜 초깃값을 지정합니다. 이렇게 사용하면 myService
를 사용 할 때마다 !!
또는 ?
를 사용해야 합니다.
이를 해결하기 위해 myService 프로퍼티를 나중에 초기화(late-initialized)할 수 있습니다. lateinit
변경자를 붙이면 프로퍼티를 나중에 초기화할 수 있습니다.
위 코드를 lateinit
을 사용해 고쳐보겠습니다.
class MyService {
fun performAction(): String = "foo"
}
class MyServiceTest {
private lateinit var myService:MyService
@BeforeEach
fun setUp() {
myService = MyService()
}
@Test
fun test() {
assertEquals("foo",myService.performAction())
}
}
나중에 초기화하는 프로퍼티는 항상 var여야 합니다.
널이 될 수 있는 타입에 대한 확장 함수를 정의하면 null 값을 다루는 강력한 도구로 활용할 수 있습니다. 어떤 메서드를 호출하기 전에 수신 객체 역할을 하는 변수가 널이 될 수 없다고 보장하는 대신, 직접 변수에 대해 메서드를 호출해도 확장 함수인 메서드가 알아서 널을 처리해줍니다. 이런 처리는 확장 함수에서만 가능합니다. 일반 멤버 호출은 객체 인스턴스를 통해 디스패치 되므로 그 인스턴스가 널인지 여부를 검사하지 않습니다.
예를 들어 코틀린 라이브러리에서 String
을 확장해 정의된 함수들이 있습니다. 아래는 그 예입니다. (String.kt
파일)
위를 보면 isEmpty
메서드는 String
타입에,
isNullOrEmpty
와 isNullOrBlank
메서드는 String?
타입에 대해 확장 함수로 구현되어 있습니다.
이는 isEmpty
메서드는 널이 될 수 없는 String 타입에만 사용할 수 있으며, isNullOrEmpty
와 isNullOrBlank
메서드는 널이 될 수 없는 타입과 널이 될 수 있는 타입 모두 사용 가능하다는 뜻입니다.
그리고 이런 확장 함수는 널이 될 수 있는 프로퍼티에 대해 안전한 호출을 할 필요가 없어집니다.
예를 보겠습니다.
fun verifyUserInput(input: String?) {
// input?.isNullOrBlank() 이렇게 안전한 호출을 할 필요가 없다.
if (input.isNullOrBlank()) {
println("User Input Error")
}
// ...
}
코틀린에서 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있습니다. 널이 될 수 있는 타입을 포함하는 어떤 타입이라도 타입 파라미터를 대신할 수 있습니다. 따라서 타입 파라미터 T
를 클래스나 함수 안에서 타입 이름으로 사용하면 이름 끝에 물음표가 없더라도 T
가 널이 될 수 있는 타입입니다.
따라서 타입 파라미터가 널이 아님을 확실히 하려면 널이 될 수 없는 타입 상한을 지정해야 합니다. 아래의 두번째 메서드에서 T:Any
처럼 말이죠.
// t는 Any? 타입이 된다.
// 따라서 t가 null일 수 있으므로 안전한 호출을 해야한다.
fun <T> printHashCode(t:T) {
println(t?.hashCode())
}
// T는 널이 될 수 없는 타입이므로 안전한 호출을 할 필요가 없다.
fun <T:Any> printHashCode(t:T) {
println(t.hashCode())
}
📖 Reference
- 도서: Kotlin in Action