Home Kotlin의 Null Safety
Post
Cancel

Kotlin의 Null Safety

개요

  • Kotlin의 타입 시스템에 내장된 Null Safety 메커니즘을 통해 NullPointerException을 컴파일 타임에 방지함
  • Safe Call(?.)과 Elvis Operator(?:)의 동작 원리 및 다른 언어들과의 비교를 학습함
  • Java 상호운용 시 주의사항과 Zero-Cost Abstraction의 의미를 이해함



Null Safety의 개념

Java의 문제점

  • Java에서는 모든 참조 타입이 기본적으로 null이 될 수 있음
  • 컴파일러가 null 가능성을 검증하지 않음
  • 개발자가 if (name != null) 체크를 잊으면 런타임 에러 발생
  • NPE는 프로그램이 실행 중일 때만 발견됨

    1
    2
    3
    4
    
    // Java
    String name = getUsername();
    System.out.println(name.toUpperCase());
    // 컴파일은 통과하지만, 런타임에 NullPointerException 발생
    

Kotlin의 해결책

  • Kotlin은 null 가능성을 타입 시스템에 통합하여 컴파일 타임에 강제함
  • String은 null이 될 수 없음 (non-nullable 타입)
  • String?은 null이 될 수 있음 (nullable 타입)
  • null 가능성은 컴파일 타임에 강제되므로 NPE가 런타임에 발생할 가능성이 사라짐

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    // Kotlin
    var name: String = getUsername()
    // Type mismatch: inferred type is String? but String was expected
    
    var name: String? = getUsername()
    println(name.toUpperCase())
    // Only safe (?.) or non-null asserted (!!.) calls are allowed
    
    println(name?.toUpperCase())
    
  • Null Safety 의사결정 흐름

Null Safety 의사결정 흐름



Null 처리 연산자

Safe Call Operator (?.)

  • null이면 null을 반환하고, 아니면 실행함
  • 체이닝 가능함

    1
    2
    3
    
    val name: String? = null
    val length = name?.length
    println(length)
    
    1
    
    val city = user?.address?.city
    
  • 동작 원리

    • name?.lengthif (name != null) name.length else null과 동일하게 컴파일됨

      1
      
      val length = if (name != null) name.length else null
      

Elvis Operator (?:)

  • null이면 기본값을 사용함

    1
    2
    3
    
    val name: String? = null
    val displayName = name ?: "Guest"
    println(displayName)
    
  • Java와 비교 시 더 간결함

    1
    2
    
    // Java
    String displayName = (name != null) ? name : "Guest";
    
    1
    2
    
    // Kotlin
    val displayName = name ?: "Guest"
    
  • 예외 던지기도 가능함

    1
    
    val age = input ?: throw IllegalArgumentException("Age required")
    

Not-Null Assertion (!!)

  • null 체크를 생략하며, null이면 NPE 발생함
  • Kotlin의 Null Safety 철학에 반함
  • 프로덕션 코드에서는 사용을 피해야 함
  • 정말 null이 아님을 확신할 때만 제한적으로 사용함

    1
    2
    
    val name: String? = getName()
    println(name!!.toUpperCase())
    

Smart Cast

  • null 체크 후 자동으로 non-nullable 타입으로 변환됨
  • 명시적 캐스팅이 불필요함

    1
    2
    3
    4
    5
    
    fun processUser(user: User?) {
        if (user != null) {
            println(user.name)
        }
    }
    
    1
    2
    3
    4
    5
    6
    
    fun getUserType(user: User?): String {
        return when (user) {
            null -> "No user"
            else -> user.name
        }
    }
    
  • Smart Cast가 작동하지 않는 경우

    • var 프로퍼티는 다른 스레드에서 변경 가능하므로 작동하지 않음

      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      class Container {
          var user: User? = null
      }
      
      fun process(container: Container) {
          if (container.user != null) {
              println(container.user.name)
          }
      }
      
    • 해결 방법

      • 지역 변수에 할당
      1
      2
      3
      4
      5
      6
      
      fun process(container: Container) {
          val user = container.user
          if (user != null) {
              println(user.name)
          }
      }
      



Kotlin에서 NPE가 발생하는 경우

  • Kotlin은 NPE 제로를 보장하지 않으며 다음 경우에만 발생함

명시적으로 던질 때

1
throw NullPointerException()

!! 연산자 사용

1
2
val name: String? = null
println(name!!.length)

초기화 실패

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
open class Parent {
    open val name: String = "Parent"

    init {
        println(printName())
    }

    open fun printName() = name
}

class Child : Parent() {
    override val name: String = "Child"
}

val child = Child()
  • Parent의 init 블록이 먼저 실행됨
  • 이 시점에 Child의 name은 아직 초기화되지 않음
  • printName() 호출 시 초기화되지 않은 name에 접근하여 NPE 발생 가능

