본문 바로가기

Language/Kotlin

[Kotlin] 코틀린 온보딩 11편 - 널이 될 수 있는 값: null 안전성 한 번에 정리하기

지난 편에서는 object와 companion object, 객체 식, value class를 살펴봤습니다.

https://dmoritle.tistory.com/292

 

[Kotlin] 코틀린 온보딩 10편 - object와 companion object: 싱글톤부터 value class까지 이해하기

지난 편에서는 생성자를 중심으로 프로퍼티의 정의와 초기화, 상속 시 생성자 호출 문법을 살펴봤습니다.https://dmoritle.tistory.com/291 [Kotlin] 코틀린 온보딩 9편 - 생성자: 정의와 초기화를 한 번에

dmoritle.tistory.com

 

자바 개발자에게 NullPointerException은 가장 익숙한 예외입니다. 코틀린은 이 문제를 코딩 컨벤션이나 Optional 같은 라이브러리가 아니라 타입 시스템으로 풀었습니다. 이번 편에서는 다음 내용을 다룹니다.

  • String과 String?의 구분
  • null을 다루는 도구들 (?., ?:, !!, as?, let, nullable 확장 함수)
  • 지연 초기화 프로퍼티 lateinit
  • 타입 파라미터의 널 가능성
  • 자바와 만나는 지점: 플랫폼 타입
  • 그래도 NPE가 발생하는 경우
  • 실무 가이드

이 글의 예제는 Kotlin 2.2.21, JDK 21 환경에서 직접 컴파일하고 실행해서 확인했습니다.

 

1. 타입 시스템에 null이 들어오다

코틀린에서 모든 타입은 기본적으로 null을 허용하지 않습니다. null을 허용하려면 타입 뒤에 ?를 붙여 별개의 타입으로 선언해야 합니다. 이 글에서는 String?처럼 null을 허용하는 타입을 nullable 타입, String처럼 허용하지 않는 타입을 non-null 타입이라고 부르겠습니다.

var a: String = "abc"
a = null          // 컴파일 에러: null은 non-null 타입 String의 값이 될 수 없습니다

var b: String? = "abc"
b = null          // OK

 

String과 String?는 서로 다른 타입입니다. 그래서 nullable 값의 멤버에 바로 접근하는 코드는 컴파일 단계에서 막힙니다.

fun printLength(b: String?) {
    val l = b.length  // 컴파일 에러: 안전 호출(?.) 또는 !!. 호출만 허용됩니다
}

컴파일러는 b에 실제로 무엇이 들어 있는지가 아니라 선언된 타입을 보고 판단합니다. 이 함수 안에서는 호출하는 쪽이 "abc"를 넘길지 null을 넘길지 알 수 없고, 타입이 String?이므로 null일 수 있는 값으로 취급해 막습니다.

 

자바에는 이런 구분이 없어서 같은 코드가 컴파일을 통과하고, 누군가 null을 넘긴 순간 실행 중에 NPE가 발생합니다. 코틀린은 이 오류를 컴파일 타임으로 끌어올립니다.

그림처럼 String은 String?에 포함되는 타입이라 String? 변수에는 String과 null이 모두 들어가지만, String 변수에는 null이 들어갈 수 없습니다.

 

참고로 Int와 Int?도 서로 다른 타입입니다. null을 담으려면 객체여야 하므로, Int?는 JVM에서 박싱 타입인 java.lang.Integer로 표현됩니다.

 

2. null을 다루는 도구들

nullable 값을 다루는 방법은 크게 일곱 가지입니다. 어떤 상황에 어떤 도구를 쓰면 되는지 먼저 한눈에 보겠습니다. 각 도구는 이어지는 절에서 하나씩 살펴봅니다.

 

2-1. if 검사와 스마트 캐스트

가장 직관적인 방법입니다. null 검사를 통과한 블록 안에서 컴파일러가 타입을 String?에서 String으로 스마트 캐스트합니다.

val b: String? = "Kotlin"

if (b != null && b.length > 0) {
    println("길이는 ${b.length}")
}

 

다만 스마트 캐스트는 컴파일러가 검사와 사용 사이에 값이 바뀌지 않음을 보장할 수 있을 때만 동작합니다. 예를 들어 다른 곳에서 바뀔 수 있는 var 프로퍼티는 스마트 캐스트가 되지 않습니다.

class Profile(var nickname: String?)

