한 줄 정의
자바처럼 널 안전성이 없는 언어에서 넘어와 널 가능성을 알 수 없는 플랫폼 타입 은 타입 시스템의 보호를 받지 못하므로, 받는 즉시 널 가능·불가능 타입으로 못 박고 자바 쪽에는 널 가능성 애너테이션을 붙여 전파를 막아야 합니다.
쉽게 말하면
코틀린의 타입 시스템은 모든 물건에 “안전”이나 “깨질 수 있음” 스티커를 붙여 관리하는 물류 창고 입니다. 스티커만 보면 포장을 뜯어 보지 않고도 다룰 수 있고, “깨질 수 있음” 물건을 험하게 다루려 하면 창고 시스템이 그 자리에서 막습니다.
자바에서 오는 택배는 스티커 없이 도착합니다. 창고는 이 택배를 일단 스티커 없는 채로 들여보내는데, 그 상태로 창고 안을 돌아다니면 누군가는 “안전”으로, 누군가는 “깨질 수 있음”으로 각자 짐작해 다루다가 결국 어디선가 깨집니다. 그때는 어느 택배가 문제였는지 추적하기도 어렵습니다. 그래서 규칙은 두 가지입니다. 입고대에서 바로 스티커를 붙이고(타입 명시), 발송처에 스티커를 붙여서 보내 달라고 요청하는 것입니다(널 가능성 애너테이션).
왜 중요한가?
코틀린의 널 안전성 덕분에 자바의 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가 나지만, 어디서 나는지가 다릅니다.
statedType | platformType | |
|---|---|---|
| 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에 일어난 가장 중요한 변화 중 하나가 노출된 타입에 애너테이션을 붙인 것이었습니다. 코틀린이 인식하는 애너테이션은 다음과 같습니다.
| 출처 | 패키지 | 애너테이션 |
|---|---|---|
| JetBrains | org.jetbrains.annotations | @Nullable, @NotNull |
| Android | androidx.annotation, com.android.annotations, android.support.annotations | @Nullable, @NonNull |
| JSR-305 / JavaX | javax.annotation | @Nullable, @CheckForNull, @Nonnull |
| FindBugs | edu.umd.cs.findbugs.annotations | @Nullable, @CheckForNull, @PossiblyNull, @NonNull |
| ReactiveX | io.reactivex.annotations | @Nullable, @NonNull |
| Eclipse | org.eclipse.jdt.annotation | @Nullable, @NonNull |
| Lombok | lombok | @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 수준으로 올려 두면 리뷰 전에 잡힙니다.