널 가능성은 NullPointerException 오류를 피할 수 있게 돕기 위한 코틀린의 타입 시스템의 특성이다.
코틀린을 비롯한 최신 언어에서 null에 대한 접근 방법은 가능한 한 이 문제를 실행 시점에서 컴파일 시점으로 옮기는 것이다. 널이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 여러 가지 오류를 컴파일 시 미리 감지해서 실행 시점에 발생할 수 있는 예외의 가능성을 줄일 수 있다.
6.1.1 널이 될 수 있는 타입
코틀린과 자바의 첫 번째이자 가장 중요한 차이는 코틀린 타입 시스템이 널이 될 수 있는 타입을 명시적으로 지원한다는 점이다. 널이 될 수 있는 타입은 프로그램 안의 프로퍼티나 변수에 null을 허용하게 한다. 어떤 변수가 널이 될 수 있다면 그 변수에 대해 메소드를 호출하면 NullPointerException이 발생할 수 있으므로 안전하지 않다. 코틀린은 그런 메소드 호출을 금지함으로써 많은 오류를 방지한다.
String?, Int?, MyCustomType? 등 어떤 타입이든 타입 이름 뒤에 물음표를 붙이면 그 타입의 변수나 프로퍼티에 null 참조를 저장할 수 있다는 뜻이다. 물음표가 없는 타입은 어떤 변수가 null 참조를 저장할 수 없다는 뜻이다. 따라서 모든 타입은 기본적으로 널이 될 수 없는 타입이다. 뒤에 ?가 붙어야 널이 될 수 있다.
6.1.2 타입의 의미
코틀린의 널이 될 수 있는 타입은 null 관련 문제에 대해 종합적인 해법을 제공한다. 널이 될 수 있는 타입과 널이 될 수 없는 타입을 구분하면 각 타입의 값에 대해 어떤 연산이 가능할지 명확히 이해할 수 있고, 실행 시점에 예외를 발생시킬 수 있는 연산을 판단할 수 있다.
실행 시점에 널이 될 수 있는 타입이나 널이 될 수 없는 타입의 객체는 같다. 널이 될 수 있는 타입은 널이 될 수 없는 타입을 감싼 래퍼 타입이 아니다. 모든 검사는 컴파일 시점에 수행된다. 따라서 코틀린에서는 널이 될 수 있는 타입을 처리하는 데 별도의 실행 시점 부가 비용이 들지 않는다.
6.1.3 안전한 호출 연산자: ?.
?.은 null 검사와 메소드 호출을 한 번의 연산으로 수행한다. 예를 들어 s?.toUpperCase()는 훨씬 더 복잡한 if (s != null) s.toUpperCase() else null과 같다.
String.toUpperCase는 String 타입의 값을 반환하지만, s가 널이 될 수 있는 타입인 경우 s?.toUpperCase() 식의 결과 타입은 String?이다.
fun printAllCaps(s: String?) {
val allCaps: String? = s?.toUpperCase() // allCaps는 null일 수도 있다.
println(allCaps)
}
>>> printAllCaps("abc")
ABC
>>> printAllCaps(null)
null
메소드 호출뿐 아니라 프로퍼티를 읽거나 쓸 때도 안전한 호출을 사용할 수 있다. 다음 예제는 null이 될 수 있는 프로퍼티가 있는 간단한 코틀린 클래스로 프로퍼티 접근 시 안전한 호출을 사용하는 방법을 보여준다.
// null이 될 수 있는 프로퍼티를 다루기 위해 안전한 호출 사용하기
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
객체 그래프에서 null이 될 수 있는 중간 객체가 여럿 있다면 한 식 안에서 안전한 호출을 연쇄해서 함께 사용하면 편할 때가 자주 있다. 예를 들어 어떤 사람에 대한 정보와 그 사람이 다니는 회사에 대한 정보, 그리고 그 회사의 주소에 대한 정보를 각각 다른 클래스로 표현한다고 가정하자. 회사나 주소는 모두 생략 가능하다. ?. 연산자를 사용하면 다른 추가 검사 없이 Person의 회사 주소에서 country 프로퍼티를 단 한줄로 가져올 수 있다.
// 코드 블록 6.3
// 안전한 호출 연쇄시키기
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: Comapny?)
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 검사를 할 수 있다. 하지만 위 코드에는 불필요한 동작이 들어있다. 맨 마지막을 보면 country가 null인지 검사해서 정상적으로 얻은 country 값을 반환하거나 null인 경우에 대응하는 "Unknown"을 반환한다. 코틀린을 사용하면 이런 if문도 없앨 수 있는데 6.1.4 엘비스 연산자를 통해 알아보자.
6.1.4 엘비스 연산자: ?:
코틀린은 null 대신 사용할 default. 값을 지정할 때 편리하게 사용할 수 있는 연산자를 제공하는데 그 연산자를 엘비스(elvis) 연산자라고 한다.
엘비스 연산자를 객체가 null인 경우 null을 반환하는 안전한 호출 연산자와 함께 사용해서 객체가 null인 경우에 대비한 값을 지정하는 경우가 많다.
// 엘비스 연산자를 활용해 null 값 다루기
fun strLenSafe(s: String?): Int = s?.length ?: 0
>>> println(strLenSafe("abc"))
3
>>> pritln(strLenSafe(null))
0
이제 코드블록 6.3의 countryName 함수도 한 줄로 표현할 수 있다.
fun Person.countryName() = company?.address?.country ?: "Unknown"
코틀린에서는 return이나 throw 등의 연산도 식이다. 따라서 연산자의 우항에 return, throw 등의 연산을 넣을 수 있고, 엘비스 연산자를 더욱 편하게 사용할 수 있다. 그런 경우 엘비스 연산자의 좌항이 null이면 함수가 즉시 어떤 값을 반환하거나 예외를 던진다. 이런 패턴은 함수의 전제 조건을 검사하는 경우 특히 유용하다.
6.1.5 안전한 캐스트: as?
코틀린에서도 자바 타입 캐스트와 마찬가지로 대상 값을 as로 지정한 타입으로 바꿀 수 없으면 ClassCastException이 발생한다. 물론 as를 사용할 때마다 is를 통해 미리 as로 변환 가능한 타입인지 검사해볼 수 있다. 하지만 안전하면서 간결한 언어를 지향하는 코틀린ㅇ,ㄴ 더 나은 해법을 제공한다.
as? 연산자는 어떤 값을 지정한 타입으로 캐스트한다. as?는 값을 대상 타입으로 변환할 수 없으면 null을 반환한다.
안전한 캐스트를 사용할 때 일반적인 패턴은 캐스트를 수행한 뒤에 엘비스 연산자를 사용하는 것이다. 예를 들어 equals를 구현할 때 이런 패턴이 유용하다.
// 안전한 연산자를 사용해 equals 구현하기
class Person(val firstName: String, val lastName: String) {
override fun equals(o: Any?): Boolean {
val otherPerson = o as? Person ?: return false // 타입이 서로 일치하지 않으면 false를 return
return otherPerson.firstName == firstName && otherPerson.lastName == lastName
}
override fun hashCode(): Int =
firstName.hashCode() * 37 + lastName.hashCode()
}
>>> val p1 = Person("Dmitry", "Jamerov")
>>> val p2 = Person("Dmitry", "Jamerov")
>>> println(p1 == p2)
true
>>> println(p1.equals(42))
false
이 패턴을 사용하면 파라미터로 받은 값이 원하는 타입인지 쉽게 검사하고 캐스트할 수 있고, 타입이 맞지 않으면 쉽게 false를 반환할 수 있다. 이 모든 동작을 한 식으로 해결 가능하다. 물론 스마트 캐스트를 이 상황에 적용할 수도 있다. 일단 타입을 검사한 후 null 값을 거부하고 나면 컴파일러가 otherPerson 변수의 값이 Person이라는 사실을 알고 적절히 처리해줄 수 있다.
6.1.6 null 아님 단언: !!
null 아님 단언(not-null assertion)은 코틀린에서 null이 될 수 있는 타입의 값을 다룰 때 사용할 수 있는 도구 중에서 가장 단순하면서도 무딘 도구다. 느낌표를 이중(!!)으로 사용하면 어떤값이든 null이 될 수 없는 타입으로 강제 지정할 수 있다. 실제 null에 대해 !!를 적용하면 NPE가 발생한다.
6.1.7 let 함수
let 함수를 사용하면 null이 될 수 있는 식을 더 쉽게 다룰 수 있다. let 함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해서 결과가 null인지 검사한 다음에 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한꺼번에 처리할 수 있다.
let을 사용하는 가장 흔한 용례는 null이 될 수 있는 값을 null이 아닌 값만 인자로 받는 함수에 넘기는 경우다. 이메일을 보내는 sendEmailTo 함수가 이메일 주소를 String 타입의 파라미터로 ㅂ다는다고 하자. 이 함수는 코틀린으로 작성됐으며 null이 아닌 파라미터를 받는다.
fun sendEmailTo(email: String) { /* ... */ }
>>> val email: String? = ...
>>> sendEmailTo(email)
ERROR: Type mismatch: inferred type is String? but String was expected
이 에러를 해결하려면 인자를 넘기기 전에 주어진 값이 null인지 검사해야 한다.
if (email != null) sendEmailTo(email)
이를 let을 사용해 간단히 할 수 있다.
email?.let { email -> sendEmailTo(email) }
// email?.let { sendEmailTo(it) } 처럼 더 짧은 구문도 가능
아주 긴 식이 있고 그 값이 null이 아닐 때 수행해야 하는 로직이 있을 때 let을 쓰면 훨씬 더 편하다. let을 쓰면 긴 식의 결과를 저장하는 변수를 따로 만들 필요가 없다.
다음의 명시적인 if 검사가 있다고 하자.
val person: Person? = getTheBestPersonInTheWorld()
if (person != null) sendEmailTo(person.email)
이를 let을 사용하면 굳이 person 변수를 추가할 필요 없이 다음과 같이 쓸 수 있다.
getTheBestPersonInTheWorld?.let { sendEmailTo(it.email) }
6.1.8 나중에 초기화할 프로퍼티
코틀린에서는 클래스 안의 null이 될 수 없는 프로퍼티를 생성자 안에서 초기화하지 않고 특별한 메소드 안에서 초기화할 수는 없다. 코틀린에서 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 한다. 게다가 프로퍼티 타입이 null이 될 수 없는 타입이라면 반드시 null이 아닌 값으로 그 프로퍼티를 초기화해야 한다. 그런 초기화 값을 제공할 수 없으면 null이 될 수 있는 타입을 사용할 수 밖에 없다. 하지만 null이 될 수 있는 타입을 사용하면 모든 프로퍼티 접근에 null 검사를 넣거나 !! 연산을 써야 한다. 이런 경우의 코드는 보기 나쁘다. 특히 프로퍼티를 여러 번 사용해야 하면 코드가 더 못생겨진다. 이를 해결하기 위해 myService 프로퍼티를 나중에 초기화(late-initilized)할 수 있다. lateinit 변경자를 붙이면 프로퍼티를 나중에 초기화할 수 있다.
// 나중에 초기화하는 프로퍼티 사용하기
class MyService {
fun performAction(): String = "foo"
}
class MyTest {
private lateinit var myService: MyService // 초기화하지 않고 null이 될 수 없는 프로퍼티를 선언한다.
@Before fun setUp() {
myService = MyService() // setUp 메소드에서 프로퍼티를 초기화한다.
}
@Test fun testAction() {
Assert.assertEquals("foo", myService.performAction()) // null 검사를 수행하지 않고 프로퍼티를 사용한다.
}
}
나중에 초기화하는 프로퍼티는 항상 var여야 한다. val 프로퍼티는 파이널 필드로 컴파일되며, 생성자 안에서 반드시 초기화해야 한다. 따라서 생성자 밖에서 나중에 초기화해야하는 프로퍼티는 항상 var여야 한다. 그렇지만 나중에 초기화하는 프로퍼티는 null이 될 수 없는 타입이라 해도 더 이상 생성자 안에서 초기화할 필요가 없다. 그 프로퍼티를 초기화하기 전에 프로퍼티에 접근하면 "lateinit property myService has not been initialized"이라는 예외가 발생한다. 예외를 보면 어디가 잘못됐는지 확실히 알 수 있기에 단순한 NPE가 발생하는 것보다 훨씬 좋다.
6.1.9 null이 될 수 있는 타입 확장
null이 될 수 있는 타입에 대한 확장 함수를 정의하면 null 값을 다루는 강력한 도구로 활용할 수 있다. 어떤 메소드를 호출하기 전에 수신 객체 역할을 하는 변수가 null이 될 수 없다고 보장하는 대신, 직접 변수에 대해 메소드를 호출해도 확장 함수인 메소드가 알아서 null을 처리해준다. 이런 처리는 확장 함수에서만 가능하다. 일반 멤버 호출은 객체 인스턴스를 통해 디스패치되므로 그 인스턴스가 null인지 여부를 검사하지 않는다.
예를 들어, 코틀린 라이브러리에서 String을 확장해 정의된 isEmpty와 isBlank라는 함수를 생각해보자. isEmpty는 문자열이 빈 문자열("")인지 검사하고, isBlank는 문자열이 모두 공백 문자로 이뤄졌는지 검사한다. 이 문자열로 무언가 의미 있는 작업을 수행하고 싶은 경우 보통 이런 함수들로 문자열을 검사할 것이다. isEmpty나 isBlank처럼 null을 검사할 수 있다면 편하지 않을까? 실제로 String? 타입의 수신 객체에 대해 호출할 수 있는 isNullOrEmpty나 isNullOrBlank 메소드가 있다.
// null이 될 수 있는 수신 객체에 대해 확장 함수 호출하기
fun verifyUserInput(input: String?) {
if (input.isNullOrBlank()) { // 안전한 호출을 하지 않아도 된다.
println("Please fill in the required fields")
}
}
>>> verifyUserInput(" ")
Please fill in the required fields
>>> verifyUserInput(null) // null을 넣어도 아무런 예외가 발생하지 않는다.
Please fill in the required fields
자바에서는 메소드 안의 this는 그 메소드가 호출된 수신 객체를 가리키므로 항상 null이 아니다. 하지만 코틀린에서는 null이 될 수 있는 타입의 확장 함수 안에서는 this가 null이 될 수 있다는 점이 자바와 다르다.
let 함수도 null이 될 수 있는 타입의 값에 대해 호출할 수 있지만 let은 this가 null인지 검사하지 않는다. null이 될 수 있는 타입의 값에 대해 안전한 호출을 사용하지 않고 let을 호출하면 람다의 인자는 null이 될 수 있는 타입으로 추론된다.
>>> val person: Person = ...
>>> person.let { sendEmailTo(it) } // 안전한 호출을 하지 않음. 따라서 "it"은 null이 될 수 있는 타입으로 취급됨
ERROR: Type mismatch: inferred type is Person? but Person was expected
따라서 let을 사용할 때 수신 객체가 null이 아닌지 검사하고 싶다면 예전에 살펴본 person?.let { sendEmailTo(it) }처럼 반드시 안전한 호출 연산인 ?.을 사용해야한다.
6.1.10 타입 파라미터의 null 가능성
코틀린에서는 함수나 클래스의 모든 타입 파라미터는 기본적으로 null이 될 수 있다. null이 될 수 있는 타입을 포함하는 어떤 타입이라도 타입 파라미터를 대신할 수 있다. 따라서 타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용하면 이름 끝에 물음표가 없더라도 T가 null이 될 수 있는 타입이다.
// null이 될 수 있는 타입 파라미터 다루기
fun <T> printHashCode(t: T) {
pritln(t?.hashCode()) // "t"가 null이 될 수 있으므로 안전한 호출을 써야만 한다.
}
>>> printHashCode(null) // "T"의 타입은 "Any?"로 추론된다.
null
printHashCode 호출에서 타입 파라미터 T에 대해 추론한 타입은 null이 될 수 있는 Any? 타입이다. t 파라미터의 타입 이름 T에는 물음표가 붙어있지 않지만 t는 null을 받을 수 있다.
타입 파라미터가 null이 아님을 확실히 하려면 null이 될 수 없는 타입 상한(upper bound)을 지정해야 한다. 이렇게 null이 될 수 없는 타입 상한을 지정하면 null이 될 수 있는 값을 거부하게 된다.
// 타입 파라미터에 대해 null이 될 수 없는 타입 상한을 사용하기
fun <T: Any> printHashCode(t: T) { // 이제 "T"는 null이 될 수 없는 타입이다.
pritln(t?.hashCode()) // "t"가 null이 될 수 있으므로 안전한 호출을 써야만 한다.
}
>>> printHashCode(null)
Error: Type Parameter bound for 'T' is not satisfied
>>> printHashCode(42)
42
6.1.11 null 가능성과 자바
자바 코드에도 어노테이션으로 표시된 null 가능성 정보가 있다. 이런 정보가 코드에 있으면 코틀린도 그 정보를 활용한다. 따라서 자바의 @Nullable String은 코틀린 쪽에서 볼 때 String?과 같고, 자바의 @NotNull String은 코틀린쪽에서 볼 때 String과 같다.
플랫폼 타입
플랫폼 타입은 Kotlin이 자바 API에서 가져온 타입으로, 자바에서는 null 허용 여부가 명시적으로 표시되지 않거나 애노테이션(@Nullable, @NotNull)이 붙지 않은 타입
플랫폼 타입은 코틀린이 null 관련 정보를 알 수 없는 타입을 말한다. 그 타입이 null이 될 수 있는 타입으로 처리해도 되고 null이 될 수 없는 타입으로 처리해도 된다. 이는 자바와 마찬가지로 플랫폼 타입에 대해 수행하는 모든 연산에 모든 책임은 온전히 우리들에게 있다는 뜻이다. 컴파일러는 모든 연산을 허용한다. 코틀린은 보통 null이 될 수 없는 타입의 값에 대해 null 안전성을 검사하는 연산을 수행하면 경고를 표시하지만 플랫폼 타입의 값에 대해 null 안전성 검사를 중복 수행해도 아무 경고도 표시하지 않는다. 어떤 플랫폼 타입의 값이 null이 될 수 있음을 알고 있다면 그 값을 사용하기 전에 null인지 검사할 수 있다. 어떤 플랫폼 타입의 값이 null이 아님을 알고 있다면 아무 null 검사 없이 그 값을 직접 사용해도 된다.
// null 검사를 통해 자바 클래스 접근하기
fun yellAtSafe(person: Person) {
println((person.name ?: "Anyone").toUpperCase() + "!!!")
}
>>> yellAtSafe(Person(null))
ANYONE!!!
위 코드에서는 null 값을 제대로 처리하므로 실행 시점에 예외가 발생하지 않는다.
자바 API를 다룰 때는 조심해야 한다. 대부분의 라이브러리는 null 관련 어노테이션을 쓰지 않는다. 따라서 모든 타입을 null이 아닌 것처럼 다루기 쉽지만 그렇게 하면 오류가 발생할 수 있다. 오류를 피하려면 사용하려는 자바 메소드의 문서를 자세히 살펴봐서 그 메소드가 null을 반환할지 알아내고 null을 반환하는 메소드에 대한 null 검사를 추가해야한다.
상속
코틀린에서 자바 메소드를 오버라이드할 때 그 메소드의 파라미터와 반환 타입을 null이 될 수 있는 타입으로 선언할지, null이 될 수 없는 타입으로 선언할지 결정해야 한다.
// String 파라미터가 있는 자바 인터페이스
/* 자바 */
interface StringProcessor {
void process(String value)p
}
코틀린 컴파일러는 위 코드에 대해 다음과 같은 두 구현을 다 받아들인다.
// 자바 인텊에ㅣ스를 여러 다른 널 가능성으로 구현하기
class StringPrinter : StringProcessor {
override fun process(value: String) {
println(value)
}
}
class NullableStringPrinter : StringProcessor {
override fun process(value: String?) {
if (value != null) {
println(value)
}
}
}
자바 클래스나 인터페이스르 코틀린에서 구현할 경우 null 가능성을 제대로 처리하는 일이 중요하다. 구현 메소드를 다른 코틀린 코드가 호출할 수 있으므로 코틀린 컴파일러는 null이 될 수 없는 타입으로 선언한 모든 파라미터에 대해 null이 아님을 검사하는 단언문을 만들어주어야한다. 자바 코드가 그 메소드에게 null 값을 넘기면 이 단언문이 발동돼 예외가 발생한다. 설령 파라미터를 메소드 안에서 결코 사용하지 않아도 이런 예외는 피할 수 없다.
'Kotlin' 카테고리의 다른 글
[Kotlin in Action] 6.3 컬렉션과 배열 (0) | 2025.03.26 |
---|---|
[Kotlin in Action] 6.2 코틀린의 원시 타입 (0) | 2025.03.25 |
[Kotlin in Action] 5.5 수신 객체 지정 람다: with와 apply (0) | 2025.03.20 |
[Kotlin] @JvmField와 @JvmStatic의 차이 (0) | 2025.03.20 |
[Kotlin] @JvmStatic (0) | 2025.03.20 |
댓글