자바 코드에서 넘어온 타입. 코틀린 컴파일러가 null 여부를 알 수 없어 검사를 유보한 상태다.
코틀린 타입 표기
String not-null
String? nullable
String! 플랫폼 타입 ← 우리가 직접 쓸 수는 없고, IDE 가 보여 주는 표기
왜 위험한가
// 자바
public class UserRepo {
public String findName(long id) { return null; } // null 을 반환할 수 있다
}
// 코틀린
val name: String = repo.findName(1) // 컴파일 통과! (플랫폼 타입이라 검사를 안 한다)
println(name.length) // 런타임 NPE
코틀린을 쓰는데도 NPE가 나는 거의 유일한 실무 경로다.
왜 이렇게 설계했나
모든 자바 타입을 nullable 로 취급하면
- 기존 자바 라이브러리를 쓸 때마다 ?. 와 !! 가 도배된다
- 실용성이 무너진다
모든 자바 타입을 not-null 로 취급하면
- 위험을 감춘다
타협: "모르니 개발자에게 맡긴다" — 대신 그 지점을 알고 있어야 한다
방어하는 법
// ① 경계에서 명시적으로 타입을 붙인다
val name: String? = repo.findName(1) // 이제 컴파일러가 검사한다
// ② 검증 함수로 의도를 남긴다
val name = requireNotNull(repo.findName(1)) { "이름이 없다" }
// ③ 자바 쪽에 애노테이션을 단다 — 코틀린이 이를 읽는다
@Nullable public String findName(long id) { ... } // → String? 로 인식
@NonNull public String getName() { ... } // → String 으로 인식
JSpecify·JetBrains·Android의 애노테이션을 코틀린 컴파일러가 인식한다 — 자바 라이브러리를 감쌀 때 이걸 다는 것이 근본 해법이다.
안드로이드에서 특히 자주 만난다
// 프레임워크 API 상당수가 플랫폼 타입으로 넘어온다
val extras = intent.extras // Bundle! → 사실 null 일 수 있다
val s = savedInstanceState?.getString("key") // 안전 호출을 습관화한다
면접 함정
- ❌ "코틀린만 쓰면 NPE가 없다" → 자바 라이브러리 경계가 남아 있다.
- ❌ "플랫폼 타입은 nullable이다" → 알 수 없음이다. 검사를 안 할 뿐이다.
어디서 새는지 찾아내기
// IDE 에서 타입을 확인하면 ! 가 보인다
val name = repo.findName(1) // 추론 타입: String! ← 플랫폼 타입
// 컴파일러 옵션으로 경고를 켤 수 있다
// kotlinc -Xjsr305=strict (JSR-305 애노테이션을 엄격하게 해석)
// build.gradle.kts
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
strict로 두면 애노테이션이 붙은 자바 API가 진짜 nullable/not-null로 해석되어 컴파일 시점에 잡힌다.
어디를 경계로 삼을 것인가
자바 라이브러리를 직접 여기저기서 부르지 않는다
- 얇은 래퍼(코틀린 함수)를 하나 두고 거기서 타입을 명시한다
- 플랫폼 타입이 코드베이스 전체로 퍼지는 것을 막는다
class UserGateway(private val java: JavaUserRepo) {
- fun findName(id: Long): String? = java.findName(id) // 여기서 한 번만 판단한다 }
안드로이드 프레임워크의 흔한 지점
requireContext() // Context! 가 아니라 Context 를 보장한다 (없으면 예외)
requireArguments()
arguments?.getString("k") // Bundle? · String? — 안전 호출을 습관화
findViewById<TextView>(R.id.x) // 없으면 null 이 아니라 NPE 로 터진다