|

코틀린 Result 타입 쉽게 이해하기

코틀린 Result 타입 대표 이미지
Result와 throw의 선택 기준을 실무 예시로 정리

코틀린 Result 타입은 실패를 없애는 도구가 아니라, 실패를 반환값으로 드러내는 도구에 가깝습니다. 그래서 이 글에서는 예외를 던지는 방식과 무엇이 다른지, 어떤 경계에서 더 읽기 쉬운지 실무 기준으로 차분하게 정리합니다.

먼저 핵심부터 말하면 호출자에게 실패 가능성을 타입 차원에서 분명히 보여주고 싶을 때는 Result가 잘 맞고, 복구 불가능한 오류나 전역 예외 정책이 더 자연스러운 자리에서는 throw가 더 잘 맞습니다.

Result와 throw 비교 카드
핵심 카드: 흐름, 장점, 잘 맞는 장면, 주의점

코틀린 Result 타입을 한 줄로 이해하기

Kotlin 공식 API 문서는 Result를 성공 값 또는 Throwable 실패를 캡슐화하는 discriminated union으로 설명합니다. 쉽게 말하면 성공이면 값이 들어 있고, 실패면 예외가 들어 있으며, 호출자는 이 둘을 반환값 차원에서 읽고 분기할 수 있습니다.

fun parsePort(text: String): Result<Int> {
    return runCatching { text.toInt() }
}

이 함수는 성공하면 Int를, 실패하면 예외를 바깥으로 던지는 대신 Result 안에 넣어 돌려줍니다. 그래서 반환 타입만 봐도 실패 가능성을 읽을 수 있습니다.


Result에서 자주 쓰는 API

  • isSuccess / isFailure: 성공인지 실패인지 빠르게 확인할 때 씁니다.
  • getOrNull() / exceptionOrNull(): 성공 값이나 실패 예외를 읽을 때 씁니다.
  • getOrThrow(): 실패면 안의 예외를 다시 던집니다.
  • fold(): 성공과 실패를 한 자리에서 분기하기 좋습니다.
  • onSuccess / onFailure: 부수 효과를 붙일 때 읽기 좋습니다.
  • map / recover 계열: 성공 값 변환이나 실패 복구 흐름을 붙일 수 있습니다.
val ok = runCatching { "8080".toInt() }
println(ok.isSuccess)
println(ok.getOrNull())

val fail = runCatching { "abc".toInt() }
println(fail.isFailure)
println(fail.exceptionOrNull())

이 구조의 장점은 try-catch를 열지 않아도 호출자가 실패를 명시적으로 다룰 수 있다는 데 있습니다.


throw와 Result는 무엇이 다를까

throw는 예외 흐름으로 탈출시킨다

fun loadUserName(id: Long): String {
    if (id <= 0) throw IllegalArgumentException("id must be positive")
    return "BS"
}

throw 방식은 간결합니다. 하지만 실패가 타입에 드러나지 않기 때문에 호출자가 try-catch를 열어야 하는지, 상위 전역 처리에 맡겨야 하는지 문맥을 더 따라가야 할 때가 있습니다.

Result는 실패를 반환값으로 드러낸다

fun loadUserName(id: Long): Result<String> {
    if (id <= 0) {
        return Result.failure(IllegalArgumentException("id must be positive"))
    }
    return Result.success("BS")
}
val result = loadUserName(0)

result.fold(
    onSuccess = { name -> println(name) },
    onFailure = { error -> println("입력 오류: ${error.message}") },
)

Result는 호출자에게 성공과 실패 분기를 더 노골적으로 드러냅니다. 그래서 use case, repository, ViewModel 경계에서는 읽기 좋아지는 경우가 많습니다.

throw와 Result 흐름 비교 다이어그램
예외 흐름과 반환값 흐름 비교

runCatching은 왜 편하고 왜 조심해야 할까

공식 문서 기준으로 runCatching은 블록을 실행하고, 블록에서 던져진 Throwable을 잡아 failure Result로 감쌉니다. 짧은 파싱, 파일 읽기, 네트워크 결과 감싸기에서는 정말 편합니다.

