자바 개발자가 가장 무서워하는 예외가 뭐냐고 물으면, 십중팔구 NullPointerException이라고 답할 거다. 코틀린은 이 문제를 언어 차원에서 해결했다. "이 변수가 null일 수 있는가?"를 타입 시스템에 녹여서, 컴파일 타임에 NPE 가능성을 잡아낸다.

Nullable 타입 — 물음표 하나의 차이

코틀린의 모든 타입은 기본적으로 null을 허용하지 않아요.

KOTLIN
var name: String = "코틀린"
// name = null              // 컴파일 에러!

var nullableName: String? = "코틀린"
nullableName = null         // OK

StringString?는 완전히 다른 타입입니다. 코틀린 컴파일러는 이 둘을 엄격하게 구분해요.

왜 이렇게 설계했을까?

Tony Hoare가 null reference를 "10억 달러짜리 실수"라고 불렀다는 일화는 유명하죠. 코틀린은 null이 필요한 곳에만 명시적으로 ?를 붙이게 해서, 코드를 읽는 것만으로 null 가능성을 파악할 수 있게 만들었습니다.

KOTLIN
// 함수 시그니처만 봐도 null 가능성을 알 수 있다
fun findUser(id: Long): User?          // null일 수 있음 — 못 찾을 수도 있다
fun getCurrentUser(): User             // null이 아님 — 반드시 존재

안전 호출 연산자(?.) — null이면 건너뛰기

Nullable 타입의 메서드를 호출할 때 ?.를 사용합니다. 수신 객체가 null이면 호출을 건너뛰고 null을 반환해요.

KOTLIN
val name: String? = null

// 안전 호출
println(name?.length)       // null 출력 (예외 아님!)
println(name?.uppercase())  // null 출력

체이닝으로 깔끔하게

안전 호출은 체이닝이 가능합니다. 중간에 하나라도 null이면 전체 결과가 null이 돼요.

KOTLIN
// 자바라면 이렇게 써야 했을 것
// if (user != null && user.getAddress() != null && user.getAddress().getCity() != null) ...

// 코틀린
val city = user?.address?.city   // 어디서든 null이면 결과는 null

자바의 Optional 체이닝과 비슷한 느낌인데, 훨씬 간결합니다.

엘비스 연산자(?:) — null일 때 기본값

?.의 결과가 null일 때 기본값을 지정하고 싶으면 엘비스 연산자를 사용합니다. 이름이 엘비스인 이유는 ?: 모양이 엘비스 프레슬리의 머리카락을 닮아서라는 설이 있어요.

KOTLIN
val name: String? = null

// 기본값 제공
val displayName = name ?: "익명"
println(displayName)          // 익명

// 안전 호출과 조합
val length = name?.length ?: 0
println(length)               // 0

조기 반환 패턴

엘비스 연산자와 return이나 throw를 조합하면 강력한 조기 반환 패턴을 만들 수 있습니다.

KOTLIN
fun processUser(userId: Long) {
    val user = findUser(userId) ?: return          // 없으면 바로 리턴
    val email = user.email ?: throw IllegalStateException("이메일 필수")

    sendNotification(email)
}

!! 연산자 — 강제 Non-null 단언

!!는 "이 값은 절대 null이 아니다"라고 개발자가 단언하는 연산자입니다. null이면 KotlinNullPointerException이 발생해요.

KOTLIN
val name: String? = "코틀린"
val length = name!!.length    // null이 아니라고 단언

val nullName: String? = null
// val crash = nullName!!.length  // KotlinNullPointerException 발생!

!! 사용을 피해야 하는 이유

!!를 남용하면 코틀린의 Null Safety 장점이 완전히 사라집니다. 사실상 자바처럼 NPE가 발생할 수 있게 되는 거예요.

KOTLIN
// 나쁜 예 — !!를 남발
fun getFullName(user: User?): String {
    return user!!.firstName + " " + user!!.lastName  // 위험!
}

// 좋은 예 — 안전 호출과 엘비스 사용
fun getFullName(user: User?): String {
    return "${user?.firstName ?: ""} ${user?.lastName ?: ""}".trim()
}

실무에서 !!가 정당화되는 경우는 거의 없습니다. 코드 리뷰에서 !!가 보이면 "이걸 왜 nullable로 선언했는지"부터 재검토하는 게 맞아요.

let/also로 null 처리 체이닝

스코프 함수와 안전 호출을 조합하면 null 분기를 매우 깔끔하게 처리할 수 있습니다.

let — null이 아닐 때만 실행

KOTLIN
val email: String? = getEmail()

// null이 아닐 때만 블록 실행
email?.let { validEmail ->
    sendVerification(validEmail)
    println("인증 메일 발송: $validEmail")
}

