Kotlin의 Null Safety: ?, !!, let 및 Elvis Operator

1. 개요

NullPointerException(NPE)은 모든 Java 개발자의 악몽입니다. Kotlin은 type system에 내장된 null safety 시스템으로 이 문제를 compile-time에 해결합니다. 이 글은 nullable type을 선언하는 ? 기호, safe call ?., elvis operator ?:, 그리고 let 함수 이렇게 네 가지 핵심 도구를 명확하게 이해하도록 도와줍니다. 이 글을 읽고 나면 각 도구를 언제 써야 하는지 알 수 있고, !!를 남발한 후에도 NPE를 만나는 전형적인 함정을 피할 수 있을 겁니다.

2. Nullable Type과 ? 기호

Kotlin에서는 기본적으로 모든 type이 non-null입니다. 변수가 null을 가질 수 있게 하려면, type 뒤에 ? 기호로 명시해야 합니다.

class Customer(val name: String, val email: String?)

fun main() {
    val customer = Customer("An", null)
    println(customer.email) // null, email이 String?이므로 오류 없음
}
  • name: String — 절대 null이 될 수 없으며, 컴파일러가 이를 보장합니다.
  • email: String? — null일 수 있으며, 컴파일러는 이를 사용하는 모든 곳에서 처리를 강제합니다.

email에 대해 직접 method를 호출하려고 하면 customer.email.length처럼 작성했을 때 컴파일러가 바로 에러를 보고합니다:

error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?

이것이 가장 큰 장점입니다: 에러가 compile-time에 잡히므로 production에서 실행될 때가 아닙니다.

3. Safe Call ?.

?.는 receiver가 null이 아닐 때만 method나 property에 접근합니다. receiver가 null이면, 전체 식이 null을 반환하고 exception이 발생하지 않습니다.

class Order(val id: Long, val customer: Customer?)

fun printCustomerName(order: Order) {
    val length = order.customer?.name?.length
    println("이름의 길이: $length")
}
  • order.customer?.name — customer가 null이면, 전체 식이 short-circuit되어 null을 반환하고 .length까지 실행되지 않습니다.
  • 결과 length의 type은 Int?입니다. ?. 체인의 모든 단계가 null을 반환할 수 있기 때문입니다.
이름의 길이: null   // customer == null일 때
이름의 길이: 2       // customer.name = "An"일 때

주의: ?. 체인이 3-4단계를 초과하면 보통 domain model 설계가 좋지 않다는 신호입니다. 함수를 분리하거나 nullable을 남발하는 대신 sealed class 사용을 검토해봅시다.

3.1. Safe Call과 함수 호출 결합

?.는 property뿐만 아니라 함수 호출도 안전하게 처리합니다:

fun notifyCustomer(order: Order) {
    order.customer?.let { sendEmail(it) }
}

fun sendEmail(customer: Customer) {
    println("${customer.email}로 이메일을 보냅니다")
}

order.customer?.let { ... } 줄은 customer가 null이 아닐 때만 let 블록을 실행합니다. 이것은 섹션 5의 let과 직접 관련이 있습니다.

4. Elvis Operator ?:

Elvis operator는 왼쪽 식이 null일 때 기본값을 제공합니다. 이름은 ?:가 옆에서 봤을 때 Elvis Presley의 머리 모양처럼 보인다고 해서 붙었습니다.

fun getDisplayEmail(customer: Customer): String {
    return customer.email ?: "이메일이 제공되지 않았습니다"
}
  • customer.email != null이면, 그 값을 반환합니다.
  • null이면, fallback 문자열 "이메일이 제공되지 않았습니다"를 반환합니다.

Elvis operator는 return이나 throw와 조합했을 때 더욱 강력해져서, early-return을 간결하게 작성할 수 있습니다:

fun validateOrder(order: Order): Customer {
    val customer = order.customer ?: throw IllegalStateException("주문 ${order.id}에 고객이 없습니다")
    return customer
}

