
앞에서도 짧게 등장했지만 자세히 다루지 않았던 널에 대한 개념 정리로 돌아온 개발자 꿈나무 김조현입니다.
인사말에서 설명했듯이 널이 될 수 있는 타입과 코틀린에서 널을 다루는 방법, 자바의 널을 코틀린에서 어떻게 처리하는지 등에 대해 정리해보겠습니다.
코틀린에서는 널 가능성을 지원합니다.
널 가능성은 NullPointerException 오류를 피할 수 있게 돕는 코틀린 타입 시스템의 특성입니다.
코틀린은 널 가능성을 사용해 null 문제를 실행 시점에서 컴파일 시점으로 옮길 수 있게 되며, 실행 시점에서 발생할 수 있는 예외의 가능성을 줄여줍니다.
코틀린은 널이 될 수 있는 타입을 명시적으로 지원합니다. 이 말은 널이 될 수 있는 변수에 대해 null을 허용하게 만드는 것입니다.
⭐️ 자바와 코틀린의 차이점
자바는 코틀린과 다르게 널 타입을 명시적으로 지원하지 않습니다. 이는 자바의 타입 시스템이 null을 제대로 다루지 못한다는 의미입니다.
int strLen(String s) {
return s.length();
}
위의 자바 함수는 안전하지 않습니다. 이 함수에 null을 넘긴다면 NullPointerException이 발생하기 때문입니다.
fun strLen(s: String) = s.length
fun main() {
strLen(null)
// e: Null cannot be a value of a non-null type 'String'.
}
이는 코틀린에서도 마찬가지 입니다. null을 함수에 보낸다면 컴파일 시 오류를 반환합니다.
위 함수에서 null을 포함하는 모든 문자열을 받도록 하기 위해서는 파라미터 타입 뒤에 물음표(?)를 붙여줘야합니다.
fun strLen(s: String?) = /* ... */
코틀린에서는 어떤 타입이든 타입 이름 뒤에 물음표를 붙이면 그 타입의 변수나 프로퍼티에 null 참조를 저장할 수 있습니다. 코틀린에서 모든 타입은 기본적으로 항상 null을 가질 수 없습니다. 명시적으로 ?를 붙여야 null이 될 수 있습니다.
또한 null이 될 수 있는 타입 값은 수행할 수 있는 연산의 종류가 제한됩니다.
fun strLenSafe(s: String?) = s.length()
이 함수의 파라미터인 s가 null이 될 수도 있기 때문에 length 함수의 사용이 제한되어 실행했을 때 오류를 출력하게 됩니다.
이런 원리로 null이 될 수 있는 값을 null이 될 수 없는 타입의 변수에 대입할 수 없으며, 함수에도 전달할 수 없습니다.
fun strLen(s: String) = s.length
fun main() {
val x: String? = null
val y: String = x
// null이 될 수 없는 값에 null이 될 수 있는 타입 값을 넣을 수 없다.
strLen(x)
// null이 될 수 있는 타입의 값을 null이 아닌 타입의 파라미터를 받는 함수에 전달할 수 없다.
}
이런 제약을 없애기 위해서는 null과 비교를 해야합니다. null과 비교하고 나면 컴파일러가 이 사실을 기억하고 null이 아님이 확실한 영역에서는 null이 아닌 타입의 값처럼 사용할 수 있도록 해줍니다.
fun strLenSafe(s: String?): Int = if (s != null) s.length else 0
fun main() {
val x: String? = null
println(strLenSafe(x))
// 0
println(strLenSafe("abc"))
// 3
}
이 코드는 함수에서 null임을 확인하여 null이 아닌 영역을 만들어낼 수 있어 s.length가 동작할 수 있는 것입니다.
코틀린이 제공하는 호출 연산자인 ?.는 null 검사와 메소드 호출을 한 연산으로 수행합니다.
str?.uppercase()
if (str != null) str.uppercase() else null
이 두 문장은 같은 의미입니다. 호출 연산자 ?.를 사용하면 null이 아닐 경우 일반 메소드처럼 호출하며 null일 때는 메소드 호출을 무시하고 결과값은 null이 됩니다.
fun printAllCaps(str: String?) {
val allCaps: String? = str?.uppercase()
println(allCaps)
}
fun main() {
printAllCaps("abc")
// ABC
printAllCaps(null)
// null
}
호출 연산자 ?.를 사용해서 안전한 호출을 해도 결과 타입이 null이 될 수 있습니다. 위 코드의 경우 null을 넣었을 때 결과 값이 null이 되어 출력되는 것을 볼 수 있습니다.
그리고 안전한 호출을 연쇄하여 사용할 수도 있습니다.
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 main() {
val person = Person("Dmitry", null)
println(person.countryName())
// Unknown
}
코틀린은 null 대신 사용할 기본값을 지정할 때 엘비스 연산자를 사용합니다. 엘비스 연산자는 ?:로 사용하며 다른 이름으로 널 복합 연산자라고 불리기도 합니다.
fun strLenSafe(s: String?): Int = s?.length ?: 0
fun main() {
println(strLenSafe("abc"))
// 3
println(strLenSafe(null))
// 0
}
위 코드는 널이 아닐 경우에 길이를 구하며, null일 경우에는 0을 넣는 strLenSafe함수를 사용했습니다.
엘비스 연산자의 오른쪽에 return, throw 등의 식을 넣어 왼쪽 값이 null일 경우에 즉시 어떤 값을 반환하거나 예외를 던지는 방식으로 사용할 수도 있습니다.
이전에서 코틀린 타입 캐스트 연산자인 as에 대해 공부했었습니다. as는 지정한 타입으로 바꿀 수 없다면 ClassCastException이 발생합니다.
이를 막기 위해 is로 검사한 후에 as를 사용할 수도 있지만, 이런 식으로 사용하면 코드가 많아지기 때문에 이런 문제를 해결할 연산자를 코틀린은 제공합니다.
as? 연산자는 어떤 값을 지정한 타입으로 변환하지만, 변환할 수 없다면 null을 반환합니다.
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()
}
fun main() {
val p1 = Person("Dmitry", "Jemerov")
val p2 = Person("Dmitry", "Jemerov")
println(p1 == null)
// false
println(p2 == p2)
// true
println(p1.equals(42))
// false
}
equals를 구현할 때도 as?를 활용할 수 있습니다. 모든 타입에 대해 파라미터를 받지만 Person으로 바꿀 수 없다면 즉시 false를 반환하는 것입니다. 이렇게 as?를 사용하면 null이나 값의 검사를 더욱 쉽게 할 수 있습니다.
널 아님 단언은 코틀린에서 널이 될 수 있는 타입의 값을 다룰 때 사용하는 도구입니다.
느낌표를 이중(!!)으로 사용하며 어떤 값이든 널이 아닌 타입으로 바꿀 수 있습니다. 그렇지만 null에 대해 !!를 적용하면 NullPointerException이 발생합니다.
예외가 발생한다면 null 값을 사용하는 코드가 아니라 단언문이 위치한 곳을 가리킵니다.