fun printLength(p: Profile) {
    if (p.nickname != null) {
        println(p.nickname.length)  // 컴파일 에러: 가변 프로퍼티라 스마트 캐스트 불가
    }
}

 

이럴 때는 값을 지역 val로 한 번 꺼내 쓰면 됩니다.

fun printLength(p: Profile) {
    val nickname = p.nickname
    if (nickname != null) {
        println(nickname.length)    // OK
    }
}

 

2-2. 안전 호출 연산자 ?.

receiver가 null이면 예외를 던지는 대신 전체 표현식이 null이 됩니다. 결과 타입은 nullable이 됩니다.

val a: String? = "Kotlin"
val b: String? = null

println(a?.length)  // 6
println(b?.length)  // null   (타입은 Int?)

 

체이닝에서 특히 유용합니다.

bob?.department?.head?.name   // bob, department, head 중 하나라도 null이면 전체가 null

 

그림처럼 체인 중간에서 null을 만나면 그 뒤는 평가하지 않고, 전체 결과가 null이 됩니다.

 

2-3. 엘비스 연산자 ?:

왼쪽이 null이 아니면 왼쪽을, null이면 오른쪽을 반환합니다. 오른쪽 식은 왼쪽이 null일 때만 평가됩니다.

val b: String? = null
val l = b?.length ?: 0   // 0

 

코틀린에서 return과 throw도 식이므로 오른쪽에 쓸 수 있습니다. 조기 반환이나 인자 검증에 자주 쓰는 패턴입니다.

// repository.findUser(id)는 User?를 반환한다고 가정합니다 (없으면 null)
fun findUserName(id: Long): String {
    val user = repository.findUser(id) ?: throw IllegalArgumentException("없는 사용자: $id")
    return user.name
}

 

2-4. 널 아님 단언 연산자 !!

값이 null이 아니라고 개발자가 컴파일러에게 단언합니다. 실제로 null이면 NPE가 발생합니다.

val b: String? = null
val l = b!!.length   // NullPointerException

 

컴파일러가 증명하지 못할 뿐 null이 아님을 확신할 때 쓰는 탈출구입니다. 확신이 틀리면 결국 자바에서 보던 NPE로 돌아가므로 남용하지 않는 것이 좋습니다.

 

게다가 !!가 던지는 NPE에는 예외 메시지도 없어서, 장애가 났을 때 원인을 추적하기 어렵습니다.

 

2-5. 안전한 캐스트 as?

일반 캐스트 as는 타입이 맞지 않으면 예외를 던지지만, as?는 null을 반환합니다.

val a: Any = "Hello, Kotlin!"

val aInt: Int? = a as? Int           // null
val aString: String? = a as? String  // "Hello, Kotlin!"

 

2-6. let과 컬렉션 함수

?.let { }은 값이 null이 아닐 때만 블록을 실행합니다. 블록 안에서 it은 이미 non-null 타입입니다.

val listWithNulls: List<String?> = listOf("Kotlin", null)

for (item in listWithNulls) {
    item?.let { println(it) }   // "Kotlin"만 출력
}

 

nullable 요소가 섞인 컬렉션에서 null만 걸러낼 때는 filterNotNull()을 씁니다.

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

 

2-7. nullable 타입의 확장 함수

확장 함수는 receiver 타입을 String?처럼 nullable로 선언할 수 있습니다. 이런 확장 함수는 ?. 없이 nullable 값에 바로 호출할 수 있습니다. receiver가 null이면 함수 안에서 this도 null이 되므로, null 처리를 호출하는 쪽마다 반복하지 않고 함수 안에 한 번만 쓰면 됩니다.

fun String?.orDash(): String = if (this == null || this.isBlank()) "-" else this

val a: String? = null
println(a.orDash())     // -    (a?.orDash()가 아니라 a.orDash()로 호출합니다)
println("  ".orDash())  // -
println("hi".orDash())  // hi

 

함수 본문에서는 this == null 검사를 직접 해야 하고, 검사를 통과한 뒤에는 this가 non-null로 스마트 캐스트됩니다. 표준 라이브러리의 isNullOrBlank()와 toString()도 이런 확장 함수라서, nullable 값에 a.isNullOrBlank()처럼 바로 호출할 수 있습니다.

 

멤버 함수는 nullable 값에 바로 호출할 수 없는데 확장 함수는 가능한 이유는, 확장 함수가 정적으로 결정되기 때문입니다. 실제 인스턴스가 아니라 선언된 receiver 타입을 기준으로 컴파일 시점에 호출 대상이 정해지므로, receiver가 null이어도 호출할 수 있습니다.

 

