한 줄 정의

자바처럼 널 안전성이 없는 언어에서 넘어와 널 가능성을 알 수 없는 플랫폼 타입 은 타입 시스템의 보호를 받지 못하므로, 받는 즉시 널 가능·불가능 타입으로 못 박고 자바 쪽에는 널 가능성 애너테이션을 붙여 전파를 막아야 합니다.

쉽게 말하면

코틀린의 타입 시스템은 모든 물건에 “안전”이나 “깨질 수 있음” 스티커를 붙여 관리하는 물류 창고 입니다. 스티커만 보면 포장을 뜯어 보지 않고도 다룰 수 있고, “깨질 수 있음” 물건을 험하게 다루려 하면 창고 시스템이 그 자리에서 막습니다.

자바에서 오는 택배는 스티커 없이 도착합니다. 창고는 이 택배를 일단 스티커 없는 채로 들여보내는데, 그 상태로 창고 안을 돌아다니면 누군가는 “안전”으로, 누군가는 “깨질 수 있음”으로 각자 짐작해 다루다가 결국 어디선가 깨집니다. 그때는 어느 택배가 문제였는지 추적하기도 어렵습니다. 그래서 규칙은 두 가지입니다. 입고대에서 바로 스티커를 붙이고(타입 명시), 발송처에 스티커를 붙여서 보내 달라고 요청하는 것입니다(널 가능성 애너테이션).

왜 중요한가?

코틀린의 널 안전성 덕분에 자바의 NPE(NullPointerException)는 거의 사라졌습니다. 하지만 널 안전성이 없는 자바나 C 같은 언어와 연결하는 순간 그 보장이 깨집니다. 자바 메서드의 반환 타입이 String일 때 @Nullable이 있으면 String?로, @NotNull이 있으면 String으로 해석하면 되지만, 아무 애너테이션도 없다면 어떻게 해야 할까요.

// 자바
public class JavaTest {
    public String giveName() {
        // ...
    }
}

애너테이션 없는 자바 타입을 전부 널 가능으로 보는 것이 가장 안전해 보입니다. 하지만 널이 아님을 확실히 아는 값에도 !!를 붙여야 하고, 제네릭이 끼면 감당하기 어려워집니다.

// 자바의 List<User> getUsers()를 전부 널 가능으로 해석하면
val users: List<User> = UserRepo().users!!.filterNotNull()
 
// List<List<User>>라면 더 복잡해집니다
val users: List<List<User>> = UserRepo().groupedUsers!!
    .map { it!!.filterNotNull() }

List에는 그나마 map이나 filterNotNull이 있지만 다른 제네릭 타입은 이런 처리조차 어렵습니다. 그래서 코틀린은 널 여부가 확인되지 않은 자바 타입을 널 가능으로 취급하는 대신 플랫폼 타입 이라는 특별한 타입으로 받습니다. 편의를 얻은 대가로 안전 확인은 개발자 몫이 되었고, 이 아이템은 그 몫을 어디서 어떻게 치러야 하는지를 다룹니다.

핵심 내용

플랫폼 타입이란

다른 언어에서 왔으며 널 가능성이 확인되지 않은 타입입니다. String!처럼 타입 이름 뒤에 느낌표를 붙여 표기하지만, 코드에 !를 직접 쓸 수는 없습니다. 자바 값을 변수에 할당하면 플랫폼 타입으로 추론될 수는 있어도 명시적으로 지정할 수는 없고, 그 대신 널 가능 혹은 널 불가능 타입으로 지정하는 방식으로 처리합니다.

// 자바
public class UserRepo {
    public User getUser() {
        // ...
    }
}
// 코틀린
val repo = UserRepo()
val user1 = repo.user           // User!  (플랫폼 타입)
val user2: User = repo.user     // User
val user3: User? = repo.user    // User?

이 덕분에 제네릭도 !! 없이 원하는 타입으로 바로 받을 수 있습니다.

