The JVM Index
뒤로 가기
Kotlin

Kotlin 에러 처리는 왜 아픈가: null, sealed, Result가 각자 무너지는 지점

Kotlin에는 에러를 표현하는 전용 언어 구문이 없다. null 반환, sealed 계층, kotlin.Result, Arrow Either가 각각 어디서 무너지는지 KEEP-0441 원문을 따라 짚고, 예외가 정당한 두 자리와 값으로서의 에러를 구분한다.

이 글 수정
목차
data class User(val name: String)
data class Transaction(val id: Long)

fun fetch(): User? = null
fun User.charge(): Transaction? = null

fun main() {
    val tx = fetch()?.charge()
    println(tx) // null. fetch가 실패했나, charge가 실패했나?
}

이 코드를 실행하면 tx에는 null이 담긴다. 그런데 fetch()가 사용자를 찾지 못해 null을 냈는지, 사용자는 찾았지만 charge()가 결제에 실패해 null을 냈는지는 결과만 봐서 구분할 수 없다. 안전 호출 ?.는 왼쪽이 null이면 오른쪽을 건너뛰고 식 전체를 null로 만든다. 두 개의 다른 실패가 같은 null 하나로 합쳐진다.

null 반환은 Kotlin에서 실패를 알리는 가장 값싼 방법이다. 타입에 물음표 하나만 붙이면 되고 호출자는 ?:?.let으로 짧게 받아낸다. 대신 실어 나를 수 있는 정보가 null이라는 한 비트뿐이다. 왜 실패했는지, 어디서 실패했는지는 어디에도 담기지 않는다. 실패했다는 사실만 남고 실패 지점은 사라진다.

그래서 개발자는 상황에 따라 도구를 바꿔 든다. 단순한 부재는 null로 반환한다. 원인을 구분해야 할 때는 sealed 계층을 세운다. 표준 래퍼가 필요하면 kotlin.Result를 꺼내고 함수형 합성까지 원하면 Arrow의 Either를 가져온다. 네 갈래가 나란히 쓰인다는 사실 자체가 하나로 충분한 답이 없다는 신호다. Kotlin에는 회복 가능한 실패를 선언하는 전용 언어 구문이 없다.

이 글은 그 빈자리를 KEEP-0441 문서를 따라 짚는 4부작의 1편이다. 예외가 정당한 자리를 먼저 긋고 checked exception이 왜 답이 아니었는지 확인한 다음 null과 sealed, kotlin.Result, Either가 각각 어디서 무너지는지를 하나씩 짚는다.

예외가 정당한 두 자리

Kotlin에서 예외가 정당한 자리는 두 곳이다. 전제조건 위반과 비지역 처리다.

첫째는 전제조건 위반이다. 함수가 요구하는 조건을 호출자가 어겼을 때 requirecheck로 즉시 멈추는 경우다.

fun process(userName: String) {
    require(userName.isNotBlank()) { "userName은 비어 있을 수 없다" }
    require(userName.first().isLetter()) { "userName($userName)은 글자로 시작해야 한다" }
    println("processing $userName")
}

fun main() = process("harry")

빈 문자열이 들어오면 첫 requireIllegalArgumentException을 던진다. 이 예외는 호출자가 회복할 실패가 아니라 코드가 잘못 쓰였다는 신호다. 호출자에게 빈 문자열을 다시 처리할 대안을 돌려줄 이유가 없다. 붙잡아서 분기할 대상이 아니라 애초에 그런 인자를 넘기지 않도록 고쳐야 할 버그다.

둘째는 비지역 처리다. 실패가 일어난 지점과 처리하는 지점이 멀리 떨어져 있어서 사이에 낀 모든 함수가 실패를 일일이 받아 넘기는 대신 최상위 한 곳에서 한꺼번에 처리하는 편이 나은 경우다. Spring MVC의 예외 처리가 전형이다.

@ResponseStatus(HttpStatus.NOT_FOUND)
class UserNotFoundException(message: String) : RuntimeException(message)

@RestController
class UserController(private val userService: UserService) {
    @GetMapping("/users/{id}")
    fun getUser(@PathVariable id: Long): UserDto =
        userService.findById(id)
            ?.let(UserDto::from)
            ?: throw UserNotFoundException("사용자 $id 를 찾을 수 없다")
}