toString()은 null에 호출해도 예외 대신 문자열 "null"을 반환합니다. ?.를 쓴 경우와 비교해 보겠습니다.

data class Person(val name: String)

val person: Person? = null

val s1: String = person.toString()    // 문자열 "null" (타입은 String)
val s2: String? = person?.toString()  // 진짜 null   (타입은 String?)

println(s1 == "null")  // true
println(s2 == null)    // true

println으로 출력하면 둘 다 null로 찍히지만, s1은 글자 네 개짜리 문자열이고 s2는 null 참조입니다.

 

3. 지연 초기화 프로퍼티: lateinit

non-null 타입 프로퍼티는 객체를 만드는 시점에 값이 있어야 합니다. 하지만 의존성 주입이나 테스트의 setup 메서드처럼 객체가 만들어진 뒤에 값이 채워지는 경우가 있습니다. 이때 프로퍼티를 String?로 선언하면 쓸 때마다 null 처리를 해야 합니다.

 

lateinit을 쓰면 non-null 타입을 유지한 채로 초기화를 나중으로 미룰 수 있습니다.

class WeatherStation {
    lateinit var latestReading: String   // non-null 타입, 초기화는 나중에

    fun printReading() {
        if (this::latestReading.isInitialized) {
            println("Latest reading: $latestReading")
        } else {
            println("No reading available")
        }
    }
}

 

 

this::latestReading.isInitialized로 초기화 여부를 확인할 수 있습니다. 이 검사는 프로퍼티가 같은 클래스나 바깥 클래스에 있을 때, 또는 같은 파일의 최상위 프로퍼티일 때만 쓸 수 있습니다.

 

초기화하기 전에 값을 읽으면 UninitializedPropertyAccessException이 발생합니다. 예외 메시지에 프로퍼티 이름이 들어 있어서 원인을 찾기 쉽습니다.

kotlin.UninitializedPropertyAccessException: lateinit property latestReading has not been initialized

 

lateinit에는 몇 가지 제약이 있습니다.

class A {
    lateinit val x: String   // 컴파일 에러: 가변 프로퍼티(var)에만 쓸 수 있습니다
    lateinit var y: String?  // 컴파일 에러: nullable 타입에는 쓸 수 없습니다
    lateinit var z: Int      // 컴파일 에러: 원시 타입에는 쓸 수 없습니다
}

이 밖에 커스텀 getter나 setter를 가질 수 없고, 클래스의 주 생성자에서는 선언할 수 없습니다. 반대로 클래스 본문의 프로퍼티뿐 아니라 최상위 프로퍼티와 지역 변수에도 쓸 수 있습니다.

 

lateinit은 null 안전성을 포기하는 것이 아니라, "값이 채워지는 순서를 개발자가 책임지겠다"고 컴파일러에게 약속하는 장치입니다. 약속을 어기면 컴파일 에러 대신 위의 예외가 발생합니다.

 

4. 타입 파라미터의 널 가능성

제네릭 함수의 타입 파라미터 T는 따로 지정하지 않으면 상한(upper bound)이 Any?입니다. 그래서 T에는 String? 같은 nullable 타입도 들어올 수 있습니다. T에 ?를 붙이지 않았더라도 T 자체가 null이 될 수 있다는 점이 핵심입니다.

fun <T> printHashCode(t: T) {
    println(t?.hashCode())   // t가 null일 수 있으므로 ?. 를 사용합니다
}

printHashCode("abc")   // 96354
printHashCode(null)    // null

 

그래서 printHashCode(null)도 컴파일됩니다. 이때 T는 nullable 타입으로 추론됩니다.

T에 non-null 타입만 받고 싶다면 상한을 Any로 지정합니다.

fun <T : Any> printHashCodeNonNull(t: T) {
    println(t.hashCode())    // t는 null이 아니므로 ?. 가 필요 없습니다
}

printHashCodeNonNull("abc")   // OK
printHashCodeNonNull(null)    // 컴파일 에러: null은 허용되지 않습니다

 

두 함수가 허용하는 인자를 비교하면 위와 같습니다. nullable 변수를 T에 넘기는 것도 상한이 Any?일 때만 가능합니다.

 

5. 자바와 만나는 지점: 플랫폼 타입

