Skip to content
ioob.dev
Go back

Kotlin 6편 — Null 안전성 심화

· 5분 읽기

Table of contents

다시 Null 이야기

1편에서 ?.?:를 배웠다. 그걸로 충분한 경우도 많지만, 실무 코드를 작성하다 보면 더 세밀한 도구가 필요해진다. API 응답이 null인 경우, 프레임워크가 나중에 주입해주는 필드, 딱 한 번만 계산하면 되는 무거운 값 — 이런 상황에서 ?.만으로는 코드가 지저분해진다.

이번 편에서는 Kotlin이 null을 다루기 위해 제공하는 나머지 무기들을 살펴본다.

스마트 캐스트

Kotlin 컴파일러는 꽤 똑똑하다. if로 null 체크를 했으면 그 블록 안에서는 자동으로 non-null 타입으로 취급해준다.

fun printLength(text: String?) {
    if (text != null) {
        // 여기서 text는 자동으로 String 취급 (String? 아님)
        println("길이: ${text.length}")
    }
}

fun main() {
    printLength("Kotlin")  // 길이: 6
    printLength(null)       // 아무것도 출력 안 됨
}

text != null 체크를 한 뒤에는 text?.length가 아니라 text.length를 바로 쓸 수 있다. 컴파일러가 흐름을 분석해서 이 시점에서 null이 아님을 보장하기 때문이다. 이걸 스마트 캐스트라고 부른다.

is 타입 체크에서도 같은 원리가 적용된다.

fun describe(obj: Any): String {
    return when (obj) {
        is String -> "문자열, 길이 ${obj.length}"  // String으로 자동 캐스트
        is Int    -> "정수, 절댓값 ${Math.abs(obj)}"
        is List<*> -> "리스트, 크기 ${obj.size}"
        else      -> "알 수 없는 타입"
    }
}

fun main() {
    println(describe("hello"))       // 문자열, 길이 5
    println(describe(-42))           // 정수, 절댓값 42
    println(describe(listOf(1, 2)))  // 리스트, 크기 2
}

when에서 is String으로 체크한 분기 안에서는 objString처럼 쓸 수 있다. Java의 instanceof 뒤에 캐스팅을 또 해야 하는 것과 비교하면 훨씬 깔끔하다.

단, 스마트 캐스트가 동작하지 않는 경우가 있다. var로 선언된 프로퍼티는 다른 스레드에서 값을 바꿀 수 있으므로 컴파일러가 보장을 해줄 수 없다. 이럴 때는 지역 변수에 먼저 담아두는 패턴을 쓴다.

class Holder(var value: String?)

fun printValue(holder: Holder) {
    val v = holder.value  // 지역 변수에 복사
    if (v != null) {
        println(v.length)  // 스마트 캐스트 가능
    }
}

as와 as?

명시적으로 타입을 변환해야 할 때는 as를 쓴다. 하지만 캐스팅이 실패하면 ClassCastException이 터진다.

fun main() {
    val obj: Any = "Hello"

    val str: String = obj as String  // 성공
    println(str.uppercase())         // HELLO

    // val num: Int = obj as Int     // ClassCastException!
}

안전하게 캐스팅하고 싶다면 as?를 쓰면 된다. 실패 시 예외 대신 null을 반환한다.

fun safeCast(obj: Any): Int {
    val number: Int? = obj as? Int
    return number ?: -1
}

fun main() {
    println(safeCast(42))       // 42
    println(safeCast("hello"))  // -1
}

as??:와 조합하면 깔끔한 방어 코드를 작성할 수 있어서 실무에서 자주 보이는 패턴이다.

!! (non-null assertion)

!!은 “이 값이 null이 아님을 내가 보장한다”는 선언이다. null이면 NullPointerException이 발생한다.

fun main() {
    var name: String? = "Kotlin"
    println(name!!.length)  // 6

    name = null
    // println(name!!.length)  // NullPointerException!
}

가능하면 사용을 피해야 한다. !!을 쓰는 순간 Kotlin의 null 안전성 체계를 우회하는 셈이니까. 그래도 쓸 수밖에 없는 상황이 있긴 하다. 테스트 코드에서 “여기서 null이면 테스트 자체가 실패해야 하는” 경우, 또는 프레임워크가 반드시 값을 채워주지만 컴파일러가 그걸 모르는 경우가 대표적이다.

코드 리뷰에서 !!이 보이면 “왜 여기서 null이 아니라고 확신하는가?”를 꼭 따져봐야 한다.

lateinit

lateinit은 “지금은 값이 없지만 나중에 반드시 초기화하겠다”는 약속이다. 주로 의존성 주입이나 테스트 셋업에서 쓴다.

class UserService {
    lateinit var repository: UserRepository

    fun findUser(id: Long): User {
        return repository.findById(id)
    }
}

interface UserRepository {
    fun findById(id: Long): User
}

data class User(val id: Long, val name: String)

