|

코틀린 Nothing 타입이란 무엇인가: throw와 return이 타입으로 읽히는 이유

코틀린 Nothing 타입과 throw return expression을 설명하는 대표 이미지
값이 없는 타입이 아니라, 정상 값이 끝내 생기지 않는 경로를 읽는 감각이 핵심이다

코틀린 Nothing 타입은 처음 보면 추상적으로 느껴지지만, 실무에서는 생각보다 자주 만납니다. 핵심은 Nothing을 “값이 없는 타입”이 아니라 “정상 값이 생기기 전에 흐름이 끝나는 경로의 타입”으로 읽는 것입니다.

이 감각을 잡으면 Elvis 연산자 오른쪽의 throw, when 식 안의 error(), helper 함수의 Nothing 반환 타입이 왜 자연스러운지 한 번에 연결됩니다. 이번 글은 type theory보다 코드 해석에 집중해 차근차근 설명하겠습니다.

코틀린 Nothing 타입 핵심 요약 카드
먼저 이 카드의 기준을 잡고 읽으면 본문이 훨씬 쉬워집니다.

코틀린 Nothing 타입을 가장 짧게 설명하면

공식 API 문서는 Nothing has no instances라고 설명합니다. 즉 Nothing 타입의 실제 값을 만들 수 없다는 뜻입니다.

fun fail(message: String): Nothing {
    throw IllegalStateException(message)
}

이 함수는 반환 타입이 있지만 정상적인 값을 한 번도 돌려주지 않습니다. 항상 예외를 던지고 끝나기 때문입니다. 그래서 Nothing은 “값이 비어 있다”기보다 “정상 값이 절대 생기지 않는다”는 경로 설명에 가깝습니다.


왜 throw와 return이 타입처럼 읽힐까

여기서 더 중요한 전제는 Kotlin이 throwreturn을 expression으로 본다는 점입니다. 그래서 둘은 식 안에 자연스럽게 들어갈 수 있습니다.

fun requireName(raw: String?): String {
    return raw ?: throw IllegalArgumentException("name expected")
}

왼쪽이 null이 아니면 문자열이 나오고, null이면 오른쪽에서 예외를 던지고 끝납니다. 오른쪽은 정상 값을 만들지 않으므로 compiler는 이 경로를 Nothing으로 이해할 수 있습니다.

throw가 somehow String으로 바뀌는 게 아닙니다. String이 필요한 자리에서 값이 나오지 않는 종료 경로가 허용되는 것입니다.


bottom type이라는 말은 이렇게 이해하면 된다

Kotlin 스펙은 Nothing을 모든 타입의 하위 타입, 즉 bottom type으로 설명합니다. 이 말을 실무 감각으로 바꾸면 값을 어디에나 넣을 수 있다가 아니라, 정상 값이 나오지 않는 경로라 어떤 타입 자리에도 충돌을 만들지 않는다는 뜻에 가깝습니다.

fun loadToken(token: String?): String {
    return token ?: error("token must not be null")
}

여기서 error()는 문자열을 대신 만드는 것이 아닙니다. 아예 정상 반환을 하지 않기 때문에 String을 기대하는 식 안에서도 자연스럽게 들어갑니다.


코틀린 Nothing 타입은 Elvis 연산자에서 가장 잘 보인다

fun parentName(node: Node): String {
    val parent = node.parent ?: return "root"
    return parent.name ?: throw IllegalStateException("name expected")
}

이 예제에는 Elvis 오른쪽에 return도 있고 throw도 있습니다. 둘 다 공통점은 더 이상 정상 값을 계산하지 않고 현재 흐름을 여기서 끝낸다는 점입니다.

그래서 Elvis 오른쪽은 꼭 대체 값만 넣는 자리가 아닙니다. 필요하면 흐름을 종료하는 코드도 들어갈 수 있습니다.

공식 문서 역시 Elvis 오른쪽에 returnthrow를 두는 예시를 보여줍니다. Elvis 연산자는 값 대체 도구이기도 하지만, 더 진행할 수 없는 경우를 짧게 끊는 도구이기도 합니다.


when 식에서도 Nothing은 자연스럽게 섞인다

fun toLabel(status: Status): String = when (status) {
    Status.Loading -> "loading"
    Status.Success -> "success"
    Status.Fail -> error("unexpected fail state here")
}

앞의 두 분기는 String을 만들고, 마지막 분기는 error()로 끝납니다. 그래도 전체 when 식은 문자열을 반환하는 코드로 읽힙니다. 마지막 분기에서 정상 값이 나오지 않기 때문입니다.

