(Kotlin) runCatching, Result
1. runCatching
runCatching은 Kotlin 1.3 표준 라이브러리에 추가된 예외 처리에 효과적인 inline function이다.
일반적으로, 안드로이드 진영에서 Coroutine과 함께 사용할 때 예외 처리에 효과적이며 Google 샘플 프로젝트에서도 runCatching을 활용한 예외 처리가 많은 만큼 Google에서 권장하는 방식이다.
runCatching의 내부 구현은 아래와 같다.
// Result#runCatching
public inline fun <R> runCatching(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: Throwable) {
Result.failure(e)
}
}
// Result#runCatching
public inline fun <T, R> T.runCatching(block: T.() -> R): Result<R> {
return try {
Result.success(block())
} catch (e: Throwable) {
Result.failure(e)
}
}
아마 runCatching을 접해보지 못했다면 대부분의 예외 처리를 try..catch로 했을 것이다.
내부 구현에서 볼 수 있듯이, runCatching은 try..catch의 예외 처리 로직을 그대로 사용하지만 Result로 캡슐화하여 반환하는 것을 알 수 있다.
즉, 지정된 함수 블록을 호출하고 해당 함수의 성공 / 실패 여부에 따라 Result<T> 형태로 반환한다.
2. runCatching과 try..catch 사례 비교
학습에 참고했던 toss 개발 블로그에서 이미 잘 정리된 사례를 설명하고 있다.
해당 블로그는 아래 Reference에서 확인할 수 있다.
사용자 로그인을 위해 LoginApi를 호출할 때 LoginException이 발생한 경우, 에러 코드가 INVALID_PASSWORD인 경우에만 예외 발생없이 null을 반환시키고 나머지 상황에서는 모두 예외를 발생시키는 요구사항이 있다고 가정하고 있다.
try..catch를 사용한 방식은 아래와 같다.
try {
loginApiClient.login(request)
} catch (e: LoginException) {
when(e.errorCode) {
is "INVALID_PASSWORD" -> return null
else -> throw e
}
}
runCatching을 사용한 방식은 아래와 같다.
return runCatching {
loginApiClient.login(request)
}.onFailure { e ->
if (e.errorCode != "INVALID_PASSWORD") throw e
}.getOrNull()
두 가지 방식은 실제로 동일한 로직을 수행한다.
runCatching으로 예외를 처리하는 방식이 try..catch 방식에 비해 조금 더 직관적이지 않나 하는 생각이 든다.
3. runCatching 기본 사용법
안드로이드 개발을 해오면서 runCatching을 처음 접해본 것은 아니다.
하지만, 지금 살펴볼 정말 기본적인 방법으로만 사용해왔다.
val execResult: Result<T> = runCatching {
// Execution
}.onSuccess {
// 성공 시
}.onFailure {
// 실패 시
}
실행시켜야 할 함수는 runCatching 블록 내부에서 실행하게 된다.
onSuccess 블록은 runCatching 블록의 실행이 성공했을 때 후 처리를 담당하고, onFailure 블록은 실행이 실패했을 때의 후 처리를 담당하게 된다.
중요한 점은 Result 객체를 그대로 반환하기 때문에 execResult 변수로 다양하게 chaining해서 사용할 수 있다는 것이다.
4. Result
Result 타입은 2가지 프로퍼티와 2가지 멤버 함수를 갖는다.
isSuccess
isSuccess는 아래와 같이 getter로 초기화 된다.
// Result.kt
public val isSuccess: Boolean get() = value !is Failure
성공적인 결과를 나타내는 경우 true를 반환한다.
if (execResult.isSuccess) { /* 성공 시 */ }
isFailure
isFailure도 마찬가지로 아래와 같이 getter로 초기화 된다.
// Result.kt
public val isFailure: Boolean get() = value is Failure
실패한 결과를 나타내는 경우에 true를 반환한다.
if (execResult.isFailure) { /* 실패 시 */ }
exceptionOrNull()
실패한 결과를 나타내는 경우에 Throwable을 반환하며, 성공인 경우에 null을 반환한다.
즉, 예외가 발생한 경우에만 해당 예외를 반환하는데, 예외가 발생하는지 아닌지 확인하고 싶을 때에 유용하게 사용할 수 있다.
// Result#exceptionOrNull
public fun exceptionOrNull(): Throwable? =
when (value) {
is Failure -> value.exception
else -> null
}
아래와 같이 사용할 수 있다.
val exception = execResult.exceptionOrNull()
when(exception) {
/* 예외가 발생한 경우 예외 타입에 맞게 분기 */
}
// 예외가 발생했는지 아닌지 확인
val isValidCredential = runCatching {
coroutineUseCase.invoke()
}.exceptionOrNull() != null
getOrNull()
실패한 결과를 나타내는 경우 null을 반환하고, 성공인 경우 캡슐화된 T를 반환한다.
이 메서드는 발생한 예외는 무시하고 null을 반환한다는 점에서 후에 null처리만 해주면 된다는 장점을 갖는다.
// Result#getOrNull
public inline fun getOrNull(): T? =
when {
isFailure -> null
else -> value as T
}
아래와 같이 사용할 수 있다.
val execResult: T? = runCatching {
coroutineUseCase.invoke() // 여기서 예외 발생 시 null 반환
}.getOrNull()
5. Result Extension
내부 코드를 살펴보면 여러가지 Extension 메서드들 있다.
getOrThrow()
성공을 나타내는 경우 캡슐화된 T를 반환, 실패인 경우 Throwable을 반환한다.
// Result.kt
public inline fun <T> Result<T>.getOrThrow(): T {
throwOnFailure()
return value as T
}
아래와 같이 사용할 수 있다.
val execResult: T = runCatching {
coroutineUseCase.invoke() // 여기서 예외 발생 시 해당 예외 반환
}.getOrThrow()
// 성공/실패 시 특정 로직 수행 후 예외 던짐
val response = runCatching {
login()
}.onSuccess {
logger.info("성공!")
}.onFailure {
logger.info("실패!")
}.getOrThrow()
getOrElse()
성공을 나타내는 경우 캡슐화된 값을 반환, 실패인 경우 Throwable에 대한 onFailure 메서드의 실행 결과 반환한다.
// Result.kt
public inline fun <R, T : R> Result<T>.getOrElse(onFailure: (exception: Throwable) -> R): R {
contract {
callsInPlace(onFailure, InvocationKind.AT_MOST_ONCE)
}
return when (val exception = exceptionOrNull()) {
null -> value as T
else -> onFailure(exception)
}
}
아래와 같이 사용할 수 있다.
// 예외 발생 시 다른 동작을 수행하고 싶다면
val response = runCatching {
executeSomething()
}.getOrElse { e ->
Log.e(TAG, "에러 발생")
// 예외를 던지고 싶다면
throw e
}
getOrDefault()
성공을 나타내는 경우 캡슐화된 값을 반환, 실패인 경우 defaultValue를 반환한다.
// Result.kt
public inline fun <R, T : R> Result<T>.getOrDefault(defaultValue: R): R {
if (isFailure) return defaultValue
return value as T
}
아래와 같이 사용할 수 있다.
val execResult: String = runCatching {
coroutineUseCase.invoke() // 여기서 예외 발생 시 defaultValue 반환
}.getOrDefault(defaultValue= "AAAA")
fold()
성공을 나타내는 경우 캡슐화된 T에 대한 onSuccess의 결과를 반환, 실패인 경우 캡슐화된 Throwable에 대한 onFailure의 결과를 반환한다.
// Result.kt
public inline fun <R, T> Result<T>.fold(
onSuccess: (value: T) -> R,
onFailure: (exception: Throwable) -> R
): R {
contract {
callsInPlace(onSuccess, InvocationKind.AT_MOST_ONCE)
callsInPlace(onFailure, InvocationKind.AT_MOST_ONCE)
}
return when (val exception = exceptionOrNull()) {
null -> onSuccess(value as T)
else -> onFailure(exception)
}
}
아래와 같이 사용할 수 있다.
val execResult: Any = runCatching {
coroutineUseCase.invoke()
}.fold(
onSuccess = { /* 성공 시 */ },
onFailure = { /* 실패 시 */ }
)
6. Result Transformation
Result Extension까지 살펴보면서 성공과 실패의 경우 어떤식으로 처리할 수 있는지 확인하였다.
이번에는 성공과 실패의 경우에 원하는 값으로 바꾸는 방법에 대해 알아보자.
map, mapCatching
map과 mapCatching 모두 성공을 나타내는 경우 transform 함수 수행에 대한 결과를 반환, 실패인 경우 원래의 캡슐화된 Throwable을 반환한다.
// Result.kt
public inline fun <R, T> Result<T>.map(transform: (value: T) -> R): Result<R> {
contract {
callsInPlace(transform, InvocationKind.AT_MOST_ONCE)
}
return when {
isSuccess -> Result.success(transform(value as T))
else -> Result(value)
}
}
public inline fun <R, T> Result<T>.mapCatching(transform: (value: T) -> R): Result<R> {
return when {
isSuccess -> runCatching { transform(value as T) }
else -> Result(value)
}
}
내부 구현을 통해서 확인할 수 있듯이, mapCatching은 map과 다르게 발생할 수 있는 예외로부터 안전하게 처리할 수 있도록 runCatching으로 감싸져 있는 것을 볼 수 있다.
아래 예시를 살펴보자.
val execResult: Int? = runCatching {
"123"
}.map { it: String ->
it.toInt()
}.getOrNull()
val execResult: Int? = runCatching {
"123"
}.mapCatching { it: String ->
it.toInt()
}.getOrNull()
위 예시는 성공인 경우 모두 동일한 값을 반환할 것이다.
하지만, 예외 처리 방식이 다르다.
map은 예외를 블록 밖으로 내보내게 된다.
try {
runCatching {
coroutineUseCase.invoke()
}.map {
throw Exception("Error Occured")
}.onSuccess {
// map에서 예외 발생 시 실행되지 않음
}.onFailure {
// map에서 예외 발생 시 실행되지 않음
}
} catch(e: Exception) {
// map에서 발생한 exception을 인자로 받아 호출
}
반면, mapCatching은 블록 안의 예외를 내부에서 처리하여 onFailure로 받을 수 있다.
그 이유는, mapCatching 자체가 발생할 수 있는 예외로부터 안전하게 처리하기 위해 runCatching으로 감싸져있기 때문이다.
runCatching {
coroutineUseCase.invoke()
}.mapCatching {
throw Exception("Error Occured")
}.onSuccess {
// mapCatching에서 예외 발생 시 실행되지 않음
}.onFailure {
// mapCatching에서 예외 발생 시 실행 됨
}
recover, recoverCatching
recover와 recoverCatching 모두 실패를 나타내는 경우 Throwable에 적용되는 transform 함수 수행에 대한 결과 값을 반환하고, 성공인 경우 원래 캡슐화된 값을 반환한다.
실패에 대한 복구를 할 때 많이 사용하며, getOrElse() 대신 사용할 수도 있다.
// Result.kt
public inline fun <R, T : R> Result<T>.recover(transform: (exception: Throwable) -> R): Result<R> {
contract {
callsInPlace(transform, InvocationKind.AT_MOST_ONCE)
}
return when (val exception = exceptionOrNull()) {
null -> this
else -> Result.success(transform(exception))
}
}
// Result.kt
public inline fun <R, T : R> Result<T>.recoverCatching(transform: (exception: Throwable) -> R): Result<R> {
return when (val exception = exceptionOrNull()) {
null -> this
else -> runCatching { transform(exception) }
}
}
위에서 살펴본 map, mapCatching과 성공/실패 케이스만 다르지 구현 방식은 동일하다.
마찬가지로 recoverCatching은 runCatching으로 감싸져있기 때문에 recover와 예외 처리 방식이 다르다는 것을 유추해볼 수 있다.
// recover 예외 처리 방식
try {
runCatching {
coroutineUseCase.invoke() // 예외 발생
}.recover { e: Throwable ->
throw Exception("recover Error")
}.onFailure {
// recover에서 발생한 예외 전달되지 않음
}
} catch(e: Exception) {
// recover에서 발생한 예외 받음
}
// recoverCatching 예외 처리 방식
runCatching {
coroutineUseCase.invoke() // 예외 발생
}.recoverCatching {
throw Exception("Error Occured")
}.onFailure {
// recoverCatching에서 예외 발생한 예외 받음
}
이제, 아래 예시를 살펴보자.
val execResult = runCatching {
coroutineUseCase.invoke()
}.mapCatching {
it.doSomething()
}.recoverCatching { e: Throwable ->
when(e) {
is IllegalStateException -> handleISE()
is NullPointerException -> handleNPE()
else -> throw e
}
}.getOrNull()
이번 포스팅을 통해 살펴본 Result의 다양한 메서드를 종합한 예시이다.
위 예시에서는 성공한 경우에만 doSomething()이 수행될 것이다.
그리고 예외가 발생할 경우 recoverCatching을 통해 발생한 예외에 맞는 특정 메서드가 수행되고, 처리하지 않는 예외에 대해서는 null로 반환할 것이다.
이번 포스팅을 통해 조금 더 유연하게 예외 처리를 할 수 있을 것 같다.
Reference
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-result/
https://toss.tech/article/kotlin-result
https://medium.com/@jcamilorada/arrow-try-is-dead-long-live-kotlin-result-5b086892a71e