컨트롤러는 사용자를 찾지 못하면 UserNotFoundException을 던지고 끝낸다. 이 예외를 HTTP 404 응답으로 바꾸는 일은 컨트롤러가 하지 않는다. 프레임워크가 최상위에서 예외를 가로채 @ResponseStatus가 지정한 404로 변환한다. 중간의 서비스 계층은 이 실패를 인지할 필요도 자기 반환 타입에 실어 나를 필요도 없다.

두 경우 모두 예외는 에러 모델이 아니라 정상 실행 경로를 우회하는 수단으로 쓰인다. 전제조건 위반은 정상 경로를 아예 벗어나야 하는 버그고 비지역 처리는 정상 경로를 건너뛰어 멀리 있는 처리기로 점프하는 지름길이다. 어느 쪽도 호출자가 값으로 받아 그 자리에서 분기할 예상된 실패가 아니다.

문제는 예상된 실패다. 호출자가 반드시 마주치고 처리해야 하는 흔한 실패까지 예외로 던지면 어떻게 되나. Java는 그 답으로 checked exception을 내놨다.

checked exceptions는 왜 답이 아니었나

Kotlin은 checked exception을 의도적으로 채택하지 않았다. 예상된 실패를 타입 시스템에 실어 강제하는 장치를 Java가 먼저 시도했고 Kotlin은 그 결과를 보고 따르지 않기로 했다.

checked exception은 메서드가 던질 수 있는 예외를 시그니처에 선언하게 하고 호출자에게 처리를 강제한다. 의도는 좋았다. 실패를 놓치지 못하도록 컴파일러가 붙잡아 준다. 그런데 막상 규모 있게 쓰면 두 지점에서 걸린다.

첫째는 장황함이다. 예외를 던질 수 있는 모든 호출 지점에 try-catch를 두르거나 자신의 시그니처에 다시 throws를 얹어야 한다. 실패를 처리할 의사가 없는 중간 계층까지 선언을 떠안고 그대로 위로 넘긴다.

둘째는 합성이다. map이나 filter 같은 고차 함수에 넘긴 람다가 checked exception을 던지면 그 예외를 어디서 어떻게 잡을지가 함수 경계에서 막힌다. Roman Elizarov는 이 지점을 이렇게 정리했다.

Checked exceptions simply don’t mix well with functional abstractions, they don’t compose well with higher-order functions.

checked exception은 함수형 추상화와 잘 섞이지 않고 고차 함수와 잘 합성되지 않는다.

그는 같은 글에서 한발 더 나아가 checked exception 자체에 사망 선고를 내린다.

Checked exceptions are now widely accepted to be a language design dead-end.

checked exception이 언어 설계의 막다른 길이라는 데 이제 폭넓은 공감대가 있다.

Kotlin은 이 결론을 출발점으로 삼아 unchecked 예외만 두었다. KEEP-0441도 “checked exceptions”를 Kotlin에 도입하는 것을 명시적 non-goal로 못박는다. 예상된 실패를 시그니처로 강제하는 방식으로는 돌아가지 않겠다는 선언이다.

여기서 한 칸이 빈다. 예상된 실패를 예외로 강제하지 않기로 했다면 그 실패는 예외가 아닌 값으로 표현되어야 한다. 그런데 Kotlin은 그 값을 담을 언어 구문을 따로 마련하지 않았다. checked exception을 버린 자리를 무엇으로도 채우지 않은 셈이다. 그 대가로 에러 전용 구문의 공백이 남았다.

null 반환의 한계

null 반환은 가장 값싼 에러 표현이고, 그래서 가장 먼저 무너진다.

값싸다는 건 관용구로 굳었다는 뜻이다. 문자열을 정수로 바꿀 때 toIntOrNull을 쓰면 파싱에 실패했을 때 예외 대신 null이 돌아온다. 호출자는 ?:로 기본값을 물리거나 ?.let으로 다음 단계를 잇는다. 실패를 표현하려고 새 타입을 세울 일도 import를 더할 일도 없다. 물음표 하나가 실패의 자리를 대신한다.

