시스템아 미안해

Chapter 1: Safety - Item 3: Eliminate platform types as soon as possible 본문

책/Effective Kotlin

Chapter 1: Safety - Item 3: Eliminate platform types as soon as possible

if else 2026. 1. 22. 14:54

Kotlin이 도입한 널 안정성(null safety) 은 정말 뛰어납니다.
Java는 커뮤니티에서 Null Pointer Exception(NPE) 으로 잘 알려져 있었는데, Kotlin의 안전 장치들은 이러한 문제를 매우 드물게 만들거나 아예 제거해 줍니다.

 

하지만 완전히 안전하게 보장할 수 없는 한 가지가 있는데, 바로 Kotlin과 널 안정성이 충분하지 않은 언어,

예를 들어 Java나 C와의 연결 지점입니다.

 

예를 들어, String을 반환 타입으로 선언한 Java 메서드를 사용한다고 가정해 보겠습니다.
이 메서드는 Kotlin에서 어떤 타입으로 취급되어야 할까요?

  • 만약 @Nullable 애노테이션이 붙어 있다면, 이를 널이 될 수 있다고 가정하고 String?으로 해석합니다.
  • 반대로 @NotNull 애노테이션이 붙어 있다면, 해당 애노테이션을 신뢰하여 String 타입으로 취급합니다. 

그렇다면 이 두 애노테이션 중 어느 것도 붙어 있지 않은 경우에는 어떻게 해야 할까요?

 
// Java
public class JavaTest {
    
    public String giveName() {
        // ...
    }
}

 

이런 경우 해당 타입을 널이 될 수 있는 타입으로 취급해야 한다고 생각할 수도 있습니다.
Java에서는 모든 것이 널이 될 수 있기 때문에, 이는 분명히 안전한 접근 방식입니다.

 

하지만 실제로는 널이 아님을 알고 있는 경우가 많기 때문에, 코드 전반에 걸쳐 널 아님 단언 연산자 !!를 반복해서 사용해야 하는 상황이 발생하게 됩니다.

 

더 큰 문제는 Java에서 제네릭 타입을 받아와야 할 때 발생합니다.
예를 들어, 아무런 애노테이션도 붙어 있지 않은 List<User>를 반환하는 Java API가 있다고 가정해 보겠습니다.
만약 Kotlin이 기본적으로 이를 널 허용 타입으로 가정하고, 우리가 이 리스트와 그 안의 User 객체들이 널이 아님을 알고 있다면,
단순히 리스트 전체에 대해 널 아님 단언을 하는 것뿐만 아니라, 리스트 내부의 널 값들까지 걸러내야 하는 상황이 발생하게 됩니다.

 
// Java
public class UserRepo {
    
    public List<User> getUsers() {
        //***
    }
}
 
 
kotlin
// Kotlin
val users: List<User> = UserRepo().users!!.filterNotNull()

 

 

함수가 List<List<User>>를 반환했다면 어떻게 될까요? 이는 더 복잡해집니다:

 
kotlin
val users: List<List<User>> = UserRepo().groupedUsers!!
    .map { it!!.filterNotNull() }

리스트의 경우에는 map이나 filterNotNull과 같은 함수들이 최소한 제공됩니다.
하지만 다른 제네릭 타입들에서는 널 처리 문제가 훨씬 더 심각해질 수 있습니다.

 

이러한 이유로, Java에서 넘어온 타입을 기본적으로 널 허용 타입으로 취급하지 않고,
Kotlin에서는 널 가능성이 불분명한 타입을 별도의 특별한 타입으로 다룹니다.
이를 플랫폼 타입(platform type) 이라고 합니다.

 

플랫폼 타입(platform type) 이란,
다른 언어에서 넘어왔으며 널 가능성이 명확하지 않은 타입을 의미합니다.

 

