본문 바로가기

Kotlin

공식 문서로 배우는 코틀린 - 36. Null safety

서른여섯 번째, 널 안전성입니다.

 

널 안전성에서는 ALGOL W에서 널 참조 개념을 고안한 토니 호어가 (바로 아래 언급되는) 십억 달러의 실수라고도 언급한 문제를 언어 차원에서 해결하기 위한 Kotlin의 방법을 설명합니다. Java를 사용하다 Kotlin을 쓰게 됐을 때 처음에는 많은 불편을 느끼게 되는 부분이기도 합니다. 하지만, 좀 익숙해 지면 불편함은 사라지게 되고 NPE의 위험으로부터 벗어나서 좀 더 안전한 프로그램을 작성할 수 있음을 느끼게 됩니다. Kotlin 사용 초기에 반드시 익숙해져야 할 내용입니다.

 

널 가능한(nullable) 타입과 가능하지 않은(non-nullable) 타입

Kotlin의 타입 체계는 십억 달러의 실수라고도 알려져 있는 null 참조의 위험성을 제거하는 것을 목표로 하고 있습니다.

 

Java를 포함함 많은 프로그래밍 언어들에서 공통의 잠재적인 위험(pitfalls) 중에 하나는  null 참조의 멤버를 접근하여 참조 예외가 발생하는 것입니다. Java에서는 NullPointException이 이에 해당하고, 줄여서 NPE라고도 합니다.

 

Kotlin에서 NPE의 가능한 원인은 단지 다음과 같습니다.

  • 명시적인 호출: throw NullPointException()
  • (아래에서 설명 될) !! 연산자 사용
  • 다음과 같은 때 초기화와 관련된 데이터 불일치
    • 생성자에서 사용 가능한 초기화 되지 않은 this가 어딘 가로 전달되고 사용되는 경우(누출된 this)
    • 수퍼 클래스의 생성자가 구현 부분에서 초기화 되지 않은 상태를 사용하는 파생된 클래스의 open 멤버를 호출하는 경우
  • Java와 상호 운용
    • 플랫폼 타입 널 참조의 멤버를 접근 시도
    • Java와 상호 운용을 위해 사용되는 제네릭 타입의 널 가능성 문제. 예를 들어, Java 코드에서 Kotlin의 MutableList<String>에 null을 추가하려고 할 수 있습니다. 그러므로, 이를 위해서는 MatableList<String?>이 필요합니다.
    • 외부 Java 코드로 인한 다른 문제들

Kotlin의 타입 체계는 null을 가질 수 있는 참조(널 가능 참조)와 그렇지 않은 참조(널 가능하지 않은 참조)를 구분합니다. 예를 들어, String 타입의 일반 변수는 null을 가질 수 없습니다.

fun main() {
    var a: String = "abc"
    a = null // 컴파일 오류
}

 

널을 허용하기 위해서 변수를 널 가능한 문자열(String?)로 선언할 수 있습니다.

fun main() {
    var b: String? = "abc" // null을 설정할 수 있습니다.
    b = null // 가능합니다.
    print(b)
}

 

이제 (위의 코드) a의 메소드를 호출하거나 프로퍼티를 접근하면 NPE가 발생하지 않는다는 것이 보장됩니다. 그래서, 다음과 같은 안전하다고 할 수 있습니다.

val l = a.length

 

하지만, b에 대해서 같은 프로퍼티를 접근하는 것은 안전하다고 보장할 수 없습니다. 그래서, 컴파일러는 오류를 보고합니다.

val l = b.length // 오류: 변수 'b'는 널일 수도 있습니다.

 

하지만, 이런 경우에도 여전히 프로퍼티를 접근해야 할 수 있습니다. 그렇게 할 수 있는 몇 가지 방법이 있습니다.

 

조건에서 널 검사

첫째로, b가 null 인지 명시적으로 검사하고 각각의 경우를 나누어서 다룹니다.

val l = if (b != null) b.length else -1

 