편의가 끝나는 지점은 그 null이 무엇을 뜻하는지 되물어야 할 때다. firstOrNull을 보자.

fun main() {
    val items: List<String?> = listOf(null, "a")
    val first = items.firstOrNull()
    println(first) // null. 리스트가 비어서가 아니라 첫 원소가 null이라서다
}

이 리스트는 비어 있지 않다. 그런데 firstOrNull()은 null을 돌려준다. 첫 원소가 null이기 때문이다. 리스트가 비어서 나온 null과 첫 원소가 null이라 나온 null이 같은 값으로 포개진다. 반환된 null만 봐서는 둘을 가를 수 없다. KEEP-0441도 이 함수가 리스트가 비었을 때 null을 반환하지만 첫 원소가 null일 때도 null을 반환한다고 짚는다.

도입부의 결제 체인이 같은 병이었다. fetch()?.charge()가 null을 내면 사용자를 못 찾은 실패인지 결제가 실패한 것인지 결과만으로는 구분할 수 없었다. firstOrNull은 한 함수 안에서 그 손실을 낸다. 안전 호출 체인은 같은 손실을 여러 함수에 걸쳐 편다. null은 실패를 한 비트로 압축하면서 원인과 지점을 함께 버린다. 부재만 알리면 되는 자리에서는 이 압축이 이득이지만 원인을 나눠야 하는 자리에서는 곧장 한계가 된다.

sealed 계층의 한계

sealed 계층은 exhaustiveness를 사지만 장황함으로 값을 치른다.

원인을 구분해야 할 때 첫 대안은 실패를 타입으로 세우는 것이다. 성공과 실패의 종류를 sealed 계층에 나열하면 컴파일러가 그 목록을 안다.

sealed interface ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>
    data object NetworkError : ApiResult<Nothing>
    data object Unauthorized : ApiResult<Nothing>
    data class ValidationError(val reason: String) : ApiResult<Nothing>
}

fun main() {
    val result: ApiResult<String> = ApiResult.Success("ok")
    when (result) {
        is ApiResult.Success -> println(result.data)
        ApiResult.NetworkError -> println("network")
        ApiResult.Unauthorized -> println("auth")
        is ApiResult.ValidationError -> println(result.reason)
    }
}

when은 네 갈래를 모두 다루므로 else 가지가 없다. 여기에 다섯 번째 실패 종류를 더하면 같은 when이 컴파일되지 않는다. 새 가지를 처리하기 전까지 컴파일러가 막아 세운다. 실패를 빠뜨릴 수 없게 만드는 이 성질이 sealed 계층이 사는 값이다. 우연히 생긴 성질도 아니어서 KEEP-0441은 when 식을 exhaustive하게 만드는 작업(KT-12380)을 JetBrains가 의도적으로 진행했다고 밝힌다.

값을 치르는 쪽은 두 군데다. 첫째는 래퍼다. 실패만 타입이면 될 것 같지만 성공까지 Success로 감싸야 계층이 닫힌다. Success(data)data에 아무 정보도 보태지 않는다. 성공값을 꺼내려면 한 겹을 벗기는 절차가 매번 붙는다. 둘째는 반복이다. 결과를 돌려주는 도메인마다 같은 모양의 계층을 다시 정의한다. ApiResult가 있어도 결제 계층에는 결제용 sealed를, 조회 계층에는 조회용 sealed를 또 세운다.

sealed 값은 받는 즉시 처리하라고 요구하기도 한다. when으로 풀어 각 가지를 다루기 전에는 안에 든 성공값에 손댈 수 없다. null이 ?.로 실패를 흘려보내며 체인을 잇던 것과 달리 sealed는 매 단계에서 분기를 강제한다. 안전한 대신 흐름이 끊긴다. 분기를 미루는 길이 없지는 않다. 래퍼 위에서 도는 map이나 fold 같은 연산자를 직접 만들면 되는데 그 연산자들을 갖추는 부담이 다시 계층마다 얹힌다.

kotlin.Result의 태생적 제약

kotlin.Result는 범용 에러 타입으로 설계된 적이 없다.

