본문 바로가기

개발

[Kotlin] Null을 대하는 자세: 꼬리표(Nullable)와 세계관의 분리

코틀린을 사용하는 가장 큰 이유 중 하나는 바로 Null Safety입니다. 자바 개발자들을 수십 년간 괴롭혀온 NullPointerException

(NPE)이라는 "10억 불짜리 실수"를 해결하기 위해, 코틀린은 과연 어떤 방식을 택했을까요?

 

코틀린의 철학: "세계관을 둘로 쪼개다"

코틀린의 타입 시스템은 근본적으로 두 개의 세상으로 나뉘어 있습니다.

  1. Null을 담을 수 있는 세상 (Nullable)
  2. Null을 절대 담을 수 없는 세상 (Non-Nullable)

이 두 세상은 상속 계층 구조(Hierarchy)의 최상위부터 다릅니다.

  • Any: Null이 아닌 모든 타입(String, Int, User 등)의 최상위 부모입니다. 여기엔 죽어도 null이 들어갈 수 없습니다.
  • Any?: Null을 포함한 모든 타입의 진짜 최상위 부모입니다.

관계상으로 String은 String?의 하위 타입(Subtype)입니다. 하위 타입은 상위 타입에 대입할 수 있지만, 반대는 성립하지 않죠. 이 간단한 원리가 컴파일 단계에서 Null 안정성을 보장합니다.

 

[주의사항] 자바와의 아슬아슬한 동거: 플랫폼 타입 (Platform Types)

자바 코드에서 가져온 객체는 코틀린에서 플랫폼 타입(String!, User!)이라 불리는 특별한 타입으로 취급됩니다.

  • 문제점: 코틀린 컴파일러는 자바 코드를 보고 이게 Nullable인지 Non-null인지 알 수 없습니다.
  • 동작 방식: "개발자가 알아서 판단해라"라며 모든 권한을 위임합니다.
  • 위험성: Non-null로 가정하고 썼는데 실제 자바 코드에서 null이 넘어오면, 런타임에 NPE가 터집니다.

💡 왜 이렇게 만들었을까? 만약 자바의 모든 타입을 강제로 Nullable(ArrayList<String?>?)로 취급했다면, 자바 라이브러리를 쓸 때마다 불필요한 null 체크를 수없이 해야 했을 것입니다. 코틀린은 실용성을 위해 "엄격함" 대신 "개발자의 책임"을 선택했습니다.

 

[주의사항] 제네릭에서 Null 막기: Upper Bound

제네릭 <T>를 쓰면 기본적으로 T는 Any?로 추론되어 null이 들어올 수 있습니다. 확정적으로 Non-null만 받고 싶다면 상한(Upper Bound)을 지정해야 합니다.

 

Kotlin

// T는 Any?로 추론됨 (null 가능)
fun <T> printHashCode(t: T) { ... } 

// T는 Any(Non-null)로 추론됨 (null 불가능)
fun <T: Any> printHashCode(t: T) { 
    println(t.hashCode())
}

 

코틀린에서 Nullable 타입을 효과적으로 다루기 위한 여러 기능을 제공합니다. 해당 기능들은 이글에서 다루지 않으니, 아래 docs를 참고해주세요.

 

테스트코드 국룰: lateinit

NullSafety Docs에 포함되어 있지는 않지만 관련이 있는 lateinit입니다. 테스트 코드나 DI(의존성 주입) 프레임워크를 사용할 때, 변수 선언 시점에 초기화하기 어려운 경우가 많습니다. 이때 lateinit var를 사용합니다.

public class OrderServiceTest {
    lateinit var orderService: OrderService

    @SetUp fun setup() {
        orderService = OrderService()
    }

    @Test fun processesOrderSuccessfully() {
        // Calls orderService directly without checking for null
        // or initialization
        orderService.processOrder()
    }
}
  • 핵심: Nullable(?)로 선언하면 매번 !!나 ?.를 써야 하는 번거로움을 없애줍니다.
  • 주의: 초기화하지 않고 접근하면 UninitializedPropertyAccessException이 발생하여, 어디서 초기화가 누락되었는지 명확히 알 수 있습니다.

 

Java Optional<T> vs Kotlin Nullable Type(T?)

두 방식 모두 "값이 없을 수 있음"을 표현하지만, 내부 동작은 완전히 다릅니다.

  • Java Optional (박스 모델): 값을 Optional이라는 객체(Box)로 감쌉니다.
    • 비용: 래퍼 객체를 힙 메모리에 생성해야 하므로 오버헤드가 발생합니다.
  • Kotlin Nullable (꼬리표 모델): 별도의 객체를 생성하지 않습니다. 컴파일 타임에 메타데이터(꼬리표)로만 존재합니다.

성능 비교 (Bytecode 레벨) 코틀린의 Nullable 코드는 컴파일되면 자바의 if (x != null)과 완전히 동일한 바이트코드로 변환됩니다.

💡 Zero Overhead 코틀린의 Nullable 타입은 런타임에 추가적인 객체 생성 비용이 "0"입니다.

 

Java

// Kotlin: str?.length ?: 0
// Java 변환 결과 (대략적)
if (str != null) {
    return str.length();
} else {
    return 0;
}

반면 Optional은 Optional.ofNullable(str)을 호출하며 객체를 생성하고, 메서드 호출을 한 단계 더 거쳐야 합니다. 코틀린은 안전함(Safety)과 성능(Performance) 두 마리 토끼를 모두 잡았습니다.


마치며

코틀린의 Nullable 시스템은 단순히 NullPointerException을 막는 도구가 아닙니다. 이는 "값이 없을 수 있다"는 사실을 타입 시스템에 명시함으로써, 코드의 의도를 명확히 하고 컴파일러의 도움을 받아 견고한 애플리케이션을 만들게 해주는 강력한 철학입니다.