Java 상호운용

  • Platform Types (타입!)

    • Kotlin에서 Java 코드를 호출하면 Platform Types가 등장함
    • !는 nullable인지 non-nullable인지 모름을 의미함

      1
      
      val user = javaApi.getUser()
      
  • 위험한 방법과 안전한 방법

    1
    2
    3
    4
    5
    
    // 위험한 방법
    val name: String = user.getName()
    
    // 안전한 방법
    val name: String? = user.getName()
    
  • Java API를 래핑하는 Kotlin 레이어 생성 권장

    1
    2
    3
    4
    5
    
    class UserRepository(private val javaApi: JavaUserApi) {
        fun getUser(id: Long): User? {
            return javaApi.getUser(id)
        }
    }
    



다른 언어와의 비교

Java vs Kotlin

측면 Java Kotlin
기본 Null 허용 모든 참조 타입 nullable 명시적으로 ? 표시 필요
Null 체크 시점 런타임 (NPE 발생 시) 컴파일 타임 (코드 작성 시)
안전한 접근 if (x != null) 또는 Optional<T> ?. (safe call)
기본값 제공 삼항 연산자 또는 Optional.orElse() ?: (elvis)
런타임 오버헤드 Optional은 wrapper 객체 생성 0 (컴파일 타임만)
  • Java Optional의 문제점

    • Optional은 wrapper 객체를 생성하여 힙 객체 생성 발생
    • GC 압력 증가
    • 접근 시 indirection 비용 발생

      1
      2
      3
      
      // Java
      Optional<String> name = Optional.of("John");
      String result = name.map(String::toUpperCase).orElse("GUEST");
      

Scala의 Option[T]

  • 함수형 프로그래밍 접근 방식 사용

    1
    2
    
    val name: Option[String] = Some("John")
    val length = name.map(_.length).getOrElse(0)
    
  • 장점
    • 더 일반적이고 composable함 (flatMap, for-comprehension 등)
  • 단점
    • Some/None 래퍼 객체가 힙에 할당되어 메모리 오버헤드 발생
  • Scala 3의 개선
    • Union Types (String | Null)를 도입해 Kotlin과 유사한 제로-코스트 달성

Swift

  • Kotlin과 유사한 접근 방식 사용

    1
    2
    
    var name: String? = nil
    print(name?.uppercased() ?? "Guest")
    
  • 차이점

    • Swift의 Optional은 enum으로 구현되어 힙 할당 없이 효율적임

C# 8+ (Nullable Reference Types)

  • C# 8부터 Kotlin과 유사한 null safety 도입

    1
    2
    
    string? name = null;
    string name2 = "Hi";
    
  • 차이점

    • 기존 코드와의 호환성 때문에 선택적(opt-in)임
    • 컴파일 에러가 아닌 경고만 발생함



Zero-Cost Abstraction

개념

  • Kotlin의 nullable 타입은 런타임 오버헤드가 전혀 없음
  • JVM 바이트코드에서 일반 String과 완전히 동일함

    1
    
    var name: String? = "John"
    
  • 컴파일 결과

    1
    2
    3
    4
    5
    
    // Kotlin 코드
    val length = name?.length
    
    // 컴파일된 바이트코드
    val length = if (name != null) name.length else null
    

성능 비교

특징 Java Optional Kotlin Nullable
Wrapper 객체 생성함 (힙 할당) 생성 안 함
메모리 오버헤드 있음 없음
GC 압력 증가 변화 없음
접근 비용 Indirection 비용 직접 접근
성능 느림 일반 null 체크와 동일
  • Kotlin의 nullable 타입은 컴파일러 매직으로 구현됨
  • 실제로는 일반 null 체크 코드로 변환됨
  • 타입 안전성을 얻으면서도 성능 손실이 전혀 없음



Nullable Receiver

  • nullable 타입에도 확장 함수를 정의할 수 있음
  • null에 대해서도 확장 함수 호출 가능

    1
    2
    3
    4
    5
    6
    
    fun String?.isNullOrEmpty(): Boolean {
        return this == null || this.isEmpty()
    }
    
    val x: String? = null
    println(x.isNullOrEmpty())
    
  • Nullable Receiver의 특징

    • null에 대해서도 확장 함수 호출 가능
    • 함수 내부에서 this는 nullable 타입임
    • this에 대한 null 체크는 개발자가 직접 수행해야 함
    • 일반 확장 함수는 non-null receiver만 허용

      1
      2
      3
      
      fun String.nonNullLength(): Int = this.length
      
      fun String?.nullableLength(): Int = this?.length ?: 0
      
  • 활용 예시

    1
    2
    3
    4
    5
    6
    
    fun String?.orDefault(default: String): String {
        return this ?: default
    }
    
    val name: String? = null
    println(name.orDefault("Guest"))
    
  • 표준 라이브러리 함수들은 모두 nullable receiver를 가짐

    1
    2
    3
    
    fun String?.isNullOrBlank(): Boolean
    fun <T> List<T>?.isNullOrEmpty(): Boolean
    fun CharSequence?.ifEmpty(defaultValue: () -> CharSequence): CharSequence
    