코틀린의 null 안전성은 코틀린 코드 안에서만 완전합니다. 자바 코드에서 온 값은 null 가능성을 알 수 없기 때문에, 코틀린은 이를 플랫폼 타입으로 취급합니다.

 

5-1. 플랫폼 타입이란

자바에서 선언된 타입은 코틀린에서 T!로 표기되는 플랫폼 타입이 됩니다. T!는 "T일 수도 있고 T?일 수도 있다"는 뜻입니다.

 

코드에 직접 쓸 수는 없고, 컴파일러가 타입을 추론하거나 IDE가 표시할 때만 나타납니다. 플랫폼 타입에는 null 검사가 완화되어, 자바에서와 같은 수준의 안전성만 갖습니다.

val item = list[0]       // 플랫폼 타입으로 추론 (자바 객체)
item.substring(1)        // 컴파일은 통과, item이 null이면 런타임 예외

 

5-2. 어떤 타입으로 받느냐에 따라 달라집니다

플랫폼 타입의 값은 nullable과 non-null 타입 변수 양쪽 모두에 대입할 수 있습니다.

fun platform(): String {
    val v: String = System.getProperty("user.home")   // non-null로 받기
    return v
}

fun platformNullable(): String? {
    val v: String? = System.getProperty("user.home")  // nullable로 받기
    return v
}

 

System.getProperty는 값이 없으면 null을 반환하는 자바 메서드입니다. 시스템 프로퍼티를 지운 뒤 두 함수를 호출해 보면 다음과 같이 동작합니다.

platform()          -> java.lang.NullPointerException: getProperty(...) must not be null
platformNullable()  -> null 반환 (예외 없음)

 

non-null 타입으로 받으면 컴파일러가 대입 시점에 null 검사를 넣어 줍니다. 그래서 자바에서 온 null이 코틀린의 non-null 변수로 흘러 들어가기 전에 그 자리에서 실패합니다.

 

반대로 nullable로 받으면 예외 없이 null을 처리할 수 있습니다. 자바 API가 null을 반환할 수 있다면 String?로 받는 것이 안전합니다.

세 가지 경우를 그림으로 정리하면 위와 같습니다. 마지막처럼 타입을 생략하면 플랫폼 타입이 그대로 유지되어 null 검사가 완화되므로, 의도를 드러내려면 받는 타입을 직접 적는 것이 좋습니다.

 

5-3. 반대 방향: 자바가 코틀린 함수에 null을 넘기면

자바 코드가 non-null 파라미터를 가진 코틀린 함수에 null을 넘기면, 함수 본문이 실행되기 전에 NPE가 발생합니다. 예외 메시지에 함수 이름과 파라미터 이름이 들어 있어서 원인을 바로 찾을 수 있습니다.

// NullDemo.kt
fun greet(name: String): String = "Hello, $name"
NullDemoKt.greet(null);
// java.lang.NullPointerException:
//   Parameter specified as non-null is null: method NullDemoKt.greet, parameter name

자바와 코틀린이 섞인 프로젝트에서 코틀린 쪽 경계를 지켜 주는 장치라고 이해하면 됩니다.

 

5-4. 자바 쪽에 null 어노테이션이 있다면

자바 선언에 null 가능성 어노테이션이 붙어 있으면, 플랫폼 타입이 아니라 실제 nullable/non-null 코틀린 타입으로 보입니다.

 

코틀린 컴파일러는 JetBrains(@Nullable, @NotNull), JSpecify, JSR-305, Android, FindBugs, Eclipse, Lombok(lombok.NonNull) 등 여러 종류의 null 어노테이션을 인식합니다. 이 중 JSpecify는 불일치를 기본적으로 에러로 보고하는 유일한 방식입니다.

 

자바 라이브러리가 어노테이션을 달아 두었는지에 따라 코틀린에서 받는 타입의 안전성이 달라집니다. 자주 쓰는 라이브러리의 시그니처가 T!로 보이는지 한 번쯤 확인해 볼 만합니다.

 

6. 그래도 NPE가 발생하는 경우

코틀린 공식 문서는 코틀린에서 NPE가 발생할 수 있는 원인을 다음과 같이 정리합니다.

원인 설명
명시적 throw NullPointerException() 직접 던지는 경우
!! 연산자 2-4절에서 본 널 아님 단언
초기화 중 불일치 생성자에서 this가 외부로 새는 경우, 상위 생성자가 호출한 open 멤버가 하위 클래스의 초기화되지 않은 상태를 쓰는 경우
자바 상호운용 플랫폼 타입의 null 멤버 접근, 자바 코드가 코틀린 MutableList<String>에 null을 넣는 것 같은 제네릭 문제

 

