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이에요. a가 null이 될 거라 기대하지 않는다면 stringLength() 함수를 다음과 같이 선언해요.
// Kotlin
fun stringLength(a: String) = a.length
매개변수 a는 String 타입인데, Kotlin에서 이는 항상 String 인스턴스를 담아야 하고 null은 담을 수 없다는 뜻이에요. Kotlin의 nullable 타입은 물음표 ?로 표시되는데, 예를 들어 String?가 그렇죠. a가 String이라면 런타임에 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 제네릭 타입에 대해 더 배우고 싶다면 관련 문서를 참고하세요.
함수 호출 결과 확인
널을 확인해야 하는 가장 흔한 상황 중 하나는 함수 호출에서 결과를 얻을 때예요.
다음 예시에는 Order와 Customer라는 두 클래스가 있어요. Order는 Customer 인스턴스에 대한 참조를 갖고 있어요. 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.data는 as 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의 컬렉션
마음에 드는 관용구가 있다면 풀 리퀘스트를 보내서 알려주세요!