컴파일러는 수행된 검사를 추적하고 if 안쪽에서 length 호출을 허용합니다. 보다 복잡한 조건 또한 잘 지원됩니다.

fun main() {
    val b: String? = "Kotlin"
    if (b != null && b.length > 0) {
        print("String of length ${b.length}")
    } else {
        print("Empty string")
    }
}

/* 결과
String of length 6
*/

 

이것은 b가 불변성일 때만 동작한다는 것을 주목해야 합니다. 만약 그렇지 않으면 b가 널 검사 이후에 널이 될 수도 있기 때문입니다. 불변성이어야 한다는 것은 지역 변수일 때는 검사와 사용 사이에 변경이 없어야 하고, 멤버일 때는 val 이고 뒷받침하는 필드를 가지며 오버라이딩 불가해야 한다는 의미입니다.

 

안전 호출 (Safe calls)

널 가능한 변수의 프로퍼티를 접근하기 위한 두 번째 옵션은 안전 호출 연산자 ?. 을 사용하는 것입니다.

fun main() {
    val a = "Kotlin"
    val b: String? = null
    println(b?.length)
    println(a?.length) // 불필요한 안전 호출
}

/* 결과
null
6
*/

 

b?.length는 b가 널이 아니면 b.length를 반환하고, 그렇지 않고 널이면 null을 반환합니다. 이 표현식의 타입은 Int? 입니다.

 

안전 호출은 연쇄적인 호출에서 유용합니다. 예를 들어, Bob은 직원인데 부서에 배치될 수도, 안 될 수도 있습니다. 부서에는 다른 직원이 부서장으로서 있을 수 있습니다. 밥의 부서에 부서장이 있는 경우 해당 부서장의 이름을 얻기 위해서는 다음과 같이 작성할 수 있습니다.

bob?.department?.head?.name

 

이런 연쇄적인 호출은 포함된 프로퍼티 중에 하나라도 널이면 널을 반환합니다.

 

널이 아닌 값에 대해서만 특정 작업을 실행해야 하는 경우에는 안전 호출 연산자를 let과 함께 사용할 수 있습니다.

fun main() {
    val listWithNulls: List<String?> = listOf("Kotlin", null)
    for (item in listWithNulls) {
        item?.let { println(it) } // "kotlin"은 출력하고 null은 무시합니다.
    }
}

/* 결과
kotlin
*/

 

안전 호출은 또한 할당의 왼쪽 부분에 위치할 수 있습니다. 연쇄적인 안전 호출에서 하나의 수신자라도 널이면 할당은 건너 뛰게 되고 오른쪽 부분의 표현식은 평가(evaluation) 되지 않습니다.

// `person`이나 `person.department`이 널이면, 함수는 호출되지 않습니다.
person?.department?.head = managersPool.getManager()

 

널 가능한 수신자

확장 함수는 널 가능한 수신자에 정의될 수 있습니다. 이런 확장 함수에 널 검사를 추가하면 확장 함수를 호출하는 지점에서 매번 널 검사를 하지 않아도 됩니다.

 

예를 들어, toString()은 널 가능한 수신자에 정의돼 있습니다. 이 함수는 수신자가 널인 경우 null이 아니라 "null"이라는 문자열을 반환합니다. 이는 특정 상황에서 유용한데 예를 들면 로그를 기록하는 상황 같은 경우입니다.

val person: Person? = null
logger.debug(person.toString()) // null이 아니고 "null" 문자열이기 때문에 예외가 발생하지 않고 "null"이 기록됩니다.

 

만약, toString() 호출 시 널 가능한 문자열을 받고 싶으면 안전 호출 연산자(?.)를 사용합니다.

var timestamp: Instant? = null
val isoTimestamp = timestamp?.toString() // null인 String? 타입 객체를 반환합니다.
if (isoTimestamp == null) {
   // timestamp 가 널인 경우를 다루는 부분
}

 

엘비스 연산자

널 가능한 참조 b가 있을 때, b가 널이 아니면 b를 사용하고, 널이면 널이 아닌 특정한 값을 사용하라고 하고 싶은 경우 다음과 같이 작성할 수 있습니다.

