배경
서버와 연결을 시도할 때(api를 호출) 실패하는 경우에 대한 액션을 처리할 필요가 있었습니다.
그 액션으로 에러가 났을 시, 토스트(Toast)를 띄우기로 했습니다
서버에서 내려오는 Response의 구조는 아래와 같습니다.
- 성공
data class ApiResponse<T>(
val data: T?,
val message: String,
val code: String
)
- 실패
data class ErrorResponse(
val message: String,
val code: String
)
code에선 어떤 에러인지에 대한 code(일반적으론 Int로 쓰이는 401, 500 등과 같은 코드입니다),
message에선 토스트로 띄울 문구가 내려옵니다.
→ 여기서 저는 성공 시 data만 내려보내고, 실패했을 땐 message를 담은 에러를 보내 토스트로 출력하고 싶었습니다.
그러나 모든 api별로 try, catch를 사용하면 보일러플레이트가 늘어나므로 이 과정은 한 곳에서만 처리하고 싶었고
따라서 CallAdapter를 이용하기로 했습니다
과정
1. 받아오는 응답을 원하는 형태로 변형 - Call
→ custom call 을 이용하여 call을 원하는 형태로 바꿉니다
성공과 실패를 담기 위해 Result를 사용했습니다.
Result는 성공 시 value를 캡슐화하고, 실패 시 Throwable(예외)를 캡슐화하는 클래스입니다.
여기서 성공 시 success 함수를 호출하고, 실패 시엔 failure 함수를 호출해 message를 담고있는 커스텀 예외 클래스를 넣을 생각입니다.
커스텀 예외 클래스는 Exception 클래스를 상속받아 제작하였습니다.
class ThreeDaysException(override val message: String, throwable: Throwable) : Exception(message, throwable) { }
아래는 커스텀한 Call의 클래스입니다.
class ResultCall<T>(private val call: Call<T>, private val retrofit: Retrofit) : Call<Result<T>> {
override fun enqueue(callback: Callback<Result<T>>) {
call.enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
if(response.body() == null) {
callback.onResponse(
this@ResultCall,
Response.success(Result.failure(ThreeDaysException("body가 비었습니다.", HttpException(response))))
)
}
else {
callback.onResponse(
this@ResultCall,
Response.success(response.code(), Result.success(response.body()!!))
)
}
} else {
if(response.errorBody() == null) {
callback.onResponse( this@ResultCall,
Response.success(Result.failure(ThreeDaysException("errorBody가 비었습니다.", HttpException(response))))
)
}
else {
val errorBody = retrofit.responseBodyConverter<ErrorResponse>(
ErrorResponse::class.java,
ErrorResponse::class.java.annotations
).convert(response.errorBody()!!)
val message: String = errorBody?.message ?: "errorBody가 비었습니다"
callback.onResponse(this@ResultCall,
Response.success(Result.failure(ThreeDaysException(message, HttpException(response))))
)
Timber.tag("ResultCall - onResponse").e("${ThreeDaysException(message, HttpException(response))}")
}
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
val message = when (t) {
is IOException -> "인터넷 연결이 끊겼습니다."
is HttpException -> "알 수 없는 오류가 발생했어요."
else -> t.localizedMessage
}
callback.onResponse(
this@ResultCall,
Response.success(Result.failure(ThreeDaysException(message, t)))
)
Timber.tag("ResultCall - onFailure").e("${ThreeDaysException(message, t)}")
}
})
}
override fun isExecuted(): Boolean {
return call.isExecuted
}
override fun execute(): Response<Result<T>> {
return Response.success(Result.success(call.execute().body()!!))
}
override fun cancel() {
call.cancel()
}
override fun isCanceled(): Boolean {
return call.isCanceled
}
override fun clone(): Call<Result<T>> {
return ResultCall(call.clone(), retrofit)
}
override fun request(): Request {
return call.request()
}
override fun timeout(): Timeout {
return call.timeout()
}
}
- onResponse : 서버에서 응답만 받아온다면 이 함수로 넘어가게 됩니다. 그러나 response가 성공일 수도, 실패일 수도 있습니다.
- onFailure : 서버에서 응답을 받아오지 못했습니다. 인터넷 연결이 끊기거나 하는 네트워크 관련 에러가 발생하면 이 함수로 넘어갑니다.
onResponse에서 response가 성공할 경우, 실패할 경우(400,500 번대의 에러)가 존재하므로
response.isSuccessful로 확인해줍니다.
만약 실패할 경우, code와 message만 내려오는데 여기서 message만 뽑아내기 위해
response.errorBody() 를 사전에 정의해둔 ErrorResopnse로 변형해줍니다.
(여기서 저는 실패해도 response.body() 에 값이 담겨오는 줄 알았는데 실패할때마다 null로 나와서 삽질을 한동안 했습니다..
알고보니 실패할땐 errorBody()에 내용이 온다는 점..)
2. CallAdapter, CallAdapterFactory
CallAdapter를 이용해 원하는 타입으로 Call을 받을 수 있습니다.
그리고 CallAdapterFactory를 통해서 CallAdapter를 얻습니다.
class ResultCallAdapterFactory: CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): CallAdapter<*, *>? {
if (getRawType(returnType) != Call::class.java || returnType !is ParameterizedType) {
return null
}
val upperBound = getParameterUpperBound(0, returnType)
return if (upperBound is ParameterizedType && upperBound.rawType == Result::class.java) {
object : CallAdapter<Any, Call<Result<*>>> {
override fun responseType(): Type = getParameterUpperBound(0, upperBound)
override fun adapt(call: Call<Any>): Call<Result<*>> =
ResultCall(call, retrofit) as Call<Result<*>>
}
} else {
null
}
}
}
3. Retrofit Builder
@Singleton
@Provides
fun providesRetrofit(
okHttpClient: OkHttpClient,
buildPropertyRepository: BuildPropertyRepository,
resultCallAdapterFactory: ResultCallAdapterFactory
): Retrofit {
return Retrofit.Builder()
.baseUrl(buildPropertyRepository.get(BuildProperty.BASE_URL))
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(resultCallAdapterFactory)
.build()
}
retrofit에 아까 만든 CallAdapterFactory를 추가해줍니다.
4. Service
@GET("/api/v1/habits/{habitId}")
suspend fun getHabit(
@Path("habitId") habitId: Long
): Result<ApiResponse<SingleHabitEntity>>
원래 ApiResponse<T> 를 응답으로 받았는데, 항상 Result로 결과를 매핑해 내려줄 것이므로 모든 응답 타입을 Result<ApiResponse<T>> 로 변경해줍니다.
5. DataSource
override suspend fun getHabit(habitId: Long): Result<SingleHabitEntity> {
return habitService.getHabit(habitId = habitId).getResult() ?: Result.failure(ThreeDaysException("데이터가 비어있습니다.", IllegalStateException()))
}
override suspend fun getHabits(status: String): Result<List<HabitEntity>> {
return habitService.getHabits(status).getResult() ?: Result.success(emptyList())
}
저는 DataSource에서는 ApiResponse 타입은 떼고 data만 받을 수 있도록 변경했습니다.
Service를 통해 받는 타입은 Result<ApiResponse<T>> 이므로 이를 Result<T> 로 변경하기 위해 확장함수를 사용했습니다.
여기서 함수의 결과값이 null인 경우 Result.failure 를 반환하거나, Result.success(디폴트값) 을 반환하도록 했습니다.
6. Result<ApiResopnse<T>> 를 Result<T> 로 변환하는 확장함수
fun <T>Result<ApiResponse<T>>.getResult(): Result<T>? {
this.onSuccess { response ->
val data = response.data ?: return null
return Result.success(data)
}.onFailure { throwable ->
return Result.failure(throwable)
}
return Result.failure(IllegalStateException("알 수 없는 오류가 발생했습니다."))
}
여기서 data가 성공임에도 null로 넘어오는 몇가지 경우가 있어서, 이는 이 함수를 호출하는 쪽에서 처리할 수 있도록 이 함수자체의 리턴값을 null로 하도록 했습니다.
7. ViewModel
habitRepository.createHabit(habit)
.onSuccess {
_action.emit(Action.SaveClick)
}.onFailure { throwable ->
throwable as ThreeDaysException
sendErrorMessage(throwable.message)
}
여기서 sendErrorMessage는 BaseViewModel에 있는 error 변수(SharedFlow)에 에러메시지 내용을 담아 방출합니다
8. Activity
viewModel.error
.onEach { errorMessage -> ThreeDaysToast().error(this, errorMessage) }
.launchIn(lifecycleScope)
위와 같이 토스트를 띄우면 됩니다.
결론
원하는 대로 응답을 받을 수 있게되었지만 응답에 대한 처리 로직을 현재 두군데(Interceptor, CallAdapter)에서 하고 있기 때문에 어떤 방식이 더 나은지를 공부하고 통합할 예정입니다.
아직 많이 부족한 코드이지만 위 과정을 잊지 않기 위해서, 그리고 추후 더 나은 코드를 짤 때 과거의 저는 어떤식으로 짰는지 확인하기 위해 작성했습니다.
참고
Retrofit — Effective error handling with Kotlin Coroutine and Result API
Centralised and effective error handling with Retrofit, Kotlin Coroutine and Kotlin result API.
blog.canopas.com
'안드로이드 > 개발' 카테고리의 다른 글
[Error] 레이아웃 에디터가 보이지 않는 에러 (1) | 2022.11.15 |
---|