Java와 Kotlin에서의 널 가능성

Java와 Kotlin에서의 널 가능성

Java 코드에 익숙하다면 NullPointerException이 얼마나 자주 골치를 썩이는지 잘 알 거예요. Kotlin으로 넘어오면 가장 큰 변화 중 하나가 바로 nullable 타입 처리예요. Kotlin이 Java의 타입을 어떻게 받아들이고, 어떤 관용구로 안전하게 다루는지 하나씩 비교해 볼게요.

출처: Kotlin 공식 문서

본문

널 가능성(nullability)은 변수가 null 값을 담을 수 있는 능력을 말해요. 변수에 null이 들어 있으면 그 변수를 역참조(dereference)하려 할 때 NullPointerException이 발생해요. 널 포인터 예외가 발생할 확률을 최소화하기 위해 코드를 작성하는 방법은 여러 가지가 있어요.

이 가이드는 nullable 변수를 처리하는 Java와 Kotlin의 접근 방식 차이를 다룹니다. Java에서 Kotlin으로 마이그레이션하고, 진짜 Kotlin 스타일로 코드를 작성하는 데 도움이 될 거예요.

이 가이드의 첫 부분은 가장 중요한 차이인 Kotlin의 nullable 타입 지원과 Kotlin이 Java 코드의 타입을 처리하는 방법을 다뤄요. 두 번째 부분은 "함수 호출 결과 확인"부터 시작해서 특정 사례들을 살펴보며 차이를 설명해요.

Kotlin의 널 안전성(null safety)에 대해 더 자세히 배우고 싶다면 관련 문서를 참고하세요.

nullable 타입 지원

Kotlin과 Java 타입 시스템의 가장 중요한 차이는 Kotlin이 nullable 타입을 명시적으로 지원한다는 점이에요. 이는 어떤 변수가 null 값을 담을 수 있는지를 나타내는 방법이죠. 변수가 null일 수 있다면 그 변수에 메서드를 호출하는 것은 안전하지 않아요. NullPointerException을 유발할 수 있기 때문이에요. Kotlin은 컴파일 시간에 그러한 호출을 금지해서 가능한 예외를 많이 방지해요. 런타임에는 nullable 타입의 객체와 non-nullable 타입의 객체가 동일하게 취급돼요. nullable 타입은 non-nullable 타입을 감싸는 래퍼가 아니에요. 모든 검사는 컴파일 시간에 수행되죠. 즉 Kotlin에서 nullable 타입을 다루는 데 거의 런타임 오버헤드가 없다는 뜻이에요.

"거의"라고 말한 이유는 내장 검사(intrinsic checks)가 생성되긴 하지만 그 오버헤드가 미미하기 때문이에요.

Java에서 널 검사를 작성하지 않으면 메서드가 NullPointerException을 던질 수 있어요.

// Java
int stringLength(String a) {
    return a.length();
}

void main() {
    stringLength(null); // NullPointerException을 던져요
}

이 호출은 다음과 같은 출력을 갖게 돼요.

java.lang.NullPointerException: Cannot invoke "String.length()" because "a" is null
    at test.java.Nullability.stringLength(Nullability.java:8)
    at test.java.Nullability.main(Nullability.java:12)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)

Kotlin에서 모든 일반 타입은 명시적으로 nullable로 표시하지 않는 한 기본적으로 non-nullable이에요. anull이 될 거라 기대하지 않는다면 stringLength() 함수를 다음과 같이 선언해요.

// Kotlin
fun stringLength(a: String) = a.length

매개변수 aString 타입인데, Kotlin에서 이는 항상 String 인스턴스를 담아야 하고 null은 담을 수 없다는 뜻이에요. Kotlin의 nullable 타입은 물음표 ?로 표시되는데, 예를 들어 String?가 그렇죠. aString이라면 런타임에 NullPointerException이 발생하는 상황은 불가능해요. 컴파일러가 stringLength()의 모든 인자가 null이 아니라는 규칙을 강제하기 때문이에요.

stringLength(a: String) 함수에 null 값을 전달하려고 하면 "Null can not be a value of a non-null type String"이라는 컴파일 시간 오류가 발생해요.

null을 포함한 어떤 인자와도 이 함수를 사용하고 싶다면, 인자 타입 뒤에 물음표 String?를 붙이고 함수 본문 안에서 인자 값이 null이 아닌지 확인하면 돼요.

// Kotlin
fun stringLength(a: String?): Int = if (a != null) a.length else 0

검사가 성공적으로 통과하면 컴파일러는 검사를 수행하는 범위 안에서 변수를 non-nullable 타입 String인 것처럼 취급해요.

이 검사를 수행하지 않으면 코드는 "Only safe (?.) or non-nullable asserted (!!.) calls are allowed on a nullable receiver of type String?"이라는 메시지와 함께 컴파일되지 않아요.