val users: List<User> = UserRepo().users
val users: List<List<User>> = UserRepo().groupedUsers

다만 널 불가능으로 지정하는 것은 타입을 지정하지 않는 것보다 낫지만 여전히 위험합니다. null이 될 수 없다고 가정한 값이 실제로는 null일 수 있고, 함수가 지금 null을 반환하지 않더라도 미래에도 그러리라는 보장은 없습니다. 설계자가 애너테이션이나 주석으로 명시하지 않았다면 규약을 바꾸지 않고도 null을 반환하도록 바뀔 수 있기 때문입니다. 그래서 자바로부터 플랫폼 타입을 가져올 때는 항상 주의해야 합니다.

NPE가 터지는 위치의 차이

플랫폼 타입을 가능한 한 제거해야 하는 이유는 아래 두 함수의 차이에 있습니다. getValue가 null을 돌려주는 자바 클래스를 두 방식으로 사용합니다.

// 자바
public class JavaClass {
    public String getValue() {
        return null;
    }
}
// 코틀린
fun statedType() {
    val value: String = JavaClass().value   // NPE
    // ...
    println(value.length)
}
 
fun platformType() {
    val value = JavaClass().value
    // ...
    println(value.length)                   // NPE
}

둘 다 “null은 안 온다”는 가정이 틀려 NPE가 나지만, 어디서 나는지가 다릅니다.

statedTypeplatformType
NPE 발생 위치자바에서 값을 가져오는 라인널 불가능 값으로 사용하는 라인 (복잡한 식 한가운데일 수 있음)
원인 파악널 불가능으로 잘못 가정했고 null을 받았다는 것이 즉시 드러남값의 출처와 사용처가 떨어져 있어 추적이 어려움
수정타입에 ?를 붙이고 나머지 코드를 맞춤어느 사용처가 문제인지부터 찾아야 함

platformType의 value는 널 가능과 널 불가능 양쪽으로 모두 취급될 수 있습니다. 한두 번 안전하게 사용했더라도 이후 어딘가에서 안전하지 않게 사용되어 NPE가 날 수 있고, 타입 시스템은 이를 막아 주지 않습니다. 자바에서라면 흔한 상황이지만 코틀린에서는 객체를 사용할 때 NPE를 예상하지 않으므로, 누군가 이 변수를 안전하지 않게 쓸 가능성이 높고 결국 원인을 찾기 힘든 런타임 예외가 됩니다.

플랫폼 타입의 전파

훨씬 더 큰 위험은 플랫폼 타입이 다른 코드나 실행 흐름으로 확산되는 것입니다. 반환 타입을 생략한 함수는 플랫폼 타입을 인터페이스의 일부로 그대로 노출합니다.

interface UserRepo {
    fun getUserName() = JavaClass().value   // 반환 타입이 String!
}

반환 타입이 플랫폼 타입이면 널 가능성 여부를 정의하는 쪽과 사용하는 쪽이 각자 결정할 수 있습니다. 구현체는 널 가능으로 취급해 null을 돌려주고, 호출자는 널 불가능으로 받으면 런타임에 터집니다.

class RepoImpl : UserRepo {
    override fun getUserName(): String? {
        return null
    }
}
 
fun main() {
    val repo: UserRepo = RepoImpl()
    val text: String = repo.getUserName()   // 런타임에 NPE 발생
    print("User name length is ${text.length}")
}

플랫폼 타입을 전파하는 것은 재앙의 지름길이므로 가능한 한 빨리 제거해야 합니다. 인텔리제이 IDEA는 이런 선언에 “Declaration has type inferred from a platform call, which can lead to unchecked nullability issues. Specify type explicitly as nullable or non-nullable.” 경고를 띄워 줍니다.

자바 쪽 해법: 널 가능성 애너테이션

코틀린과 상호운용해야 하는 자바 코드를 제어할 수 있다면, 가능한 한 @Nullable과 @NotNull 애너테이션을 적용합니다. 그러면 코틀린에서 플랫폼 타입 대신 User나 User?로 바로 들어옵니다.