NPE는 아니지만 null 안전성과 관련된 예외가 하나 더 있습니다. 3장에서 본 lateinit 프로퍼티를 초기화 전에 읽으면 UninitializedPropertyAccessException이 발생합니다.

 

7. 실무에서의 사용 가이드

공식 문서와 직접 실행해 본 결과를 바탕으로 정리한 권장 사항입니다.

 

1. 가능하면 non-null 타입으로 설계합니다. 값이 없을 수 있다는 의미가 도메인에 정말 있을 때만 ?를 붙입니다. 그러면 null 처리 코드가 필요한 범위가 자연스럽게 줄어듭니다.

 

2. !!보다 ?: throw나 ?: return을 씁니다. !!의 NPE에는 메시지가 없지만, ?: throw에는 의도를 담은 예외 메시지를 쓸 수 있어서 장애 원인 추적이 훨씬 쉬워집니다.

// 지양
val user = repository.findUser(id)!!

// 권장
val user = repository.findUser(id)
    ?: throw NoSuchElementException("사용자를 찾을 수 없습니다: $id")

 

3. null을 반환할 수 있는 자바 API는 받는 타입을 직접 선언합니다. 5-2절처럼 non-null로 받으면 그 자리에서 즉시 실패하고, nullable로 받으면 이후 흐름에서 처리할 수 있습니다. 어느 쪽이 맞는지는 코드의 계약에 달려 있으므로, 타입 추론(T!)에 맡기지 말고 의도를 명시하는 것이 좋습니다.

 

4. ?.let { } ?: 기본값 패턴은 let 블록의 결과가 null일 수 있을 때 주의합니다. let이 null을 반환해도 엘비스 연산자가 실행되기 때문입니다.

fun findNickname(id: Long): String? = null

val name: String? = "모리"
val result = name?.let { findNickname(1L) } ?: "기본값"
// name은 null이 아니지만, let 블록이 null을 반환해서 "기본값"이 됩니다

name이 null이어서 기본값이 된 건지, 블록 결과가 null이어서 기본값이 된 건지 이 코드만으로는 구분할 수 없습니다. 분기가 의미를 가질 때는 if나 when을 쓰는 편이 안전합니다.

 

5. lateinit은 초기화 시점이 생성자 밖에 있을 때만 씁니다. 의존성 주입이나 테스트 setup처럼 값이 나중에 채워질 수밖에 없는 경우가 대상입니다. 생성자에서 값을 받을 수 있다면 val로 선언하는 편이 초기화 순서를 신경 쓰지 않아도 되어 더 안전합니다.

 

정리

  • 코틀린은 String과 String?를 서로 다른 타입으로 구분해서 null 오류를 컴파일 타임에 잡습니다.
  • 기본 도구는 ?., ?:, as?, ?.let이고, !!는 마지막 수단으로 아껴 씁니다.
  • nullable receiver를 받는 확장 함수는 ?. 없이 호출할 수 있고, null 처리를 함수 안에 모아 둘 수 있습니다.
  • lateinit은 non-null 타입을 유지한 채 초기화를 미루는 장치이고, 초기화 전에 읽으면 예외가 발생합니다.
  • 타입 파라미터 T의 기본 상한은 Any?라서 T 자체가 null일 수 있고, non-null만 받으려면 T : Any를 씁니다.
  • 자바에서 온 값은 플랫폼 타입이라 컴파일러가 null 여부를 보장하지 못합니다. 받는 타입을 직접 선언해서 의도를 드러내는 것이 좋습니다.
  • NPE는 !!, 초기화 중 불일치, 자바 상호운용에서 여전히 발생할 수 있습니다.

 

마무리

코틀린이 String과 String?를 다른 타입으로 구분해서 null 오류를 컴파일 타임으로 끌어올리는 원리부터, lateinit, 제네릭의 널 가능성, 자바와 만나는 플랫폼 타입까지 null 안전성을 한 번에 정리했습니다. (다음 편 예고는 12편 주제가 정해지면 채워 넣겠습니다.)

 

이 시리즈는 『코틀린 인 액션(Kotlin in Action)』을 참고하여 작성했습니다.

이번 편에서 다룬 내용은 아래 문서로도 검증했습니다.