이처럼 4번째 줄에 널 아님 단언을 했지만 null이 들어갔을 때 널 아님 단언 코드 줄을 가리킵니다.
이런 예외의 스택 트레이드에는 어떤 파일의 몇 번째 줄인지에 대한 정보는 들어있지만 어떤 식에서 예외가 발생했는지에 대한 정보는 들어있지 않기 때문에 여러 개의 !! 단언문을 한 줄에 함께 쓰는 일은 피하는 것이 가장 좋습니다.
person.company!!.address!!.country
let 함수를 안전한 호출 연산자와 함께 사용하면 널이 될 수 있는 식을 더 쉽게 다룰 수 있습니다.
이런 조합으로 사용한다면 결과가 null인지 검사하고, 그 결과를 변수에 넣는 작업을 한 번에 처리할 수 있습니다.
만약 안전한 호출 연산자와 함께 사용하지 않는다면 수신 객체를 무조건 람다에 넘깁니다.
fun sendEmailTo(email: String) {
println("Sending email to $email")
}
fun main() {
var email: String? = "yole@example.com"
email?.let { sendEmailTo(it) }
// Sending email to yole@example.com
email = null
email?.let { sendEmailTo(it) }
//
}
코틀린에서는 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 합니다. 게다가 프로퍼티 타입이 널이 될수 없는 타입이라면 반드시 널이 아닌 값으로 프로퍼티를 초기화해야 합니다. 하지만 널이 될 수 있는 타입을 사용하면 모든 프로퍼티 접근에 null 검사를 넣거나 !! 연산자를 사용해야 합니다.
class MyService {
fun performAction(): String = "Action Done!"
}
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class MyTest {
private val myService: MyService? = null
@BeforeAll fun setUp() {
myService = MyService()
}
@Test fun testAction() {
assertEquals("Action Done!", myService!!.performAction())
}
}
이렇게 작성된 코드는 프로퍼티를 여러 번 사용하면서 보기에 힘들다는 문제점이 있습니다. 코틀린은 지연 초기화를 사용하여 문제를 해결합니다.
지연 초기화를 사용하는 방법은 lateinit 변경자를 붙이면 됩니다. lateinit 프로퍼티는 널이 될 수 없는 타입이지만 값을 즉시 초기화할 필요가 없습니다.
지연 초기화를 사용하면 생성자 밖에서 값을 바꿔야 하는데, val 프로퍼티는 생성자 안에서 반드시 초기화돼야 합니다. 이런 이유로 지연 초기화 프로퍼티는 항상 var로 생성해야 합니다.
class MyService {
fun performAction(): String = "Action Done!"
}
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class MyTest {
private lateinit var myService: MyService
@BeforeAll fun setUp() {
myService = MyService()
}
@Test fun testAction() {
assertEquals("Action Done!", myService.performAction())
}
}
isEmpty나 isBlank를 사용하는 것처럼 null을 쉽게 검사할 수 있는 isNullOrEmpty과 isNullOrBlank 메소드가 있습니다. 이 메소드들은 안전한 호출 없이도 널이 될 수 있는 수신 객체 타입에 대해 선언된 확장 함수를 호출할 수 있습니다.
fun verifyUserInput(input: String?) {
if (input.isNullOrBlank()) {
println("Please fill in the required fields")
}
}
fun main() {
verifyUserInput(" ")
// Please fill in the required fields
verifyUserInput(null)
// Please fill in the required fields
}
isNullOrBlank()은 널을 명시적으로 검사해서 null인 경우에 true를 반환하고, null이 아닌 경우 isBlank()를 호출합니다.
fun String?.isNullOrBlank(): Boolean =
this == null || this.isBlank()
⭐️ 자바와 코틀린의 차이점
자바에서는 메소드 안의 this는 그 메소드가 호출된 수신 객체를 가리키므로 항상 null이 아닙니다. 코틀린에서는 널이 될 수 있는 타입의 확장 함수 안에서는 this가 null이 될 수 있습니다.
코틀린의 널을 다루는 방법에 대해 살펴봤습니다. 그치만 자바의 타입 시스템은 널 가능성을 지원하지 않습니다.
코틀린이 자바와의 상호 운영성을 위해 널을 어떻게 사용해야하는지에 대해 정리해보겠습니다.
자바에서는 @NotNull String, @Nullable String과 같이 널을 다루기 위한 어노테이션을 제공합니다.
@Nullable String은 코틀린에서 String?과 같고, @NotNull String은 코틀린의 String과 같습니다.
플랫폼 타입은 널 가능성 어노테이션들이 없는 경우에 코틀린이 널 가능성을 이해하는 방식입니다.
플랫폼 타입은 코틀린이 널 관련 정보를 알 수 없는 타입을 말합니다. 즉, 그 타입을 널이 될 수 있는 타입으로 처리해도 되고 널이 될 수 없는 타입으로 처리해도 되는 것입니다. 이 말은 자바의 널 가능성에 대해서는 프로그래머가 직접 처리해야하는 것입니다.
플랫폼 타입을 표시하는 방법은 타입 이름 뒤에 느낌표를 붙이는 방식을 사용합니다. 하지만 코틀린에서 플랫폼 타입을 명시할 수 없고, 자바 코드에서 가져온 타입만 플랫폼 타입이 되는 것입니다.
public class Person {
private final String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
위 자바 클래스의 getName이 null을 반환할까? 반환하지 않을까?에 대해서는 직접 처리해야합니다.
null이 아님을 확신할 수 있다면 타입 검사 없이 참조할 수 있지만, null이 들어갔을 때 예외가 발생할 수도 있습니다.
fun yellAt(person: Person) {
println(person.name.uppercase() + "!!!")
}
fun main() {
yellAt(Person(null))
// NullPointerException 발생
}
그래도 되지만, 만약 배열에 대해 다룬다고 생각해보면 ArrayList<String?>?처럼 배열의 모든 원소에 대해 null 검사를 수행하거나 안전한 캐스트를 수행해야 합니다.
이런 방식으로 처리한다면 널 안전성으로 얻는 이익보다 검사에 드는 비용이 더 커지는 문제점이 발생합니다.
이번 글에서는 코틀린에서의 널에 대한 개념과 자바와 상호 운용성을 위해 어떤 식으로 자바의 널을 처리하는지에 대해 정리해봤습니다.
코딩을 하면서 제가 가장 까다로웠다고 느꼈던 부분이 바로 널을 다루는 것입니다.
널이라는 것이 '아무 것도 가지지 않은 비어있는 값'이라는 개념은 알지만 어떨 때 널이 사용되는지, 어떤 상황일 때 널 처리를 해야하는지 등 컴파일러가 해결하라고 명시하지 않는 한 제 의지로 널을 처리하겠다고 생각한 적이 없었습니다.
기본적으로 코틀린의 타입은 널이 안되는 것이 기본이며, 널을 처리하기 위해 (?.), (?:) 등과 같이 다양한 연산자를 사용할 수 있다는 것을 알게 되었습니다. 그리고 자바의 API에서 참조를 할 때는 플랫폼 타입으로 표현되며 스스로 문서를 읽어보며 널 처리가 필요한지 확인하는 과정을 통해 검사의 유무를 정해야 한다는 것을 알게 되었습니다.
글을 읽으며 모든 타입을 플랫폼 타입으로 처리하면 되지 않을까라는 궁금증이 들었었는데, 이 질문에 대한 답을 얻으며 왜 모든 타입을 플랫폼 타입으로 처리하면 안되는지 이해하게 되었습니다.
다음에는 코틀린의 기본 타입과 컬렉션, 배열에 대한 정리글로 돌아오겠습니다.
읽어주셔서 감사합니다!🙂↕️