// 자바
import org.jetbrains.annotations.NotNull;
 
public class UserRepo {
    public @NotNull User getUser() {
        // ...
    }
}

널 가능성을 애너테이션으로 표기하는 것은 코틀린 개발자를 지원하고 싶을 때 가장 중요한 단계이고, 자바 개발자에게도 귀중한 정보입니다. 코틀린이 일급 시민이 된 뒤 Android API에 일어난 가장 중요한 변화 중 하나가 노출된 타입에 애너테이션을 붙인 것이었습니다. 코틀린이 인식하는 애너테이션은 다음과 같습니다.

출처패키지애너테이션
JetBrainsorg.jetbrains.annotations@Nullable, @NotNull
Androidandroidx.annotation, com.android.annotations, android.support.annotations@Nullable, @NonNull
JSR-305 / JavaXjavax.annotation@Nullable, @CheckForNull, @Nonnull
FindBugsedu.umd.cs.findbugs.annotations@Nullable, @CheckForNull, @PossiblyNull, @NonNull
ReactiveXio.reactivex.annotations@Nullable, @NonNull
Eclipseorg.eclipse.jdt.annotation@Nullable, @NonNull
Lomboklombok@NonNull

JSR-305의 @ParametersAreNonnullByDefault를 쓰면 자바에서도 기본적으로 모든 타입을 널 불가능으로 지정할 수 있습니다.

비교 / 트레이드오프

자바 값을 코틀린에서 받는 방식별로 안전이 어디서 확보되는지 정리하면 다음과 같습니다.

방식예NPE가 나는 곳평가
플랫폼 타입 그대로val v = java.value나중에 널 불가능으로 쓰는 어딘가가장 위험. 전파되면 추적이 어려움
널 불가능 명시val v: String = java.value값을 받는 그 라인원인은 즉시 드러나지만 자바 쪽 변경에 취약
널 가능 명시val v: String? = java.value나지 않음가장 안전. 대신 이후 코드에서 null 처리 필요
자바에 애너테이션public @NotNull User getUser()자바 쪽 규약 위반 시근본 해법. 자바 코드를 제어할 수 있을 때만 가능

내 생각

  • Spring 생태계는 이미 애너테이션 쪽 해법을 택했습니다. Spring Framework는 패키지 단위 @NonNullApi로 널 가능성을 표기해 두었고, Spring Framework 7부터는 JSpecify로 옮겨 갔습니다. Spring Initializr가 만들어 주는 코틀린 프로젝트에 -Xjsr305=strict 컴파일 옵션이 기본으로 들어가는 이유가 이것이며, 이 옵션이 없으면 패키지 단위 선언은 경고로만 반영됩니다.
  • 플랫폼 타입은 주로 사내 자바 모듈과 오래된 라이브러리에서 들어옵니다. 자바 엔티티·DTO, JDBC, 서블릿 API처럼 애너테이션이 없는 코드가 대표적입니다. 자바 모듈을 손볼 수 있다면 메서드마다 애너테이션을 붙이기보다 package-info.java에 @NonNullApi를 선언하고 예외만 @Nullable로 표시하는 편이 비용이 적습니다.
  • 경계에서 널 불가능으로 못 박을 때는 !!보다 requireNotNull이 낫습니다. requireNotNull(java.value) { "..." }로 받으면 실패 원인이 메시지로 남고, 잘못된 입력이라는 의미가 IllegalArgumentException으로 드러납니다. 맨 !!는 메시지 없는 NPE만 남깁니다.
  • 인텔리제이 경고는 팀 규칙으로 올릴 만합니다. 자바 호출 결과를 반환 타입 없이 노출하는 함수는 코드 리뷰에서 걸러야 하며, 해당 인스펙션을 error 수준으로 올려 두면 리뷰 전에 잡힙니다.