// 엘비스와 조합
val result = email?.let { processEmail(it) } ?: "이메일 없음"

also — 부수 효과(로깅 등)

KOTLIN
val user = findUser(userId)?.also { u ->
    logger.info("사용자 조회 성공: ${u.name}")
}

체이닝 패턴

KOTLIN
fun processOrder(orderId: Long): String {
    return findOrder(orderId)
        ?.let { order -> validateOrder(order) }
        ?.also { validated -> logger.info("주문 검증 완료: ${validated.id}") }
        ?.let { validated -> completeOrder(validated) }
        ?: "주문을 찾을 수 없습니다"
}

이 패턴이 처음엔 낯설 수 있는데, 익숙해지면 null 분기를 if-else 없이 깔끔하게 작성할 수 있어요.

스마트 캐스트 — null 검사 후 자동 변환

코틀린 컴파일러는 null 검사 이후에 자동으로 타입을 Non-null로 변환해줍니다. 이걸 스마트 캐스트 라고 해요.

KOTLIN
fun printLength(text: String?) {
    if (text != null) {
        // 여기서 text는 자동으로 String 타입 (Non-null)
        println(text.length)      // ?. 없이 직접 호출 가능
    }
}

// when과 조합
fun describe(value: Any?) {
    when (value) {
        null -> println("null입니다")
        is String -> println("문자열 길이: ${value.length}")  // 스마트 캐스트
        is Int -> println("정수 값: $value")
    }
}

자바 코드와의 상호운용 — Platform Type

코틀린의 Null Safety가 빛나는 건 순수 코틀린 코드일 때입니다. 자바 코드가 섞이면 이야기가 달라져요.

Platform Type이란?

자바에서 넘어온 타입 중 @Nullable이나 @NotNull 어노테이션이 없는 타입은 Platform Type 이 됩니다. IDE에서는 String!로 표시돼요.

JAVA
// 자바 코드 — 어노테이션 없음
public class JavaUtils {
    public static String getName() {
        return null;  // null을 반환할 수 있음
    }
}
KOTLIN
// 코틀린에서 호출
val name = JavaUtils.getName()   // String! (Platform Type)
// name.length                    // 런타임에 NPE 가능!

안전하게 처리하는 방법

KOTLIN
// 방법 1: Nullable로 받기 (권장)
val name: String? = JavaUtils.getName()
println(name?.length ?: 0)

// 방법 2: Non-null로 받기 (확신이 있을 때만)
val name: String = JavaUtils.getName()   // null이면 즉시 예외

@Nullable/@NotNull 어노테이션

자바 코드에 어노테이션을 붙이면 코틀린 컴파일러가 인식합니다.

JAVA
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.NotNull;

public class JavaUtils {
    @Nullable
    public static String getNullableName() { return null; }

    @NotNull
    public static String getNotNullName() { return "Java"; }
}
KOTLIN
val nullable = JavaUtils.getNullableName()   // String? 로 추론
val notNull = JavaUtils.getNotNullName()     // String 으로 추론

코틀린의 Null Safety 한계는 바로 이 Platform Type 입니다. 자바와 혼용할 때는 완벽한 Null Safety가 보장되지 않으며, 어노테이션으로 보완해야 합니다.

자바 Optional vs 코틀린 Nullable

자바 개발자라면 Optional과 비교가 궁금하실 거예요.

특성Java OptionalKotlin Nullable
적용 범위반환 타입에만 권장모든 곳에 적용
성능래퍼 객체 생성추가 객체 없음
문법.orElse(), .map()?., ?:, let
필드 사용권장하지 않음자유롭게 사용
컬렉션 원소권장하지 않음자유롭게 사용

코틀린의 Nullable은 타입 시스템에 내장되어 있어서 추가 객체 생성 비용이 없습니다. Optional처럼 래퍼로 감싸는 방식이 아니라, 컴파일러 레벨에서 null 검사를 강제하는 방식이에요.

정리

코틀린의 Null Safety는 단순히 편의 기능이 아니라 타입 시스템의 핵심입니다.

  • Nullable 타입(?): null 가능성을 타입으로 명시
  • ** 안전 호출(?.)**: null이면 건너뛰고 null 반환
  • ** 엘비스 연산자(?:)**: null일 때 기본값 제공, 조기 반환 패턴
  • **!! 연산자 **: 최후의 수단, 남용 금지
  • **let/also 체이닝 **: 스코프 함수와 조합해 깔끔한 null 처리
  • Platform Type: 자바 코드와의 상호운용 시 주의 필요

"코틀린이 NPE를 어떻게 방지하느냐"보다 "어떤 한계가 있고 어떻게 보완하느냐"까지 이해하는 것이 중요합니다.

댓글 로딩 중...