
코틀린 Result 타입은 실패를 없애는 도구가 아니라, 실패를 반환값으로 드러내는 도구에 가깝습니다. 그래서 이 글에서는 예외를 던지는 방식과 무엇이 다른지, 어떤 경계에서 더 읽기 쉬운지 실무 기준으로 차분하게 정리합니다.
먼저 핵심부터 말하면 호출자에게 실패 가능성을 타입 차원에서 분명히 보여주고 싶을 때는 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 경계에서는 읽기 좋아지는 경우가 많습니다.

runCatching은 왜 편하고 왜 조심해야 할까
공식 문서 기준으로 runCatching은 블록을 실행하고, 블록에서 던져진 Throwable을 잡아 failure Result로 감쌉니다. 짧은 파싱, 파일 읽기, 네트워크 결과 감싸기에서는 정말 편합니다.
fun readConfigPort(config: Map<String, String>): Result<Int> {
return runCatching {
config.getValue("port").toInt()
}
}다만 runCatching은 Exception만이 아니라 Throwable을 잡습니다. 그래서 범위를 너무 넓게 감싸면 입력 오류, 외부 시스템 실패, 취소 흐름, 프로그래밍 오류가 한 덩어리처럼 보일 수 있습니다.
- 짧고 의미가 분명한 실패 단위로 감싼다.
- 실패 원인이 중요한 경계에서는 도메인 오류 타입이나 메시지를 정리한다.
- 모든 함수를 무조건 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")
}이런 자리는 실패를 값으로 돌려주기보다, 여기까지 오면 안 된다는 사실을 빨리 드러내는 편이 더 좋을 수 있습니다.
실수하기 쉬운 포인트
- 모든 함수를 무조건 Result로 감싸는 것
- 너무 넓은 범위를 runCatching으로 감싸는 것
- 실패 원인을 분류하지 않고 Throwable만 그대로 흘리는 것
- Result를 쓰면 에러 설계가 자동으로 좋아진다고 기대하는 것
Result는 설계를 대신해주지 않습니다. 다만 실패를 더 드러내기 쉬운 형태로 바꿔줄 뿐입니다.
한 번에 기억하는 선택 기준
- 호출자가 실패를 명시적으로 분기해야 하는가?
- 실패가 예상 가능한 비즈니스/입력/외부 시스템 실패인가?
- 반환 타입만 보고도 실패 가능성을 드러내고 싶은가?
- 반대로 이 실패는 버그이거나 즉시 중단되어야 하는가?
앞의 세 질문이 예라면 Result 쪽이 더 잘 맞을 가능성이 큽니다. 마지막 질문이 예라면 throw가 더 자연스러울 수 있습니다.
함께 보면 좋은 글과 공식 문서
값 중심 모델과 copy 감각이 함께 궁금하다면 코틀린 data class: equals, copy, destructuring 실무 정리도 같이 읽어보면 좋습니다.
공식 기준은 Kotlin Result API 문서와 runCatching API 문서를 참고하면 됩니다.
결론: Result는 예외를 없애는 도구가 아니라 실패를 드러내는 도구다
코틀린 Result 타입은 예외의 대체재라기보다, 호출자에게 실패 가능성과 처리 책임을 더 선명하게 보여주는 선택지에 가깝습니다. 그래서 실패를 값으로 드러내고 싶을 때는 Result를, 빨리 터뜨려야 하는 오류에는 throw를 고르는 균형 감각이 중요합니다.