fun readConfigPort(config: Map<String, String>): Result<Int> {
    return runCatching {
        config.getValue("port").toInt()
    }
}

다만 runCatching은 Exception만이 아니라 Throwable을 잡습니다. 그래서 범위를 너무 넓게 감싸면 입력 오류, 외부 시스템 실패, 취소 흐름, 프로그래밍 오류가 한 덩어리처럼 보일 수 있습니다.

  1. 짧고 의미가 분명한 실패 단위로 감싼다.
  2. 실패 원인이 중요한 경계에서는 도메인 오류 타입이나 메시지를 정리한다.
  3. 모든 함수를 무조건 runCatching으로 감싸지 않는다.

실무에서 Result가 잘 맞는 장면

1) 파싱과 검증 결과를 그대로 호출자에게 넘길 때

fun parseAge(input: String): Result<Int> {
    return runCatching {
        val age = input.toInt()
        require(age >= 0) { "age must be non-negative" }
        age
    }
}

2) Repository가 외부 실패를 감싸서 UseCase에 올릴 때

class UserRepository(
    private val api: UserApi,
) {
    fun fetchUserName(id: Long): Result<String> = runCatching {
        api.fetchUser(id).name
    }
}

3) ViewModel에서 fold로 UI 상태를 만들 때

val state = repository.fetchUserName(id).fold(
    onSuccess = { name -> UiState(content = name, error = null) },
    onFailure = { error -> UiState(content = null, error = error.message) },
)

이 세 장면의 공통점은 실패가 충분히 예상 가능하고, 호출자가 그 실패를 실제로 분기 처리해야 한다는 점입니다.


Result보다 throw가 더 자연스러운 장면

  • 복구 불가능한 프로그래밍 오류처럼 빨리 터뜨리는 편이 나은 경우
  • 상위 전역 예외 처리 정책이 이미 분명한 경우
  • 모든 호출자에게 성공/실패 분기를 강요하면 오히려 소음이 커지는 내부 유틸 함수
fun requireSession(session: Session?) : Session {
    return session ?: error("Session must exist here")
}

이런 자리는 실패를 값으로 돌려주기보다, 여기까지 오면 안 된다는 사실을 빨리 드러내는 편이 더 좋을 수 있습니다.


실수하기 쉬운 포인트

  1. 모든 함수를 무조건 Result로 감싸는 것
  2. 너무 넓은 범위를 runCatching으로 감싸는 것
  3. 실패 원인을 분류하지 않고 Throwable만 그대로 흘리는 것
  4. Result를 쓰면 에러 설계가 자동으로 좋아진다고 기대하는 것

Result는 설계를 대신해주지 않습니다. 다만 실패를 더 드러내기 쉬운 형태로 바꿔줄 뿐입니다.


한 번에 기억하는 선택 기준

  1. 호출자가 실패를 명시적으로 분기해야 하는가?
  2. 실패가 예상 가능한 비즈니스/입력/외부 시스템 실패인가?
  3. 반환 타입만 보고도 실패 가능성을 드러내고 싶은가?
  4. 반대로 이 실패는 버그이거나 즉시 중단되어야 하는가?

앞의 세 질문이 예라면 Result 쪽이 더 잘 맞을 가능성이 큽니다. 마지막 질문이 예라면 throw가 더 자연스러울 수 있습니다.


함께 보면 좋은 글과 공식 문서

값 중심 모델과 copy 감각이 함께 궁금하다면 코틀린 data class: equals, copy, destructuring 실무 정리도 같이 읽어보면 좋습니다.

공식 기준은 Kotlin Result API 문서runCatching API 문서를 참고하면 됩니다.


결론: Result는 예외를 없애는 도구가 아니라 실패를 드러내는 도구다

코틀린 Result 타입은 예외의 대체재라기보다, 호출자에게 실패 가능성과 처리 책임을 더 선명하게 보여주는 선택지에 가깝습니다. 그래서 실패를 값으로 드러내고 싶을 때는 Result를, 빨리 터뜨려야 하는 오류에는 throw를 고르는 균형 감각이 중요합니다.

함께보면 좋은 글