?: throw IllegalStateException(...)을 통해 customer는 이제 non-null Customer type으로 컴파일러가 추론합니다. 이 방식은 nullable을 함수 초반에 “unwrap”하는 매우 흔한 기법입니다.

4.1. 안전한 기본값과의 Elvis Operator

fun calculateDiscount(order: Order): Double {
    val tier = order.customer?.name?.length ?: 0
    return if (tier > 5) 0.1 else 0.0
}

여기서 safe call 체인과 elvis가 결합됩니다. 어떤 단계든 null이 나오면, 최종 결과는 fallback 0입니다.

5. Null이 아닐 때만 처리하는 let 함수

let은 scope function입니다: receiver를 받아서 블록을 실행하고, 블록의 결과를 반환합니다. ?.와 조합하면, 값이 null이 아닐 때만 코드를 실행하는 전형적인 패턴이 됩니다.

fun processPayment(amount: Double?) {
    amount?.let {
        println("결제 처리 중: $it")
        chargeCard(it)
    }
}

fun chargeCard(value: Double) {
    println("카드에서 $value를 차감했습니다")
}
  • amount?.let { ... } — amount != null일 때만 블록 안의 코드가 실행됩니다.
  • 블록 내에서 it의 type은 Double(non-null)이지 Double?가 아니므로, chargeCard(it)를 망설임 없이 호출할 수 있고, !!나 추가 체크가 필요 없습니다.

if (amount != null) { ... }로 쓰는 것과 비교하면, let은 임시 변수의 scope을 제한해서 변수 누수를 방지하고, 코드를 “pipeline” 스타일로 읽기 더 좋게 만듭니다.

5.1. let과 Elvis를 조합해 값 반환하기

fun formatAmount(amount: Double?): String {
    return amount?.let { "금액: %.2f".format(it) } ?: "금액이 없습니다"
}

이 줄은 자연스럽게 읽힙니다: “만약 amount가 있으면 포맷하고, 없으면 fallback을 반환한다”. 이것은 실제 Kotlin 코드에서 특히 DTO를 domain model로 변환할 때 매우 자주 보이는 idiom입니다.

6. Non-null Assertion !! — 언제 쓸까?

!!는 컴파일러에게 nullable type을 non-null로 강제합니다. 만약 실제 값이 null이면, 즉시 NullPointerException을 throw합니다.

fun getCustomerNameUnsafe(order: Order): String {
    return order.customer!!.name
}
  • order.customer!! — customer가 null이면, 이 줄이 NPE를 throw하고 스택 추적은 꽤 명확합니다:
Exception in thread "main" java.lang.NullPointerException
    at MainKt.getCustomerNameUnsafe(Main.kt:2)

주의: !!은 본질적으로 Java의 NPE를 Kotlin으로 되돌려 놓는 것입니다. 절대 확신하는 경우에만 사용하세요(예: 이전 단계에서 validation 후). 가능하면 왜 안전한지 주석으로 설명하세요.

!!를 사용하는 적절한 경우:

  • 구 Java 코드와의 interop에서, API가 @Nullable 없이 object를 반환하지만 business logic이 null을 보장할 때.
  • Test 코드에서, setup이 잘못되면 빨리 실패하고 싶을 때.

!!를 피해야 할 경우:

  • 사용자 입력, HTTP request, 데이터베이스에서 나온 데이터 처리 — 예상 밖으로 null이 될 수 있습니다.
  • validation logic 대신 사용하기 — 대신 requireNotNull()이나 elvis + 명확한 exception을 써야 합니다.
fun getCustomerName(order: Order): String {
    val customer = requireNotNull(order.customer) { "주문 ${order.id}에는 고객이 있어야 합니다" }
    return customer.name
}

requireNotNull은 nullable을 unwrap하면서 동시에 !!보다 훨씬 더 명확한 에러 메시지를 제공하므로, bare !!보다 선호되어야 합니다.

7. 네 가지 도구 빠른 비교

