널 안전성

널 안전성

널 참조는 많은 언어에서 골칫거리예요. 자바에서도 null인 참조의 멤버에 접근하려 하면 NullPointerException, 줄여서 NPE가 터지죠. 사람들은 이걸 "10억 달러의 실수"라고 부르기도 해요. Kotlin의 **널 안전성(null safety)**은 바로 이 위험을 크게 줄이기 위해 설계된 기능이에요.

Kotlin은 타입 시스템 안에서 널 허용 여부를 명시적으로 다뤄요. 어떤 변수나 프로퍼티가 null을 가질 수 있는지 타입만으로 드러나고, 널이 아닌 변수에는 컴파일러가 null이 들어가지 못하게 강제해요. 그래서 잠재적인 널 문제를 실행 시점이 아니라 컴파일 시점에 잡을 수 있어요.

이 페이지에서는 널 허용 타입과 널 불가 타입의 차이부터 시작해서, ?. 안전 호출, ?: 엘비스 연산자, !! 단언 연산자 같은 처리 도구들을 하나씩 살펴볼게요.

출처: Null safety — Kotlin 공식 문서

널 허용 타입과 널 불가 타입

Kotlin의 타입 시스템은 null을 담을 수 있는 타입(널 허용)과 그렇지 않은 타입(널 불가)을 구분해요. 예를 들어 String 타입 변수는 null을 담을 수 없어요.

fun main() {
    // 널이 아닌 문자열을 변수에 할당
    var a: String = "abc"
    // 널 불가 타입에 null을 다시 할당하려 하면 오류
    a = null
    print(a)
}

a는 널 불가 변수라서 언제나 유효한 String 값을 가져요. 그래서 그 위에서 메서드를 호출하거나 프로퍼티에 접근해도 NPE가 날 걱정이 없어요.

fun main() {
    // 널이 아닌 문자열을 val에 할당
    val a: String = "abc"
    // 널 불가 변수의 길이를 반환
    val l = a.length
    print(l) // 3
}

null을 허용하고 싶으면 타입 바로 뒤에 ?를 붙여요. 예를 들어 String?라고 선언하면 그 타입은 null을 받아들일 수 있어요.

fun main() {
    // 널 허용 문자열을 var에 할당
    var b: String? = "abc"
    // 널 허용 변수에는 null을 다시 할당 가능
    b = null
    print(b) // null
}

널 허용 변수 b에서 직접 length에 접근하면 컴파일러가 오류를 뱉어요. 널 허용 변수는 null을 담을 수 있기 때문에, 직접 프로퍼티에 접근하는 건 NPE로 이어질 수 있기 때문이에요. 컴파일러는 널 허용 타입의 수신자에서는 안전 호출(?.)이나 널 단언 호출(!!.)만 허용해요.

if 조건으로 널 검사하기

널 허용 타입을 안전하게 다루는 가장 직접적인 방법은 if 조건식으로 null인지 먼저 확인하는 거예요.

fun main() {
    // 널 허용 변수에 null 할당
    val b: String? = null
    // 널 여부를 먼저 확인한 뒤 length에 접근
    val l = if (b != null) b.length else -1
    print(l) // -1
}

이 예시에서 컴파일러는 **스마트 캐스트(smart cast)**로 b의 타입을 널 허용 String?에서 널 불가 String으로 바꿔요. 검사했다는 정보를 추적한 덕분에 if 안에서 length 호출을 허용해 주죠. 더 복잡한 조건도 지원해요.

fun main() {
    // 널 허용 문자열을 val에 할당
    val b: String? = "Kotlin"
    // 널 여부를 먼저 확인한 뒤 length에 접근
    if (b != null && b.length > 0) {
        print("String of length ${b.length}") // String of length 6
    } else {
        print("Empty string")
    }
}

주의할 점은 이런 스마트 캐스트가 성립하려면 컴파일러가 검사와 사용 사이에 b가 바뀌지 않는다는 걸 보장할 수 있어야 한다는 거예요.

안전 호출 연산자 ?.

?. 연산자는 널 처리를 더 짧게 줄여줘요. 객체가 null이면 NPE를 던지는 대신 그냥 null을 반환하죠.

fun main() {
    // 널 허용 문자열 할당
    val a: String? = "Kotlin"
    // 널 허용 변수에 null 할당
    val b: String? = null
    // 널 여부를 확인하고 길이 또는 null 반환
    println(a?.length) // 6
    println(b?.length) // null
}

b?.lengthb가 널이 아니면 b.length를, 널이면 null을 반환해요. 이 표현식의 타입은 Int?예요.

안전 호출은 연쇄할 때 특히 유용해요. 예를 들어 직원 Bob이 부서에 배정됐을 수도 아닐 수도 있고, 그 부서에는 다시 부서장이 있을 수도 없을 수도 있다고 해볼게요. Bob 부서장의 이름을 얻으려면 이렇게 쓰면 돼요.

bob?.department?.head?.name

체인에서 어느 한 프로퍼티가 null이면 이 표현식은 null을 반환해요.

할당문의 왼쪽에도 안전 호출을 둘 수 있어요.

person?.department?.head = managersPool.getManager()

여기서 체인 안의 수신자가 null이면 할당은 건너뛰고 오른쪽 표현식은 아예 평가되지 않아요. person이나 person.departmentnull이면 함수도 호출되지 않죠.

엘비스 연산자 ?:

널 허용 타입을 다룰 때 null이면 대체 값을 주고 싶은 경우가 많아요. if 식으로도 되겠지만, **엘비스 연산자(Elvis operator) ?:**를 쓰면 훨씬 간결해져요.

fun main() {
    // 널 허용 변수에 null 할당
    val b: String? = null
    // null이 아니면 length, null이면 0을 반환
    val l = b?.length ?: 0
    println(l) // 0
}

?: 왼쪽 표현식이 null이 아니면 그 값을 그대로 반환하고, null이면 오른쪽 표현식을 반환해요. 오른쪽 표현식은 왼쪽이 null일 때만 평가돼요.

Kotlin에서 throwreturn도 표현식이므로 엘비스 연산자 오른쪽에 쓸 수 있어요. 함수 인자를 검사할 때 특히 편리하죠.

fun foo(node: Node): String? {
    // getParent()가 null이 아니면 parent에 할당, null이면 null 반환
    val parent = node.getParent() ?: return null
    // getName()이 null이 아니면 name에 할당, null이면 예외 던짐
    val name = node.getName() ?: throw IllegalArgumentException("name expected")
    // ...
}

널 단언 연산자 !!

**널 단언 연산자(not-null assertion) !!**는 어떤 값이든 널 불가 타입으로 바꿔요. 값이 null이 아니면 그냥 널 불가 타입처럼 취급돼서 정상적으로 실행되고, null이면 강제로 널 불가로 다뤄지다가 NPE가 나요.

fun main() {
    // 널 허용 문자열 할당
    val b: String? = "Kotlin"
    // b를 널 불가로 취급하고 length에 접근
    val l = b!!.length
    println(l) // 6
}

bnull이면 !!는 널 불가로 다루라고 강제하므로 NPE가 발생해요.

!!는 값이 확실히 null이 아니라고 자신할 때 유용해요. 컴파일러가 어떤 규칙 때문에 널 불가임을 보장하지 못하지만, 내가 보기엔 분명 널이 아니라는 상황이죠. 그럴 때 !!로 "이 값은 널이 아니다"라고 명시적으로 알려주는 거예요.

널 허용 수신자와 let 함수

확장 함수는 널 허용 수신자 타입에도 정의할 수 있어요. 그러면 변수가 null일 수도 있는 자리에서 안전하게 그 함수를 호출할 수 있죠. 함수 내부에서 null을 처리하면 호출하는 곳마다 일일이 검사하지 않아도 돼요.

예를 들어 .toString() 확장 함수는 널 허용 수신자에서 호출돼요. null 값에서 호출하면 예외 없이 문자열 "null"을 안전하게 돌려줘요.

fun main() {
    // person 변수에 null 할당
    val person: Person? = null
    // 널 허용 변수에 .toString 적용 후 출력
    println(person.toString()) // null
}

// 간단한 Person 데이터 클래스
data class Person(val name: String)

.toString()이 널 허용 문자열을 돌려주도록 하고 싶다면(null일 수도 있는 문자열) 안전 호출 연산자 ?.를 쓰면 돼요. ?.는 객체가 널이 아닐 때만 .toString()을 호출하고, 아니면 null을 반환해요.

fun main() {
    // 널 허용 Person 객체 할당
    val person1: Person? = null
    val person2: Person? = Person("Alice")
    // person이 null이면 "null" 출력, 아니면 toString 결과 출력
    println(person1?.toString()) // null
    println(person2?.toString()) // Person(name=Alice)
}

// Person 데이터 클래스 정의
data class Person(val name: String)

널 허용 타입에서만 작업을 수행하고 싶을 땐 안전 호출 ?.let 함수를 함께 써요. 표현식을 평가해 결과가 null인지 확인하고, 널이 아닐 때만 코드를 실행해 주죠.

fun main() {
    // 널 허용 문자열 리스트 선언
    val listWithNulls: List<String?> = listOf("Kotlin", null)
    // 각 항목을 순회하며 null이 아닌 값만 출력
    for (item in listWithNulls) {
        item?.let { println(it) } // Kotlin
    }
}

안전 캐스트와 널 컬렉션

일반 캐스트 연산자 as는 객체가 대상 타입이 아니면 예외를 던질 수 있어요. **안전 캐스트 as?**는 값을 지정 타입으로 캐스팅하려 시도하고, 맞지 않으면 null을 반환해요.

fun main() {
    // 어떤 타입의 값이든 담을 수 있는 Any 변수 선언
    val a: Any = "Hello, Kotlin!"
    // 'as?' 연산자로 Int로 안전 캐스트
    val aInt: Int? = a as? Int
    // 'as?' 연산자로 String으로 안전 캐스트
    val aString: String? = a as? String
    println(aInt) // null
    println(aString) // "Hello, Kotlin!"
}

aInt가 아니므로 캐스트가 안전하게 실패해 null을 출력하고, String? 타입과는 일치하므로 안전 캐스트가 성공해요.

널 요소를 담는 컬렉션에서 널이 아닌 값만 남기고 싶다면 filterNotNull() 함수를 써요.

fun main() {
    // null과 non-null 정수를 담는 리스트 선언
    val nullableList: List<Int?> = listOf(1, 2, null, 4)
    // null 값을 걸러내 널 불가 정수 리스트 생성
    val intList: List<Int> = nullableList.filterNotNull()
    println(intList) // [1, 2, 4]
}

더 알아보기