플랫폼 타입은 타입 이름 뒤에 느낌표(!) 하나를 붙여 String!과 같이 표기합니다.
다만 이 타입은 명시적으로 선언할 수 없는(non-denotable) 타입이기 때문에,
코드에서 직접 String!과 같이 작성할 수는 없습니다.

 

플랫폼 타입을 가진 값이 Kotlin 변수나 프로퍼티에 할당되면,
그 타입은 자동으로 추론될 수는 있지만, 명시적으로 지정할 수는 없습니다.
대신, 우리는 해당 값을 널 허용 타입(nullable) 이나 널 비허용 타입(non-nullable) 중 하나로 선택하여 선언할 수 있습니다.

 
// Java
public class UserRepo {
    public User getUser() {
        //...
    }
}
 
 
kotlin
// Kotlin
val repo = UserRepo()
val user1 = repo.user       // Type of user1 is User!
val user2: User = repo.user // Type of user2 is User
val user3: User? = repo.user // Type of user3 is User?

이 덕분에 Java에서 generic 타입을 가져오는 것은 문제가 되지 않습니다:

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

플랫폼 타입을 널 비허용 타입(non-nullable type) 으로 캐스팅하는 것은
아예 타입을 지정하지 않는 것보다는 낫지만,
여전히 위험한 선택입니다. 우리가 널이 아니라고 가정한 값이
실제로는 널일 수도 있기 때문입니다.

 

이 때문에 안전성을 고려한다면, Java에서 넘어온 플랫폼 타입을 다룰 때는 항상 각별한 주의가 필요합니다.
지금은 어떤 함수가 널을 반환하지 않더라도, 미래에도 그렇다고 보장할 수는 없습니다.
함수 설계자가 애노테이션이나 주석으로 이를 명확히 명시하지 않았다면,
계약(contract)을 변경하지 않고도 이러한 동작이 나중에 도입될 수 있습니다.

 

만약 Kotlin과 상호 운용되어야 하는 Java 코드에 대해 일정 수준의 제어권이 있다면,
가능한 한 모든 곳에 @Nullable과 @NotNull 애노테이션을 도입하는 것을 권장합니다.

 
 
// Java
import org.jetbrains.annotations.NotNull;

public class UserRepo {
    public @NotNull User getUser() {
        //...
    }
}

 

이는 Kotlin 개발자를 제대로 지원하기 위해 반드시 거쳐야 하는 가장 중요한 단계 중 하나이며
(동시에 Java 개발자에게도 매우 중요한 정보입니다).
Kotlin이 1급 시민(first-class citizen) 이 된 이후, Android API에 도입된 가장 중요한 변화 중 하나가
바로 외부로 노출되는 타입들에 대규모로 애노테이션을 추가한 것이었습니다.
그 결과 Android API는 훨씬 Kotlin 친화적으로 바뀌었습니다.

참고로, 다음과 같이 매우 다양한 종류의 애노테이션들이 지원됩니다.

  • JetBrains
    @Nullable, @NotNull (org.jetbrains.annotations)
  • Android
    @Nullable, @NonNull
    (androidx.annotation, com.android.annotations, android.support.annotations)
  • JSR-305
    @Nullable, @CheckForNull, @Nonnull (javax.annotation)
  • JavaX
    @Nullable, @CheckForNull, @Nonnull (javax.annotation)
  • FindBugs
    @Nullable, @CheckForNull, @PossiblyNull, @NonNull
    (edu.umd.cs.findbugs.annotations)
  • ReactiveX
    @Nullable, @NonNull (io.reactivex.annotations)
  • Eclipse
    @Nullable, @NonNull (org.eclipse.jdt.annotation)
  • Lombok
    @NonNull (lombok)

또는 Java에서는 JSR-305의 @ParametersAreNonnullByDefault 애노테이션을 사용해,
모든 타입을 기본적으로 널 비허용(non-null) 으로 지정할 수도 있습니다.

 