sealed 계층을 도메인마다 새로 세우는 일이 번거로우면 표준 라이브러리의 Result로 눈을 돌리게 된다. 이름부터 결과와 실패를 함께 담는다고 말한다. 그런데 이 타입의 출신을 보면 용도가 좁다. kotlin.Result는 Kotlin 1.3에서 Continuation<T> 콜백 인터페이스에 쓰려고 도입됐다. 코루틴이 중단 지점에서 성공값이나 예외를 하나로 실어 나를 그릇이 필요했고 그 자리를 채우려고 만든 타입이다.

출신이 남긴 제약이 세 가지다. 타입 파라미터가 하나뿐이라 실패 쪽은 타입으로 구분하지 못한다. 실패는 언제나 Throwable이고 어떤 실패인지는 그 Throwable의 실제 클래스를 실행 시점에 들여다봐야 안다. 이건 의도된 제한이라 늘릴 수도 없다. Result는 sealed 클래스도 아니다. 그래서 when으로 풀 때 성공 가지와 실패 가지가 스마트캐스트로 갈리지 않는다.

세 제약이 코드에서 어떻게 드러나는지는 Result를 체인으로 엮어 보면 나온다.

class NetworkException : Exception()
class ParsingException : Exception()

data class RawUser(val payload: String)
data class ParsedUser(val name: String)

fun fetchUser(): Result<RawUser> = Result.failure(NetworkException())
fun RawUser.parse(): Result<ParsedUser> = Result.failure(ParsingException())

fun getUser(): Result<ParsedUser> {
    val raw = fetchUser().getOrElse { return Result.failure(it) }
    val user = raw.parse().getOrElse { return Result.failure(it) }
    return Result.success(user)
}

fun main() {
    getUser()
        .onSuccess { println(it.name) }
        .onFailure { error ->
            when (error) {
                is NetworkException -> println("사용자 조회 실패")
                is ParsingException -> println("응답 파싱 실패")
                else -> println("처리되지 않은 오류")
            }
        }
}

getUser가 하는 실제 일은 사용자를 가져오고 파싱한 뒤 결과에 따라 분기하는 것이다. 그런데 코드 대부분은 getOrElse로 실패를 꺼내 다시 Result.failure로 감싸고 onSuccessonFailure로 가지를 나누는 연산자가 차지한다. 실패 타입을 구분하려면 onFailure 안에서 when으로 Throwable의 클래스를 다시 검사해야 한다. sealed였다면 컴파일러가 강제했을 분기를 여기서는 손으로 되짚는다. 표준에 들어 있다는 편의는 크지만 도메인 에러를 실어 나르는 그릇으로는 처음부터 맞지 않았다.

Either와 자작 Result의 난립

표준이 비면 생태계가 채운다. 문제는 제각각 채운다는 것이다.

kotlin.Result가 도메인 에러에 맞지 않으니 실패의 종류를 타입으로 다루려는 코드는 다른 데서 답을 찾는다. Arrow 라이브러리의 Either가 대표적이다. Either<실패, 성공>은 타입 파라미터가 둘이라 Result가 못 하던 실패 타입 구분을 해내고 함수형 연산자도 갖췄다. 표준이 비운 자리에 서드파티가 들어선 모양새다.

빈자리를 메우는 게 서드파티만은 아니다. KEEP-0441은 JetBrains 자신의 코드베이스에도 자작 결과 타입이 흔하다고 인정한다. Kotlin 저장소와 IntelliJ 저장소 양쪽에서 각자 만든 결과 타입이 링크와 함께 제시된다. 언어를 만드는 팀조차 표준 대신 프로젝트마다 자기 타입을 세운다는 뜻이다.

이 난립은 두 가지를 함께 말한다. 하나는 수요다. 회복 가능한 실패를 값으로 다루려는 요구가 넓게 깔려 있으니 저마다 타입을 만든다. 다른 하나는 부재다. 표준이 그 요구를 받아냈다면 제각각 만들 이유가 없다. Arrow의 Either, JetBrains의 자작 타입들, 앞서 본 sealed 계층은 같은 공백을 저마다 다르게 메운 결과이고 그래서 어느 하나가 정답으로 굳지 못한다.

진단 요약과 다음 편

진단을 요약하면 이렇다. Kotlin에는 회복 가능한 실패를 함수 시그니처에 선언할 언어 차원의 방법이 없다.

