이번 우테코 미션부터 매칭된 백엔드 크루와 협업을 하여 외부 API를 이용해 통신을 해야한다.
외부 API를 사용해야 하는 만큼 동기 통신을 하면 사용자 경험에 있어서 많이 느려질 것이라고 판단하기에 비동기 통신을 구현해야한다. 코틀린에서는 비동기를 구현하기 위해 세가지가 있을 것이다.
이번 미션에서는 코루틴이 제한되어 있기 때문에 콜백으로 기능을 구현해야한다.
여기서 저장소 패턴을 지키면서 에러 처리를 하는 방법을 많이 고민하였고 처음에는 onSuccess와 onFailure라는 두가지 람다를 넘겨주었었다.
하지만 이를 대체할 방법을 찾아보니 코틀린에서 1.3버전부터 기본적으로 제공하고 있는 Result<T>를 발견하게 되었고 이를 적극 활용하기 위해서 정리해보고자 한다.
처음 코틀린을 접하게 된다면 Result 클래스가 무엇인지 감이 잡히지 않을 것이라고 생각한다. 그러기 위해서는 익숙한 runCatching이라는 친구를 확인해보자. Kotlin에서는 runCatching을 최상위함수로 제공한다. 이 때 함수의 반환값으로 활용되는 것 Result<T>이다. 보통 처음 접하게 된다면 다음과 같은 형태로 볼 것이다.
fun test() {
runCatching { ... }
.onSuccess { println("success") }
.onFailure { println("failure") }
}
이 때 내부적으로 동작할 때 Result가 활용되고 인자값으로 들어오지 않고 Result로 래핑된 값을 전달하기 때문에 존재를 알기 어려웠을 것이라고 생각한다.
아래의 코드를 통해서 대략적으로 onFailure과 onSuccess가 어떻게 동작되는지를 볼 수 있다.
...
public inline fun <T> Result<T>.onFailure(action: (exception: Throwable) -> Unit): Result<T> {
...
exceptionOrNull()?.let { action(it) }
return this
}
...
public inline fun <T> Result<T>.onSuccess(action: (value: T) -> Unit): Result<T> {
...
if (isSuccess) action(value as T)
return this
}
RunCatching 처럼 성공과 실패가 일어날 수 있는 곳에서 활용 할 수 있다.
이번 미션에서도 외부 API를 활용하고 성공과 실패가 있을 수 있는 상황이기 때문에 활용될 수 있다.
CallBack의 인자 값을 Result로 받아서 성공과 실패의 분기를 처리할 수 있으며 추가로 실패시 내용까지 적을 수 있다.
해당 미션을 진행하면서 상품목록을 받아오는 Retrofit Service에 Result를 어떻게 활용하는지 확인할 수 있다.
class test {
@Test
fun testGetProducts() {
val retrofitRepository = RetrofitRepository()
retrofitRepository.getProducts { result ->
result.onSuccess { products -> println(products) }
.onFailure { throwable -> println(throwable) }
}
println("after getProducts")
sleep(1000)
}
}
class RetrofitRepository {
private val retrofitService = RetrofitClient.retrofitService
fun getProducts(callback: (Result<List<Product>>) -> Unit) {
retrofitService.getProducts().enqueue(object : Callback<List<Product>> {
override fun onResponse(call: Call<List<Product>>, response: Response<List<Product>>) {
if (response.isSuccessful) {
callback(Result.success(response.body()!!))
} else {
callback(Result.failure(Exception("response is not successful")))
}
}
override fun onFailure(call: Call<List<Product>>, t: Throwable) {
callback(Result.failure(t))
}
})
}
}
/**
@InlineOnly
@SinceKotlin("1.3")
public inline fun <R> runCatching(block: () -> R): Result<R> {
return try {
Result.success(block())
} catch (e: Throwable) {
Result.failure(e)
}
}
다음과 같은 코드를 보게 된다면 runCatching을 사용한다면 내부적으로 try catch를 하는 것을 볼 수 있다. 그리고 반환 값으로 Result<R>를 반환하고 있다.
처음에 내부 구현체를 보기 전에는 success와 failure 두개로 어떻게 나뉘는지 궁금하여 분석해보았다.
아래의 코드블록은 두 경우가 어떻게 생성되는지 볼 수 있다. 성공시에는 value가 그대로 Result에 들어가는 것을 볼 수 있다.
실패시에는 createFailure(exception)으로 Throwable을 한번 Wrapping하고 있다.
public value class Result<out T> @PublishedApi internal constructor(
@PublishedApi
internal val value: Any?
) : Serializable {
public companion object {
@Suppress("INAPPLICABLE_JVM_NAME")
@InlineOnly
@JvmName("success")
public inline fun <T> success(value: T): Result<T> =
Result(value)
@Suppress("INAPPLICABLE_JVM_NAME")
@InlineOnly
@JvmName("failure")
public inline fun <T> failure(exception: Throwable): Result<T> =
Result(createFailure(exception))
}
internal class Failure(
@JvmField
val exception: Throwable
) : Serializable {
override fun equals(other: Any?): Boolean = other is Failure && exception == other.exception
override fun hashCode(): Int = exception.hashCode()
override fun toString(): String = "Failure($exception)"
}
}
@PublishedApi
@SinceKotlin("1.3")
internal fun createFailure(exception: Throwable): Any =
Result.Failure(exception)
// value 타입이 failure일 경우 Throw
inline fun <T> Result<T>.getOrThrow(): T
// value 타입이 failure일 경우 람다 실행
inline fun <R, T : R> Result<T>.getOrElse(
onFailure: (exception: Throwable) -> R
): R
// value 타입이 failure일 경우 기본값 반환
inline fun <R, T : R> Result<T>.getOrDefault(defaultValue: R): R
// value 타입이 failure일 경우 onFailure실행, 아닐 경우 on Success 실행
inline fun <R, T> Result<T>.fold(
onSuccess: (value: T) -> R,
onFailure: (exception: Throwable) -> R
): R
// value를 인자로 받는 람다의 반환 값으로 value값 변경
inline fun <R, T> Result<T>.map(transform: (value: T) -> R): Result<R>
// failure일시 그대로 반환
// 아닐시 내부 runCatching으로 map 실행
fun <R, T> Result<T>.mapCatching(transform: (value: T) -> R): Result<R>
// failure일경우 transform 람다 실행, 아닐 경우 success 그대로 반환
inline fun <R, T : R> Result<T>.recover(transform: (exception: Throwable) -> R): Result<R>
// failure일 경우 action 실행
inline fun <T> Result<T>.onFailure(action: (exception: Throwable) -> Unit): Result<T>
// failure가 아닐 경우 action 실행
inline fun <T> Result<T>.onSuccess(action: (value: T) -> Unit): Result<T>
@SinceKotlin("1.3")
@JvmInline
public value class Result<out T> @PublishedApi internal constructor(
@PublishedApi
internal val value: Any?
) : Serializable {
// discovery
public val isSuccess: Boolean get() = value !is Failure
public val isFailure: Boolean get() = value is Failure
@InlineOnly
public inline fun getOrNull(): T? =
when {
isFailure -> null
else -> value as T
}
public fun exceptionOrNull(): Throwable? =
when (value) {
is Failure -> value.exception
else -> null
}
public override fun toString(): String =
when (value) {
is Failure -> value.toString() // "Failure($exception)"
else -> "Success($value)"
}
public companion object {
@Suppress("INAPPLICABLE_JVM_NAME")
@InlineOnly
@JvmName("success")
public inline fun <T> success(value: T): Result<T> =
Result(value)
@Suppress("INAPPLICABLE_JVM_NAME")
@InlineOnly
@JvmName("failure")
public inline fun <T> failure(exception: Throwable): Result<T> =
Result(createFailure(exception))
}
internal class Failure(
@JvmField
val exception: Throwable
) : Serializable {
override fun equals(other: Any?): Boolean = other is Failure && exception == other.exception
override fun hashCode(): Int = exception.hashCode()
override fun toString(): String = "Failure($exception)"
}
}
해당 구조를 공부하면서 코틀린에서 사용하는 문법중 쓸만한 것을 발견해서 기억하는 용도로 적어본다.
when의 객체를 넣을 때 동시에 할당을 할 수 있다. 여러번 호출하지 않고 한번만 호출하도록 할 수 있는 좋은 문법인 것같다.
public inline fun <R, T : R> Result<T>.getOrElse(onFailure: (exception: Throwable) -> R): R {
...
return when (val exception = exceptionOrNull()) {
null -> value as T
else -> onFailure(exception)
}
}