한편, Kotlin 코드 쪽에서도 우리가 할 수 있는 일이 있습니다.
제 권장 사항은 안전성을 위해 플랫폼 타입을 가능한 한 빨리 제거하는 것입니다.
그 이유를 이해하기 위해, 다음 예제에서
statedType 함수와 platformType 함수가 어떻게 다르게 동작하는지를 한 번 생각해 보시기 바랍니다.

 
// Java
public class JavaClass {
    public String getValue() {
        return null;
    }
}
 
 
kotlin
// Kotlin
fun statedType() {
    val value: String = JavaClass().value
    //...
    println(value.length)
}

fun platformType() {
    val value = JavaClass().value
    //...
    println(value.length)
}

두 경우 모두 개발자는 getValue가 null을 반환하지 않을 것이라고 가정했지만,
이 가정은 잘못된 것이며 결과적으로 두 경우 모두 NPE가 발생합니다.
다만 오류가 발생하는 지점에는 중요한 차이가 있습니다.

 

statedType의 경우에는, Java로부터 값을 가져오는 바로 그 줄에서 NPE가 발생합니다.
즉, 우리가 널이 아니라고 잘못 가정했다는 사실이 즉시 드러나며,
해당 타입을 수정하고 그에 맞게 나머지 코드를 조정하면 됩니다.

 

반면 platformType의 경우에는, 이 값을 널 비허용 값처럼 사용하는 시점에서 NPE가 발생합니다.
이는 종종 더 복잡한 표현식의 중간 지점에서 일어날 수 있습니다.
플랫폼 타입으로 선언된 변수는 널 허용 타입처럼도, 널 비허용 타입처럼도 취급될 수 있기 때문입니다.

 

이러한 변수는 몇 번은 안전하게 사용되다가,
어느 순간 안전하지 않은 방식으로 사용되어 NPE를 발생시킬 수 있습니다.
이때는 타입 시스템이 우리를 보호해 주지 못합니다.
이는 Java와 유사한 상황이지만, Kotlin에서는 객체를 사용하는 것만으로 NPE가 발생하리라고 기대하지 않기 때문에
더 큰 문제가 됩니다.

 

결국 언젠가는 누군가가 플랫폼 타입 변수를 안전하지 않게 사용하게 될 가능성이 매우 높고,
그 결과 원인을 찾기 쉽지 않은 런타임 예외로 이어질 수 있습니다.

 
// Java
public class JavaClass {
    public String getValue() {
        return null;
    }
}
 
 
kotlin
// Kotlin
fun statedType() {
    val value: String = JavaClass().value // NPE
    //...
    println(value.length)
}

fun platformType() {
    val value = JavaClass().value
    //...
    println(value.length) // NPE
}

더 위험한 것은, platform 타입이 인터페이스의 일부로 노출될 때 더욱 전파될 수 있다는 것입니다:

 
 
kotlin
interface UserRepo {
    fun getUserName() = JavaClass().value
}

 

이 경우, 해당 메서드의 추론된 타입은 플랫폼 타입이 됩니다.
즉, 이를 널 허용으로 볼지, 널 비허용으로 볼지는 사용하는 사람이 여전히 결정할 수 있습니다.

 

어떤 사람은 정의하는 쪽(definition site) 에서는 이를 널 허용 타입으로 취급하고,
사용하는 쪽(use site) 에서는 널 비허용 타입으로 취급하는 선택을 할 수도 있습니다.

 

 
 
kotlin
class RepoImpl : UserRepo {
    override fun getUserName(): String? {
        return null
    }
}

fun main() {
    val repo: UserRepo = RepoImpl()
    val text: String = repo.getUserName() // NPE in runtime
    print("User name length is ${text.length}")
}

 

랫폼 타입을 그대로 전파(propagate)하는 것은 재앙으로 가는 지름길입니다.
플랫폼 타입은 본질적으로 문제가 많기 때문에, 안전성을 위해 가능한 한 빨리 제거해야 합니다.

이 경우에는 IntelliJ IDEA가 경고를 통해 이를 알려줍니다.