Spring이나 Android처럼 프레임워크가 객체 생성 후 필드를 주입하는 구조에서 빛을 발한다. 생성자에서 바로 넣을 수 없는 상황이기 때문이다.

몇 가지 제약이 존재한다.

class Example {
    // lateinit var count: Int      // 원시 타입 불가
    // lateinit val name: String    // val 불가
    lateinit var data: String       // var + 참조 타입만 가능
}

fun main() {
    val example = Example()

    // 초기화 여부 확인
    if (::example.isInitialized) {
        println(example.data)
    }

    // 초기화 전에 접근하면?
    // println(example.data)  // UninitializedPropertyAccessException
}

lateinitvar에만 쓸 수 있고, Int, Boolean 같은 원시 타입에는 사용할 수 없다. 초기화 전에 접근하면 UninitializedPropertyAccessException이 발생하는데, !!처럼 NPE가 나는 것보다는 디버깅하기 훨씬 수월하다.

lazy

lazylateinit과 목적이 다르다. “처음 접근할 때 한 번만 계산하고, 그 뒤로는 캐시된 값을 쓴다”는 개념이다.

class ConfigLoader {
    val config: Map<String, String> by lazy {
        println("설정 파일 로딩 중...")
        // 무거운 초기화 작업
        mapOf(
            "db.url" to "jdbc:mysql://localhost:3306/app",
            "db.user" to "admin"
        )
    }
}

fun main() {
    val loader = ConfigLoader()
    println("ConfigLoader 생성 완료")
    println(loader.config["db.url"])  // 이 시점에 "설정 파일 로딩 중..." 출력
    println(loader.config["db.user"]) // 캐시된 값 사용, 다시 로딩하지 않음
}

출력 결과는 이렇다.

ConfigLoader 생성 완료
설정 파일 로딩 중...
jdbc:mysql://localhost:3306/app
admin

lazyval에만 쓸 수 있고, 기본적으로 스레드 안전하다. 무거운 리소스 초기화를 필요한 시점까지 미루고 싶을 때 적합하다.

lateinitlazy의 차이를 정리하면 이렇다.

특성lateinitlazy
키워드varval
초기화 시점외부에서 직접첫 접근 시 자동
원시 타입불가가능
스레드 안전보장 안 됨기본 보장
주 용도DI, 테스트 셋업무거운 초기화 지연

let과 run 활용

5편에서 scope function을 간단히 다뤘는데, null 처리 맥락에서 좀 더 깊이 살펴보자.

let — null 안전 체이닝

?.let은 null이 아닐 때만 블록을 실행하는 관용 패턴이다. 여러 단계의 변환이 필요할 때 특히 유용하다.

data class Address(val city: String, val zipCode: String?)
data class Company(val name: String, val address: Address?)
data class Employee(val name: String, val company: Company?)

fun getEmployeeZipCode(employee: Employee?): String {
    return employee
        ?.company
        ?.address
        ?.zipCode
        ?.let { code ->
            "우편번호: $code"
        } ?: "우편번호 없음"
}

fun main() {
    val emp = Employee("Kim", Company("Acme", Address("Seoul", "06234")))
    println(getEmployeeZipCode(emp))   // 우편번호: 06234
    println(getEmployeeZipCode(null))  // 우편번호 없음
}

안전 호출 ?.을 체이닝하다가 마지막에 let으로 변환 작업을 수행하고, ?:로 기본값을 지정하는 구조다. if-else를 중첩하는 것보다 의도가 명확하게 드러난다.

run — null 체크 + 복잡한 로직

run은 여러 줄의 로직을 하나의 블록으로 묶어야 할 때 유용하다.

data class Config(
    val host: String?,
    val port: Int?,
    val database: String?
)

fun buildConnectionString(config: Config?): String {
    return config?.run {
        val h = host ?: "localhost"
        val p = port ?: 3306
        val d = database ?: "default"
        "jdbc:mysql://$h:$p/$d"
    } ?: "jdbc:mysql://localhost:3306/default"
}

fun main() {
    val config = Config("prod-server", 5432, "myapp")
    println(buildConnectionString(config))
    // jdbc:mysql://prod-server:5432/myapp

    println(buildConnectionString(null))
    // jdbc:mysql://localhost:3306/default
}

run 블록 안에서는 thisConfig 객체이므로 프로퍼티를 바로 접근할 수 있다. 여러 필드를 동시에 다루면서 하나의 결과를 만들어낼 때 let보다 가독성이 좋다.


1편에서 시작한 null 이야기가 여기서 완결된다. ?.?:라는 기본 도구에 스마트 캐스트, as?, lateinit, lazy, 그리고 scope function을 조합하면 null 때문에 코드가 지저분해질 일은 거의 없다.

다음 편에서는 제네릭을 다룬다. 타입 파라미터부터 공변성까지, 타입 시스템의 깊은 곳으로 들어가 보자.

7편: 제네릭


Share this post on:

Comments

Loading comments...


Previous Post
Kotlin 7편 — 제네릭
Next Post
Go 입문 12편 — 제네릭과 실전 패턴