코틀린인데 NPE가 납니다: 원인은 자바에서 온 값이었습니다

자바로 쓰던 서비스를 코틀린으로 옮기기 시작했습니다. 널 안전성이 언어에 들어 있다는 말을 믿고 물음표를 안 붙였는데, 며칠 뒤 운영 로그에 NullPointerException이 찍혔습니다. 코틀린 컴파일러는 아무 경고도 하지 않았고요. 원인은 자바 모듈에서 받아 온 값이었습니다. 더 흥미로운 건 그다음인데, 받는 자리에 타입을 적었더니 같은 널인데 예외가 나는 위치와 메시지가 달라졌습니다.

무슨 일이 벌어지는지 알고 싶어서 코틀린을 컴파일하고 자바로 디컴파일해 봤습니다. Kotlin 2.0.21, JDK 21 기준입니다.

물음표는 실행 코드에 남습니다

먼저 확인하고 싶었던 건 널 불가 타입이 런타임에 아무것도 아닌지였습니다. 타입 검사는 컴파일 시점의 일이니까요.

class NullProbe {
    fun greet(name: String): String = "hi $name"
}

디컴파일하면 이렇게 나옵니다.

public final String greet(@NotNull String name) {
    Intrinsics.checkNotNullParameter(name, "name");
    return "hi " + name;
}

검사 한 줄이 함수 입구에 들어가 있습니다. 왜 필요한지는 조금만 생각하면 납득이 됩니다. 자바 코드나 리플렉션은 코틀린의 타입 검사를 거치지 않고 이 메서드를 부를 수 있으니까요. 그 경우를 막는 장치입니다.

재밌는 건 private 함수였습니다. 같은 널 불가 파라미터인데 검사가 없습니다.

private final int inner(String s) {
    return s.length();
}

밖에서 부를 수 없는 함수는 컴파일러가 이미 모든 호출부를 확인했으니 다시 검사할 이유가 없다는 뜻입니다. 검사가 필요한 곳은 외부와 닿는 경계뿐이라는 게 여기서 드러납니다.

안전 호출은 감싸는 객체를 만들지 않습니다

자바에서 Optional을 필드에 쓰지 말라는 이야기를 많이 들었던 터라, 코틀린의 물음표도 비슷한 비용이 있을 거라고 막연히 생각했습니다. 아니었습니다.

fun lengthOf(s: String?): Int = s?.length ?: -1
public final int lengthOf(@Nullable String s) {
    String string = s;
    return string != null ? string.length() : -1;
}

평범한 널 비교입니다. 객체도 안 만들고 람다도 없습니다. 자바에서 손으로 쓰던 if (x != null)과 같은 코드가 나옵니다. 그래서 반환 타입이든 필드든 가리지 않고 쓸 수 있는 거였습니다. Optional을 필드에 두지 말라던 이유가 여기서는 성립하지 않습니다. 자바에서 NPE를 Optional로 다루던 방식과 견줘 보면 접근이 꽤 다릅니다.

진짜 원인은 자바에서 온 값이었습니다

여기까지는 코틀린 안쪽 이야기입니다. 제가 겪은 NPE는 다른 데서 왔습니다.

public class JavaSource {
    public static String plain() { return null; }   // 애너테이션 없음
}

이 메서드의 반환 타입이 코틀린에서는 String도 String?도 아닙니다. 플랫폼 타입이라고 부르고 String!으로 표시합니다. 컴파일러가 널 가능 여부를 모른다는 뜻이고, 그래서 아무 쪽으로나 받게 해 줍니다.

val a: String = JavaSource.plain()    // 컴파일된다
val b: String? = JavaSource.plain()   // 이것도 컴파일된다

널 불가 타입에 널이 들어올 수 있는 값을 넣었는데 컴파일러가 막지 않습니다. 코틀린 널 안전성에 난 유일한 구멍이고, 자바와 섞어 쓰는 프로젝트에서 NPE가 나오는 경로는 거의 전부 여기라고 봐도 됩니다.

다행히 자바 쪽에 @Nullable 같은 애너테이션이 붙어 있으면 코틀린이 읽어서 String?으로 취급합니다. 어떤 애너테이션을 인식하는지는 코틀린 자바 상호운용 문서에 목록이 있습니다. 스프링이나 주요 라이브러리는 대체로 붙어 있어서 안전합니다. 문제는 애너테이션이 없는 오래된 사내 코드였습니다. 제 경우가 정확히 그랬습니다.

타입 한 줄을 적었더니 메시지가 달라졌습니다

플랫폼 타입을 받을 때 타입을 적느냐 마느냐는 취향 문제라고 생각했습니다. 어차피 추론되니까요. 실제로 재 보니 결과가 달랐습니다.

같은 널을 두 방식으로 받고 예외가 어디서 나는지 찍어 봤습니다.

fun takeAsNonNull(): Int {
    val s: String = JavaSource.plain()   // 타입을 적었다
    return s.length
}

fun passThrough(): Int {
    val s = JavaSource.plain()           // 추론에 맡겼다
    return s.length
}
널 불가로 받기 : NullPointerException @ takeAsNonNull:8
                 plain(...) must not be null
그대로 흘리기  : NullPointerException @ passThrough:15
                 Cannot invoke "String.length()" because "s" is null

둘 다 터지지만 내용이 전혀 다릅니다.