그 공백을 네 수단이 나눠 메우고 각자 다른 데서 무너진다. null은 가장 값싸게 실패를 알리는 대신 원인과 지점을 한 비트로 압축해 버린다. sealed 계층은 컴파일러가 분기 누락을 잡아 주는 대가로 성공까지 감싸는 장황함과 도메인마다 반복되는 정의를 떠안긴다. kotlin.Result는 표준에 있지만 코루틴 콜백용으로 태어나 타입 파라미터가 하나뿐이라 실패를 타입으로 못 가른다. Arrow의 Either와 자작 결과 타입들은 그 부족을 메우되 표준이 아니라서 제각각이다. 네 수단 모두 예상된 실패를 값으로 다루려 하지만 언어가 그 값을 담을 자리를 내주지 않아 우회로 남는다.

JetBrains가 준비하는 답은 이 자리를 언어 구문으로 여는 것이다. KEEP-0441이 그리는 설계는 반환 타입에 실패를 직접 얹는다.

fun load(): User | NotFound

when (val user = load()) {
    is User -> println("Hello, ${user.name}")
    is NotFound -> println("Not found!")
}

이 문법은 아직 컴파일되지 않는다. load()User 아니면 NotFound를 돌려준다고 시그니처에 적고 호출자가 when으로 두 경우를 가른다. KEEP-0441 원문에 실린 설계 예시다. 래퍼 없이 sealed 계층의 exhaustiveness를 얻으면서 Result의 표준성에 타입 구분까지 더하려는 방향이다. JetBrains는 이 공백을 언어 기능으로 메우는 rich errors를 설계 중이다. 이 글을 쓰는 2026년 7월 기준 공개 리뷰 단계(KEEP-0462, Public discussion)다.

2편에서는 null, sealed, Result, Either를 같은 실패 시나리오에 하나씩 붙여 어느 지점에서 무엇이 갈라지는지 코드로 나란히 견준다.

자주 묻는 질문

Kotlin에서 예외는 언제 쓰는 게 맞나?

회복 불가능한 상황과 비지역 처리 두 자리다. require 같은 전제조건 위반은 호출자가 회복할 의도가 없는 버그 신호고, 프레임워크가 최상위에서 일괄 처리하는 Spring의 @ExceptionHandler 패턴이 비지역 처리다. 호출자가 처리해야 하는 예상 가능한 실패는 예외 대신 타입으로 반환하는 것이 Kotlin의 관용이다.

Kotlin에는 왜 checked exception이 없나?

의도적인 설계 선택이다. checked exception은 모든 호출 지점에 try-catch나 throws 선언을 강제해 장황해지고 고차 함수와의 합성이 나쁘다는 문제가 Java에서 확인됐다. Kotlin은 unchecked 예외만 두고, 회복 가능한 실패는 null이나 sealed 타입 같은 값으로 표현하는 쪽을 택했다.

sealed class 에러 처리의 단점은 무엇인가?

장황함과 래퍼 비용이다. 컴파일러가 when 분기 누락을 잡아주는 대신 성공 케이스까지 Success 래퍼로 감싸야 하고, 계층 하나마다 같은 구조를 반복 정의하게 된다. 값을 꺼내려면 즉시 처리하거나 래퍼 전용 연산자를 따로 만들어야 하는 부담도 따라온다.

kotlin.Result는 왜 범용 에러 타입으로 부족한가?

코루틴 내부용으로 설계된 타입이라서다. Kotlin 1.3에서 Continuation 콜백에 쓰려고 도입됐고, 타입 파라미터가 하나뿐이라 실패 원인을 타입으로 구분하지 못한다. sealed가 아니어서 when 스마트캐스트가 안 되고, Throwable에 묶여 있어 도메인 에러 표현에는 맞지 않는다.


이 글 수정
이 글 공유하기:
주진현 Spring 백엔드 개발자 · The JVM Index 운영

JVM · Spring · Kotlin 생태계와 클라우드 네이티브 스택을 백엔드 개발자의 시선으로 기록합니다.


다음 글
X- 헤더는 쓰지 마라? RFC 6648을 다시 읽고 알게 된 것