let 함수와 Safe Call

  • null이 아닐 때만 실행함

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    val name: String? = getName()
    
    // 전통적인 방법
    if (name != null) {
        println(name.length)
        saveToDatabase(name)
    }
    
    // Kotlin 스타일
    name?.let {
        println(it.length)
        saveToDatabase(it)
    }
    
  • 반환값 활용

    1
    2
    3
    4
    5
    
    val result = name?.let {
        println(it.length)
        saveToDatabase(it)
        "Success"
    } ?: "Failed"
    
  • 체이닝 예시

    1
    2
    3
    
    user?.address?.city?.let { city ->
        println("User lives in $city")
    }
    
  • also, apply와의 비교

    • let은 마지막 표현식을 반환
    • also는 원본 객체를 반환하여 체이닝에 유용
    • apply는 this를 사용하며 원본 객체를 반환

      1
      2
      3
      4
      5
      6
      7
      
      name?.also {
          println(it.length)
      }
      
      name?.apply {
          println(length)
      }
      



검증 함수

require와 check

  • require는 파라미터 검증에 사용하며 IllegalArgumentException을 던짐

    1
    2
    3
    4
    
    fun process(name: String?) {
        require(name != null) { "Name must not be null" }
        println(name.length)
    }
    
  • check는 상태 검증에 사용하며 IllegalStateException을 던짐

    1
    2
    3
    4
    
    fun internalProcess(data: String?) {
        check(data != null) { "Data must be initialized" }
        println(data.length)
    }
    

requireNotNull과 checkNotNull

  • 직접 non-null 값을 반환함

    1
    2
    3
    4
    
    fun ensureNotNull(value: String?) {
        val nonNull: String = requireNotNull(value) { "Value is null" }
        println(nonNull.length)
    }
    
    1
    2
    3
    4
    
    fun ensureState(data: String?) {
        val nonNull: String = checkNotNull(data) { "Data must be initialized" }
        println(nonNull.length)
    }
    



권장 사용 패턴

nullable보다 non-nullable을 선호

1
2
3
4
5
6
7
8
9
// 나쁜 예
class User {
    var name: String? = null
}

// 좋은 예
class User(
    val name: String
)

Elvis Operator로 기본값 제공

1
2
3
4
5
// 나쁜 예
val displayName = if (user.name != null) user.name else "Guest"

// 좋은 예
val displayName = user.name ?: "Guest"

검증 함수 활용

1
2
3
4
5
6
7
8
9
10
11
12
13
// 나쁜 예
fun process(name: String?) {
    if (name == null) {
        throw IllegalArgumentException("Name required")
    }
    println(name.length)
}

// 좋은 예
fun process(name: String?) {
    require(name != null) { "Name required" }
    println(name.length)
}

!! 사용 금지

1
2
3
4
5
// 절대 금지
val length = name!!.length

// 안전한 방법
val length = name?.length ?: 0
  • !!를 사용해도 되는 유일한 경우

    ```kotlin private lateinit var repository: UserRepository

    fun init() { repository = UserRepository() repository.findAll() }

    Nullable Collection 처리

1
2
3
4
5
6
7
8
9
10
11
12
val names: List<String>? = getNames()

// 복잡함
if (names != null && names.isNotEmpty()) {
    names.forEach { println(it) }
}

// 간결함
names?.forEach { println(it) }

// 또는 빈 리스트 반환하는 것을 선호
fun getNames(): List<String> = fetchNames() ?: emptyList()



정리

주요 원칙

  • 타입 시스템에 null 가능성 내장
    • String vs String?로 명확히 구분
    • 컴파일 타임에 강제되어 런타임 NPE 방지
  • 세 가지 연산자
    • ?. (Safe Call)
      • null-safe 접근
    • ?: (Elvis)
      • 기본값 제공
    • !! (Not-Null Assertion)
      • 사용 금지 권장
  • Smart Cast
    • null 체크 후 자동으로 non-nullable 타입으로 변환
    • var 프로퍼티는 지역 변수에 할당 후 사용
  • 검증 함수
    • require
      • 파라미터 검증 (IllegalArgumentException)
    • check
      • 상태 검증 (IllegalStateException)
    • requireNotNull/checkNotNull
      • non-null 값 반환
    • 검증 후 자동으로 non-nullable 타입으로 스마트 캐스트됨
  • Zero-Cost Abstraction
    • 런타임 오버헤드 없음
    • Java Optional보다 성능 우수
    • 일반 null 체크와 동일한 바이트코드 생성
  • Java 상호운용 주의
    • Platform Types (타입!) 조심
    • Java API는 Kotlin 레이어로 래핑
    • nullable 여부를 명시적으로 선언
    • Public 함수는 null 체크 유지, Private/Internal 함수는 최적화 가능

다른 언어와의 비교

  • Java
    • 런타임 체크만 가능
    • Optional은 오버헤드 있음
  • Scala
    • 함수형이지만 Some/None 할당 비용 발생
  • Swift
    • enum 기반 Optional로 효율적
  • C#
    • 선택적(opt-in)이며 경고만 발생
Contents