상태 모델링에서 이런 패턴을 자주 보게 됩니다. 관련 글로는 코틀린 sealed class vs enum: 상태 모델링 기준을 함께 읽어보면 좋습니다.


Nothing과 Unit은 왜 다른가

입문자들이 많이 헷갈리는 비교가 NothingUnit입니다. 둘은 둘 다 특수해 보이지만 의미는 꽤 다릅니다.

fun logMessage(message: String): Unit {
    println(message)
}

fun crash(message: String): Nothing {
    throw IllegalStateException(message)
}
  • Unit: 정상적으로 실행이 끝났지만 돌려줄 의미 있는 데이터가 없음
  • Nothing: 정상 종료 자체가 없어서 값을 돌려줄 기회가 없음

짧게 줄이면 Unit은 “끝나기는 끝난다”, Nothing은 “정상적으로 끝나지 않는다”입니다.


Nothing과 null은 같은 것이 아니다

null은 실제 값입니다. 반면 Nothing은 아예 인스턴스가 없는 타입입니다. 그래서 둘은 같은 뜻이 아닙니다.

val x: Nothing? = null

nullable로 바꾼 Nothing?는 사실상 null 하나만 가능한 타입처럼 읽을 수 있습니다. 하지만 Nothing 자체에는 넣을 실제 값이 없습니다.


실무에서 코틀린 Nothing 타입을 자주 만나는 자리

error()로 필수값을 끊을 때

val port = config["port"] ?: error("port is required")

필수값이 없으면 더 진행하지 않겠다는 의도가 아주 짧고 분명하게 보입니다.

TODO()로 미구현 분기를 표시할 때

fun mapRole(code: Int): String = when (code) {
    1 -> "admin"
    2 -> "member"
    else -> TODO("role mapping not implemented")
}

fail-fast helper를 만들 때

fun unreachable(reason: String): Nothing {
    throw IllegalStateException(reason)
}

fun parseMode(text: String): Mode = when (text) {
    "light" -> Mode.LIGHT
    "dark" -> Mode.DARK
    else -> unreachable("unknown mode: $text")
}

실패를 값으로 돌려줄지, 예외로 흐름을 끊을지의 판단은 코틀린 Result 타입 쉽게 이해하기와 함께 보면 더 선명해집니다.


그럼 Nothing을 직접 많이 써야 할까

대부분의 경우는 아닙니다. 개발자가 실무에서 먼저 얻어야 하는 것은 Nothing을 적극적으로 선언하는 습관보다, 이미 있는 코드를 자연스럽게 읽는 감각입니다.

  1. 왜 Elvis 오른쪽에 throw나 return이 들어가는지 읽을 수 있다
  2. 왜 when 분기 하나가 값을 만들지 않아도 전체 식이 성립하는지 이해한다
  3. 왜 error(), TODO(), fail-fast helper가 식 안에서 자연스러운지 안다

직접 Nothing 반환 함수를 만드는 경우는 항상 예외를 던지는 helper나 절대 도달하면 안 되는 분기처럼 의도가 매우 뚜렷한 자리에 한정되는 편이 안전합니다.


자주 하는 오해

  1. Nothing은 null과 같다
  2. Nothing은 Unit보다 더 작은 비슷한 반환 타입이다
  3. throw가 String 같은 타입으로 변환되어 Elvis가 성립한다
  4. Nothing을 많이 드러낼수록 더 코틀린다운 API가 된다

이 네 가지는 모두 조금씩 어긋난 이해입니다. 핵심은 변환이나 마법이 아니라, 정상 값이 나오지 않는 종료 경로를 타입 시스템이 읽을 수 있다는 점입니다.

nullable 처리와 scope function 감각을 함께 정리하고 싶다면 코틀린 let, run, apply, also, with 차이도 이어서 읽어볼 만합니다.


정리

코틀린 Nothing 타입은 어려운 이론을 외우는 주제가 아닙니다. 정상 값을 만들기 전에 흐름이 끝나는 경로를 표현하는 타입이라는 감각만 잡히면 충분합니다.

이 관점만 익히면 throw, return, error(), TODO()가 Elvis, when, helper 함수 안에서 왜 타입처럼 읽히는지 자연스럽게 이어집니다.

공식 기준은 Kotlin Nothing API 문서, Kotlin null safety 문서, Kotlin exceptions 문서, Kotlin type system specification을 함께 보면 가장 정확합니다.

함께보면 좋은 글