같은 코드를 더 짧게 쓸 수도 있어요. 널 검사와 메서드 호출을 하나의 연산으로 결합하는 안전 호출 연산자 ?.(If-not-null 단축 표기)를 사용하면 돼요.

// Kotlin
fun stringLength(a: String?): Int = a?.length ?: 0

플랫폼 타입

Java에서는 변수가 null일 수 있는지 여부를 보여주는 어노테이션을 사용할 수 있어요. 이런 어노테이션은 표준 라이브러리의 일부가 아니지만 별도로 추가할 수 있어요. 예를 들어 JetBrains 어노테이션 @Nullable@NotNull(org.jetbrains.annotations 패키지), JSpecify 어노테이션(org.jspecify.annotations), 또는 Eclipse의 어노테이션(org.eclipse.jdt.annotation)을 사용할 수 있어요. Kotlin은 Kotlin 코드에서 Java 코드를 호출할 때 이런 어노테이션을 인식하고 어노테이션에 따라 타입을 처리해요.

Java 코드에 이런 어노테이션이 없으면 Kotlin은 Java 타입을 **플랫폼 타입(platform types)**으로 취급해요. 하지만 Kotlin은 이런 타입에 대한 널 가능성 정보를 갖고 있지 않기 때문에 컴파일러가 그 타입에 대한 모든 연산을 허용해요. 널 검사를 수행할지 여부는 직접 결정해야 해요. 그 이유는:

  • Java에서처럼 null에 대해 연산을 시도하면 NullPointerException을 얻게 돼요.
  • 컴파일러가 중복된 널 검사를 강조 표시하지 않는데, 이는 보통 non-nullable 타입의 값에 널 안전 연산을 수행할 때 하는 동작이에요.

Java에서 Kotlin을 호출할 때 널 안전성과 플랫폼 타입에 대해 더 배우고 싶다면 관련 문서를 참고하세요.

확실히 non-nullable 타입 지원

Kotlin에서 인자에 @NotNull을 포함하는 Java 메서드를 오버라이드하려면 Kotlin의 definitely non-nullable 타입이 필요해요.

예를 들어 Java의 이 load() 메서드를 살펴볼게요.

import org.jetbrains.annotations.*;

public interface Game<T> {
  public T save(T x) {}
  @NotNull
  public T load(@NotNull T x) {}
}

Kotlin에서 load() 메서드를 성공적으로 오버라이드하려면 T1을 definitely non-nullable(T1 & Any)로 선언해야 해요.

interface ArcadeGame<T1> : Game<T1> {
  override fun save(x: T1): T1
  // T1은 definitely non-nullable
  override fun load(x: T1 & Any): T1 & Any
}

definitely non-nullable 제네릭 타입에 대해 더 배우고 싶다면 관련 문서를 참고하세요.

함수 호출 결과 확인

널을 확인해야 하는 가장 흔한 상황 중 하나는 함수 호출에서 결과를 얻을 때예요.

다음 예시에는 OrderCustomer라는 두 클래스가 있어요. OrderCustomer 인스턴스에 대한 참조를 갖고 있어요. findOrder() 함수는 Order 클래스의 인스턴스를 반환하거나, 주문을 찾을 수 없으면 null을 반환해요. 목표는 가져온 주문의 고객 인스턴스를 처리하는 거예요.

Java의 클래스는 다음과 같아요.

//Java
record Order (Customer customer) {}

record Customer (String name) {}

Java에서는 함수를 호출하고 결과에 if-not-null 검사를 수행해서 필요한 속성의 역참조를 진행해요.

// Java
Order order = findOrder();

if (order != null) {
    processCustomer(order.getCustomer());
}

위 Java 코드를 Kotlin 코드로 직접 변환하면 다음과 같아져요.

// Kotlin
data class Order(val customer: Customer)

data class Customer(val name: String)

val order = findOrder()

// 직접 변환
if (order != null){
    processCustomer(order.customer)
}

안전 호출 연산자 ?.(If-not-null 단축 표기)를 표준 라이브러리의 어떤 범위 함수(scope functions)와 함께 사용해요. 이때 보통 let 함수를 사용하죠.

// Kotlin
val order = findOrder()

order?.let {
    processCustomer(it.customer)
}

같은 코드의 더 짧은 버전은 다음과 같아요.

// Kotlin
findOrder()?.customer?.let(::processCustomer)

null 대신 기본값

null 검사는 널 검사가 성공했을 때 기본값을 설정하는 것과 함께 자주 사용돼요.

널 검사가 있는 Java 코드:

// Java
Order order = findOrder();
if (order == null) {
    order = new Order(new Customer("Antonio"))
}

Kotlin에서 같은 내용을 표현하려면 엘비스 연산자(If-not-null-else 단축 표기)를 사용해요.