타입을 적으면 대입하는 자리에서 터집니다. 메시지도 plain(...) must not be null이라 어느 자바 메서드가 널을 돌려줬는지 이름이 찍힙니다. 코틀린이 대입 지점에 검사를 넣어 준 결과입니다.

추론에 맡기면 플랫폼 타입이 그대로 흘러가고 검사가 안 들어갑니다. 그래서 값을 실제로 쓰는 자리에서 평범한 자바 NPE가 납니다. 메시지에는 널이 어디서 왔는지가 없습니다.

제가 헤맨 이유가 이거였습니다. 자바에서 받은 값을 필드에 넣고 다른 메서드로 넘긴 뒤 한참 뒤에 썼거든요. 스택 트레이스가 가리킨 곳은 원인과 아무 상관 없는 자리였습니다. 타입을 적어 두었으면 받는 순간 메서드 이름과 함께 터졌을 겁니다.

경계에서 하는 일 세 가지

정리하면 규칙은 단순합니다.

첫째, 자바에서 값을 받는 자리에는 타입을 적습니다. 널이 올 수 없다고 판단하면 String으로 적어 그 자리에서 터지게 하고, 올 수 있으면 String?으로 적어 컴파일러가 검사를 강제하게 합니다. 안 적는 것만이 나쁜 선택입니다.

둘째, !! 대신 requireNotNull이나 checkNotNull을 씁니다. 둘 다 널이 아닌 값을 돌려주니 이후 코드에서는 널 불가 타입이 되고, 무엇보다 메시지를 붙일 수 있습니다.

requireNotNull(config.url) { "메일 설정이 비었습니다" }   // IllegalArgumentException
checkNotNull(session.user)                              // IllegalStateException

호출한 쪽이 잘못했으면 requireNotNull, 지금 상태가 잘못됐으면 checkNotNull입니다. 자바에서 Objects.requireNonNull 하나로 쓰던 자리를 둘로 나눠 쓰는 셈인데, 로그만 보고 원인을 짐작할 수 있게 돼서 생각보다 도움이 됩니다.

셋째, 사내 자바 모듈에 널 가능 애너테이션을 붙입니다. 코틀린으로 옮기지 않고도 코틀린 쪽 안전성이 올라가서, 투자 대비 효과가 가장 큰 작업이었습니다.

예외 메시지로 원인 찾기

나중을 위해 어떤 메시지가 무엇을 뜻하는지 정리해 뒀습니다.

메시지 원인 볼 곳
Parameter specified as non-null is null 자바나 리플렉션이 널을 넘겼다 호출한 자바 코드
... must not be null 자바 메서드가 널을 돌려줬다 메시지에 찍힌 메서드
평범한 NullPointerException 플랫폼 타입이 검사 없이 흘렀다 받는 자리에 타입을 적는다
UninitializedPropertyAccessException lateinit을 초기화 전에 썼다 초기화 시점

세 번째가 가장 자주 나오고 가장 찾기 어렵습니다. 스택 트레이스가 원인이 아니라 증상이 드러난 자리를 가리키니까요.

첫 번째와 두 번째는 반대로 반갑습니다. 코틀린이 넣어 준 검사에 걸린 것이라 메시지에 메서드나 파라미터 이름이 들어 있거든요. 로그만 보고 어느 줄인지 짐작할 수 있습니다. 세 번째가 나왔다면 그 값이 처음 들어온 지점을 거슬러 올라가야 하는데, 그 시간을 줄이려고 타입을 적는 겁니다.

마무리

코틀린의 널 안전성은 코틀린 안에서만 완전합니다. 자바와 맞닿는 경계에서는 컴파일러가 손을 놓고, 거기서 들어온 값이 어디까지 흘러가느냐가 디버깅 난이도를 정합니다.

타입을 적는 습관 하나로 그 난이도가 꽤 내려갑니다. 저는 자바 라이브러리를 부르는 줄마다 타입을 적는 쪽으로 바꿨고, 그 뒤로는 NPE가 나도 로그 한 줄이면 어디서 왔는지 알 수 있게 됐습니다.

다음 글에서는 data class가 자바로 무엇이 되는지, 그리고 왜 JPA 엔티티에 쓰면 안 되는지를 같은 방식으로 확인해 보겠습니다. 영속성 컨텍스트가 엔티티를 어떻게 관리하는지 알고 있으면 그 글이 훨씬 잘 읽힙니다.


자바 개발자를 위한 코틀린

문법이 아니라 생성되는 코드를 봅니다

이 글에서 쓴 방식 그대로, 코틀린이 자바로 무엇이 되는지 디컴파일해서 확인한 책입니다. 널 안전성부터 코루틴·스프링 설정까지 여덟 장이고, 모든 수치와 오류 메시지는 실제로 돌려 얻었습니다. 확인에 쓴 코드는 도커로 그대로 재현됩니다.

공개 준비 중입니다. 나오면 알려 드릴 수 있도록 텔레그램 채널에 소식을 올리겠습니다.

이 주제를 더 깊게 보려면

인프런 자바 개발자를 위한 코틀린 입문 (Java to Kotlin Starter Guide)

이 링크를 통해 수강 신청하면 운영자에게 소정의 수수료가 지급될 수 있습니다. 강의 가격은 달라지지 않습니다.

“코틀린인데 NPE가 납니다: 원인은 자바에서 온 값이었습니다”에 대한 1개의 생각

  1. 핑백: 코틀린 data class를 JPA 엔티티에 쓰면 안 되는 이유 - loghubs

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