도구 언제 쓸까 null을 만났을 때의 동작
? null을 가질 수 있는 type 선언 N/A, 단지 type annotation
?. 안전한 접근, null 여부가 불명확할 때 null 반환, exception 없음
?: fallback 값이 필요하거나 early-return할 때 오른쪽 식을 실행
let null이 아닐 때만 로직을 실행할 때 블록 실행 안 함
!! 100% 확신할 때 NullPointerException throw

8. Null 처리 함수의 test 작성

null 처리 로직이 제대로 작동하려면, 값이 있는 경우와 null인 경우 두 가지 모두를 test해야 합니다.

import org.junit.jupiter.api.Test
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy

class OrderServiceTest {

    @Test
    fun `getDisplayEmail은 email이 null일 때 fallback 반환`() {
        val customer = Customer(name = "An", email = null)
        val result = getDisplayEmail(customer)
        assertThat(result).isEqualTo("이메일이 제공되지 않았습니다")
    }

    @Test
    fun `getDisplayEmail은 email이 있을 때 email 반환`() {
        val customer = Customer(name = "An", email = "an@example.com")
        val result = getDisplayEmail(customer)
        assertThat(result).isEqualTo("an@example.com")
    }

    @Test
    fun `validateOrder는 customer가 null일 때 exception throw`() {
        val order = Order(id = 1L, customer = null)
        assertThatThrownBy { validateOrder(order) }
            .isInstanceOf(IllegalStateException::class.java)
            .hasMessageContaining("고객이 없습니다")
    }
}
  • 첫 번째 test는 elvis가 email == null일 때 fallback을 제대로 반환하는지 확인합니다.
  • 두 번째 test는 반대 케이스 — 실제 값이 손실되지 않는지 확인합니다.
  • 세 번째 test는 ?: throw가 올바른 exception과 메시지를 throw하는지 확인합니다. AssertJ의 assertThatThrownBy를 사용합니다.

Test에 필요한 의존성 (Maven):

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>

9. 흔한 실수들

!!를 난발해서 “컴파일러를 침묵하게” 하기. 컴파일러가 nullable 에러를 보고할 때 반사적으로 !!를 붙이면 멈춰서 다시 생각해보세요 — 그건 아직 데이터 흐름을 제대로 이해 못했다는 신호입니다. 자문해보세요: 이 값이 정말 null일 수 있나요? 있다면 ?:나 let으로 처리하세요.

?.let을 무작정 if-else 대신 쓰기. 로직이 복잡하고 여러 분기가 있다면, if (x != null) { } else { }이 때론 let을 겹겹이 쌓는 것보다 더 읽기 쉽습니다. 무리해서 모든 걸 elvis/let으로 강제하면 코드가 더 어려워질 수 있습니다.

Java에서 온 platform type이 null-safe하지 않다는 것을 잊기. Java 메서드가 @Nullable/@NonNull annotation 없이 object를 반환하면, Kotlin은 그걸 platform type(String!)으로 간주하고, 컴파일러가 null 체크를 강제하지 않습니다. Runtime에 NPE가 여전히 발생할 수 있으니까 — Java interop할 때 직접 ?.를 붙이거나 manual check를 해야 합니다.

// Java: public String getLegacyName() { return null; } // annotation 없음
val name = legacyService.getLegacyName() // type 추론: String!, Kotlin이 경고하지 않음
println(name.length) // runtime에 NPE 가능, name이 null이면

10. 결론

Kotlin의 null safety는 NPE를 완전히 없애는 “마법”이 아니라, 에러를 runtime 대신 compile-time에 잡는 도구입니다. ?는 null 가능성을 선언하고, ?.는 안전하게 접근하며, ?:는 fallback이나 early-return을 제공하고, let은 null이 아닐 때의 로직을 scope 제한 하에 실행합니다. !!는 마지막 수단이고, null을 throw할 준비가 되어 있어야 할 때만 사용합니다. 이 네 가지 도구를 제대로 습득하면, Java의 구식 null-check 습관보다 훨씬 안전한 Kotlin 코드를 작성할 수 있을 겁니다.

kotlinnull-safetybest-practicesjava-interop