val l: Int = if (b != null) b.length else -1

 

온전한 if 표현식을 사용하는 대신에 엘비스 연산자 ?: 를 사용하여 다음과 같이 표현할 수도 있습니다.

val l = b?.length ?: -1

 

※ 엘비스 프레슬리 머리 모양을 닮았다고 엘비스 연산자라고 부릅니다.

 

?: 의 왼쪽 표현식이 널이 아니면, 엘비스 연산자는 해당 표현식을 반환합니다. 그렇지 않은 경우에는 오른쪽 표현식을 반환합니다. 오른쪽 부분은 왼쪽 부분이 널인 경우에만 평가(evaluation) 된다는 것을 주의해야 합니다.

 

Kotlin에서는 throw와 return이 표현식이기 때문에 이 둘도 엘비스 연산자의 오른쪽 부분에 사용할 수 있습니다. 이는 예를 들어 함수의 인수를 검사하는 것 같은 데에 유용합니다.

fun foo(node: Node): String? {
    val parent = node.getParent() ?: return null
    val name = node.getName() ?: throw IllegalArgumentException("name expected")
    // ...
}

 

!! 연산자

세 번째 옵션은 NPE를 좋아하는 사람을 위한 것입니다. 널 아님 단언 연산자(non-null assertion operator, !!)는 어느 값이든 널 가능하지 않은 타입으로 변환하고 만약 값이 null이면 예외를 던집니다. 예를 들어, b!!라고 하면 널이 아닌 b의 값이 반환되거나 b가 널인 경우 NPE를 던집니다.

 

※ non-null assertion operator를 직역한 '널 아님 단언 연산자'라는 한글 표현으로는 좀 이상하게 보입니다. "널이 아님을 단언(주장)하는 연산지인 !!는" 식으로 의역할 수도 있었지만, 영어권에서의 표현을 알아두면 좋기는 해서 영어를 괄호 안에 표기하고 그대로 직역했습니다(그래서, 괄호 안의 영어 표현을 기억하는 게 더 좋기는 합니다). 의미만 받아 들이시면 될 거 같고, 우리말로 이 연산자를 부를 때는 '느낌표 두 개'나 그냥 영어로 더블 뱅(double-bang) 연산자라고 부르는 경우가 많을 거라고 생각됩니다.

val l = b!!.length

 

이렇게 NPE를 원하는 경우에는 NPE를 가질 수 있습니다. 다만, 이렇게 명시적으로 요청해야 합니다. NPE 그냥 갑자기 나타나지는 않습니다.

 

안전한 캐스트

보통의 캐스트는 객체가 대상 타입이 아닌 경우 ClassCastException을 초래할 수 있습니다. 안전한 캐스트를 사용하기 위한 다른 옵션은 캐스트가 실패할 때 null을 반환하는 것입니다.

val aInt: Int? = a as? Int // a를 Int로 캐스트하는데 실패하면 널을 반환합니다.

 

널 가능한 타입의 컬렉션

널 가능한 타입의 항목을 갖는 컬렉션이 있는데 널이 아닌 항목(element)들만 필터링하고 싶은 경우에는 filterNotNull을 사용합니다.

val nullableList: List<Int?> = listOf(1, 2, null, 4)
val intList: List<Int> = nullableList.filterNotNull()
// intList는 [1, 2, 4]

 

※ filter라는 단어는 특정한 것을 걸러내고 그 나머지를 통과 시키는 여과라는 뜻과 여광기나 여파기처럼 특정한 빛이나 신호만 통과 시킨다는 뜻으로 쓰일 수 있습니다.  주의해야 할 것이 Java나 Kotlin, Python 등등 프로그래밍 언어 쪽에서는 주로 후자의 의미로 사용됩니다. 생활 속에서 필터, 필터링이라고 하면 뭔가 걸러낸다는 뜻으로 많이 쓰이기 때문에 (약간의) 주의가 필요합니다.