// Kotlin
val order = findOrder() ?: Order(Customer("Antonio"))

값 또는 null을 반환하는 함수

Java에서는 리스트 요소를 다룰 때 주의해야 해요. 요소를 사용하기 전에 인덱스에 요소가 존재하는지 항상 확인해야 하죠.

// Java
var numbers = new ArrayList<Integer>();
numbers.add(1);
numbers.add(2);

System.out.println(numbers.get(0));
//numbers.get(5) // Exception!

Kotlin 표준 라이브러리는 이름이 null 값을 반환할 가능성이 있는지를 나타내는 함수를 자주 제공해요. 특히 컬렉션 API에서 흔하죠.

fun main() {
//sampleStart
    // Kotlin
    // Java와 같은 코드:
    val numbers = listOf(1, 2)

    println(numbers[0])  // 컬렉션이 비어 있으면 IndexOutOfBoundsException을 던질 수 있어요
    //numbers.get(5)     // Exception!

    // 더 많은 기능:
    println(numbers.firstOrNull())
    println(numbers.getOrNull(5)) // null
//sampleEnd
}

집계 연산

가장 큰 요소를 얻거나, 요소가 없으면 null을 얻어야 할 때 Java에서는 Stream API를 사용해요.

// Java
var numbers = new ArrayList<Integer>();
var max = numbers.stream().max(Comparator.naturalOrder()).orElse(null);
System.out.println("Max: " + max);

Kotlin에서는 집계 연산을 사용해요.

// Kotlin
val numbers = listOf<Int>()
println("Max: ${numbers.maxOrNull()}")

Java와 Kotlin의 컬렉션에 대해 더 배우고 싶다면 관련 문서를 참고하세요.

타입 안전 캐스팅

타입을 안전하게 캐스팅해야 할 때 Java에서는 instanceof 연산자를 사용한 뒤 그것이 얼마나 잘 동작했는지 확인해요.

// Java
int getStringLength(Object y) {
    return y instanceof String x ? x.length() : -1;
}

void main() {
    System.out.println(getStringLength(1)); // `-1`을 출력해요
}

Kotlin에서 예외를 피하려면 실패 시 null을 반환하는 안전 캐스트 연산자 as?를 사용해요.

// Kotlin
fun main() {
    println(getStringLength(1)) // `-1`을 출력해요
}

fun getStringLength(y: Any): Int {
    val x: String? = y as? String // null
    return x?.length ?: -1 // x가 null이므로 -1을 반환
}

위 Java 예시에서 함수 getStringLength()는 원시 타입 int의 결과를 반환해요. null을 반환하게 만들려면 박싱된 타입 Integer를 사용할 수 있어요. 하지만 이런 함수가 음수 값을 반환하게 하고 그 값을 확인하는 것이 더 자원 효율적이에요. 어차피 검사는 해야 하고, 박싱을 추가로 수행하지 않기 때문이죠.

Java 코드를 Kotlin으로 마이그레이션할 때 처음에는 코드의 원래 의미를 보존하기 위해 nullable 타입과 함께 일반 캐스트 연산자 as를 사용하고 싶을 수 있어요. 하지만 더 안전하고 관용적인 접근을 위해 안전 캐스트 연산자 as?를 사용하도록 코드를 적응시키는 걸 권장해요. 예를 들어 다음 Java 코드가 있다면:

public class UserProfile {
    Object data;

    public static String getUsername(UserProfile profile) {
        if (profile == null) {
            return null;
        }
        return (String) profile.data;
    }
}

as 연산자로 직접 마이그레이션하면 다음과 같이 돼요.

class UserProfile(var data: Any? = null)

fun getUsername(profile: UserProfile?): String? {
    if (profile == null) {
        return null
    }
    return profile.data as String?
}

여기서 profile.dataas String?로 nullable 문자열에 캐스팅돼요.

한 단계 더 나아가 as? String을 사용해 값을 안전하게 캐스팅하는 것을 권장해요. 이 접근 방식은 ClassCastException을 던지는 대신 실패 시 null을 반환해요.

class UserProfile(var data: Any? = null)

fun getUsername(profile: UserProfile?): String? =
  profile?.data as? String

이 버전은 if 표현식을 안전 호출 연산자 ?.로 대체해서, 캐스팅을 시도하기 전에 data 속성에 안전하게 접근해요.

다음 단계

  • 다른 Kotlin 관용구를 살펴보세요.
  • Java-to-Kotlin(J2K) 변환기로 기존 Java 코드를 Kotlin으로 변환하는 방법을 배워 보세요.
  • 다른 마이그레이션 가이드도 확인해 보세요.
    • Java와 Kotlin의 문자열
    • Java와 Kotlin의 컬렉션

마음에 드는 관용구가 있다면 풀 리퀘스트를 보내서 알려주세요!

더 알아보기