커디(Kerdy) 프로젝트에서는 서버와의 HTTP 통신을 위해 Retrofit2(이하 Retrofit), OkHttp3 라이브러리를 사용하고 있다.
Retrofit는 OkHttp3을 보다 간편하고 직관적으로 사용할 수 있도록 해준다.
목차
- 다양한 HTTP 통신 결과
- Kerdy가 거쳐온 에러 처리
- 하나하나 처리하기
- 공통 top-level 함수 활용하기
- CallAdapter 활용하기
- ApiResponse를 DataModel로 변환하기
1. 다양한 HTTP 통신 결과
memberService.getMember(1).enqueue(object : Callback<MemberApiModel> {
override fun onResponse(
call: Call<MemberApiModel>,
response: Response<MemberApiModel>,
) {
val member = response.body()
when {
response.isSuccessful && member != null -> println(member)
!response.isSuccessful -> {
when (response.code()) {
401 -> println("401 에러 발생")
404 -> println("404 에러 발생")
500 -> println("500 에러 발생")
else -> println("알 수 없는 에러 발생")
}
}
}
}
override fun onFailure(call: Call<MemberApiModel>, t: Throwable) {
println("서버 통신 실패")
}
})
Retrofit을 통해 서버와의 HTTP 통신을 거치면, 이에 대한 응답(Response) 값을 전달받는다.
이때, 통신을 비동기적으로 수행하기 위해 call.enqueue() 메서드를 호출했다고 가정해 보자.
서버와 정상적으로 통신하여 어떠한 응답을 받았다면 어떻게 될까?
enqueue()에 전달하는 Callback의 onReponse()가 호출될 것이다.
서버 응답에는 여러 가지 상황으로 이어질 수 있다.
1. 성공(200): @GET 요청을 통해 정상 응답을 받은 경우
2. 토큰 만료(401): 토큰이 만료되어 신원 확인이 어려운 경우
3. 잘못된 EndPoint(404): 올바르지 않은 EndPoint로 요청을 보낸 경우
4. 서버 문제(500): 서버에 문제가 있는 경우 등
...
반면, 서버에 도달할 수 없는 상황(비행기 모드, Wifi 연결 끊김 등)에서는 call.enqueue()에 전달한 Callback의 onFailure() 메서드가 호출된다.
코루틴을 사용하는 경우라면, suspend 키워드를 활용하여 Call 대신 Response를 반환한다.
suspend 함수는 Retrofit 내부적으로 enqueue()를 호출하며, 통신이 정상적으로 이루어진 경우에만 값을 받아온다.
그렇지 않은 경우 예외가 발생하므로 적절한 예외 처리가 필요하다.
2. Kerdy가 거쳐온 에러 처리
서비스 사용 도중에 앱이 강제 종료되는 것은 개발 상황이 아니라면, 사용자에게는 좋지 않은 경험을 제공하게 된다.
그래서 이러한 상황을 방지하기 위해 다양한 상황에 대한 처리가 필요하다.
커디 안드로이드 팀은 Retrofit 응답을 더 효율적으로 처리할 수 있을지 고민해왔다.
1. 하나하나 처리하기
가장 간단하면서도 단순한 방법이다.
모든 요청에 대해 응답을 일일이 처리해주었다.
그러기 위해서 RunCatching과 Sealed 클래스를 활용하였다.
sealed interface ApiResult<T : Any>
// 성공적으로 응답을 가져온 경우, 값과 헤더를 포함한다.
class ApiSuccess<T : Any>(val data: T, val header: Headers = Headers.headersOf()) : ApiResult<T>
// 서버와 통신은 하였지만 응답 코드가 200대가 아닌 경우, Response line 정보를 포함한다.
class ApiError<T : Any>(val code: Int, val message: String?) : ApiResult<T>
// 서버와 아예 통신조차 하지 못한 경우, 예외를 포함한다.
class ApiException<T : Any>(val e: Throwable) : ApiResult<T>
// 알 수 없는 오류는 기타로 분류한다.
class UnknownException<T : Any>(val e: Throwable) : ApiResult<T>
각 응답 상황별로 ApiResult를 상속하는 4개의 클래스를 정의해 주었다.
ApiResult의 자식 타입을 통해 ViewModel에서 상황별로 대응할 수 있다.
class MemberRepository(
private val memberService: MemberService,
) {
suspend fun getMember(): ApiResult<Member> = runCatching {
memberService.getMember(1)
}.fold(
onSuccess = { response ->
val responseBody = response.body()
when {
// 서버에서 정상적으로 응답이 왔을 때
response.isSuccessful && responseBody != null -> Success(responseBody.toData())
// 서버에서 응답이 왔지만, 200대 응답 코드가 아닌 경우
else -> Failure(response.code(), response.message())
}
},
// 200대 응답 코드가 아니거나, 서버와 통신을 실패한 경우
onFailure = { error ->
when (error) {
// 서버와 통신을 실패한 경우
is IOException -> NetworkError(error)
// 그 외의 경우
else -> UnknownError(error)
}
},
)
}
memberService.getMember()를 통해 Response를 반환한다.
runCatching을 사용하였기 때문에,
만약 예외가 발생하지 않았다면 onSuccess가 호출되고, 예외가 발생하면 onFailure가 호출될 것이다.
위 코드의 흐름을 크게 4가지로 정리하면 아래와 같다.
1. 서버에서 정상적으로 값을 가져왔다면 Success를 반환한다.
2. 200대 응답 코드가 아니거나, body가 비었다면 Failure를 반환한다.
3. 서버와 아예 통신조차 하지 못했다면 NetworkError를 반환한다.
4. 그 외에 예상할 수 없는 에러는 UnknownError를 반환한다.
class MemberViewModel(
private val memberRepository: MemberRepository,
) {
suspend fun getMember() {
val result = memberRepository.getMember()
when (result) {
is Success -> // 화면에 유저 정보를 보여준다.
is Failure -> handleError(result.code, result.message) // 화면에 에러 메시지를 보여준다.
is NetworkError -> // 화면에 인터넷 연결 상태를 체크하라는 메시지를 보여준다.
is UnknownError -> // 화면에 관리자 문의 요청 뷰를 보여준다.
}
}
private fun handleError(code: Int, message: String?) {
when (code) {
401 -> // 로그인 화면으로 이동한다.
404 -> // 화면에 유저 정보가 없다는 메시지를 보여준다.
500 -> // 화면에 관리자 문의 요청 뷰를 보여준다.
else -> // 화면에 에러 메시지를 보여준다.
}
}
}
이러한 타입들을 ViewModel에서는 위와 같이 다룰 수 있다.
각 상황에 대한 데이터들을 모두 가지고 있기 때문에 적절히 핸들링할 수 있다.
2. 공통 top-level 함수 활용하기
하지만 이 방식에 큰 문제점을 느끼기 시작했다.
보일러 플레이트가 지나치게 많이 발생한다는 점이다.
어떠한 통신을 하던지 간에 runCatching부터, ApiResult 타입으로 변환해야 한다는 사실에 벌써부터 머리가 지끈거렸다.
모두 동일한 형식으로 변환하는 것인데 각각 따로 존재해야 할 필요가 있을까?
우리가 내린 결론은 No! 였다.
1. ApiResult 변환 로직이 변경된다면, 모든 Repository의 코드를 수정해야 한다는 점.
2. 모든 서버 통신 코드에 동일한 코드가 흩뿌려져 있는 점.
위 두 가지는 개발에 피로감을 줄뿐더러, 유지보수에도 취약하다고 판단하였다.
따라서 공통 코드를 하나의 최상위 함수(Top-level)로 만드는 방법을 시도했다.
suspend inline fun <T : Any, reified V : Any> handleApi(
execute: suspend () -> Response<T>,
mapToDomain: suspend (T) -> V,
): ApiResult<V> = runCatching {
execute()
}.fold(
onSuccess = { response ->
val body = response.body()
val headers = response.headers()
when {
// 서버에서 정상적으로 응답이 왔을 때 (Unit 타입인 경우 고려)
response.isSuccessful && body == null && V::class == Unit::class -> Success(
Unit as V,
headers,
)
// 서버에서 정상적으로 응답이 왔을 때 (Unit 타입이 아닌 경우 고려)
response.isSuccessful && body != null -> Success(mapToDomain(body), headers)
// 서버에서 응답이 왔지만, 200대 응답 코드가 아닌 경우
else -> Failure(code = response.code(), message = response.message())
}
},
onFailure = { error ->
when (error) {
// 서버와 통신을 실패한 경우
is HttpException -> Failure(code = error.code(), message = error.message())
// 서버와 통신을 실패한 경우
is UnknownHostException -> NetworkError(error)
// 그 외의 경우
else -> UnknownError(error)
}
},
)
기존 코드를 어디에서나 동일하게 사용할 수 있도록 handleApi() 함수를 구현하였다.
다만, 기존 코드와 조금 다른 점이 있다.
이전에는 response.body()가 null이라면 에러로 분리하였다.
하지만, 이제는 공통적으로 함수를 사용하기 때문에 반환 타입이 Unit인 경우를 고려해야 한다.
따라서 서버에서 정상적으로 응답을 받았으며, 응답 타입이 Unit이라면 성공으로 분류하는 로직이 추가되었다.
3. CallAdapter 활용하기
여기서 만족할 수 있는 수준이라면 너무나도 좋겠지만 현실은 그렇지 않았다.
아래 코드를 함께 살펴보자.
interface ScrappedEventService {
@GET("scraps")
suspend fun getScrappedEvents(): Response<List<ScrappedEventResponse>>
@POST("scraps")
suspend fun scrapEvent(
@Body scrappedEventRequestBody: ScrappedEventRequestBody,
): Response<Unit>
@DELETE("scraps")
suspend fun deleteScrap(
@Query("event-id") eventId: Long,
): Response<Unit>
}
class ScrappedEventRepositoryImpl(
private val scrappedEventService: ScrappedEventService,
) : ScrappedEventRepository {
override suspend fun getScrappedEvents(): ApiResult<List<ScrappedEvent>> {
return handleApi(
execute = { scrappedEventService.getScrappedEvents() },
mapToDomain = List<ScrappedEventApiModel>::toData,
)
}
override suspend fun scrapEvent(eventId: Long): ApiResult<Unit> {
val scrappedEventRequestBody = ScrappedEventRequestBody(eventId)
return handleApi(
execute = { scrappedEventService.scrapEvent(scrappedEventRequestBody) },
mapToDomain = {},
)
}
override suspend fun deleteScrap(eventId: Long): ApiResult<Unit> {
return handleApi(
execute = { scrappedEventService.deleteScrap(eventId) },
mapToDomain = {},
)
}
}
handleApi 함수를 통해 기존 코드를 간략하게 줄일 수는 있었다.
하지만 위 코드를 보다시피 매번 handleApi를 호출해주어야 한다는 점은 우리를 100% 만족시킬 수 없었다.
어떻게 하면 개선을 할 수 있을까 리서치하는 도중 CallAdapter라는 한 줄기 빛을 발견하였다.
CallAdapter는 우리가 호출하는 요청에 대해 Custom Call을 반환해 줄 수 있도록 Retrofit에서 제공해 주는 클래스이다.
(CallAdapter을 학습한 내용은 포스팅으로 다룰 예정이다.)
즉, 개발자가 매번 handleApi를 직접 호출하지 않고, 요청을 응답받을 때 원하는 형태로 변환하는 것이다.
class KerdyCall<T : Any>(private val call: Call<T>, private val responseType: Type) :
Call<ApiResult<T>> {
override fun enqueue(callback: Callback<ApiResult<T>>) {
call.enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
val responseBody = response.body()
return when {
responseType == Unit::class.java -> callback.onResponse(
this@KerdyCall,
Response.success(Success(Unit as T)),
)
responseBody == null -> callback.onResponse(
this@KerdyCall,
Response.success(
UnknownError(IllegalStateException("Response Body가 존재하지 않습니다."))
),
)
else -> callback.onResponse(
this@KerdyCall,
Response.success(Success(responseBody))
)
}
} else {
callback.onResponse(
this@KerdyCall,
Response.success(
Failure(response.code(), response.errorBody()?.string()),
),
)
}
}
override fun onFailure(call: Call<T>, error: Throwable) {
val response = when (error) {
is IOException -> NetworkError<T>(error)
else -> UnknownError(error)
}
callback.onResponse(this@KerdyCall, Response.success(response))
}
})
}
...
}
위에서 만든 KerdyCall 객체를 CallAdapter의 adapt() 메서드 반환으로 제공해 주면 된다.
또 하나의 특징으로, CallAdapter의 타입 파라미터를 확인해 보면 ApiResult이다.
이것이 무엇을 의미하는지는 아래 코드를 보면 알 수 있다.
interface ScrappedEventService {
@GET("/scraps")
suspend fun getScrappedEvents(): ApiResult<List<ScrappedEventResponse>>
@POST("/scraps")
suspend fun scrapEvent(
@Body scrappedEventRequestBody: ScrappedEventRequestBody,
): ApiResult<Unit>
@DELETE("/scraps")
suspend fun deleteScrap(
@Query("event-id") eventId: Long,
): ApiResult<Unit>
}
class ScrappedEventRepositoryImpl(
private val scrappedEventService: ScrappedEventService,
) : ScrappedEventRepository {
override suspend fun getScrappedEvents(): ApiResult<List<ScrappedEventResponse>> =
scrappedEventService.getScrappedEvents()
override suspend fun scrapEvent(eventId: Long): ApiResult<Unit> =
scrappedEventService.scrapEvent(eventId)
override suspend fun deleteScrap(eventId: Long): ApiResult<Unit> =
scrappedEventService.deleteScrap(eventId)
}
handleApi의 로직을 KerdyCall에서 처리해주고 있기 때문에,
interface의 메서드의 반환 타입을 보면 ApiResult임을 확인할 수 있다.
또한, Repository에서도 handleApi를 일일이 호출해 줄 필요가 없어졌다.
기존 방식에 비해 매우 세련된 방식이라고 생각한다.
다만, Repository에서 Remote DTO를 그대로 반환하고 있는 점은 아직 불편하다.
이 문제에 대해서는 아직 고민 중에 있다.
+) 2023.09.11 : DTO를 Data Model로 변환하는 로직 추가
ApiResponse를 DataModel로 변환하기
CallAdapter를 활용하여 ApiResult로 받아오는 것까지는 좋은 시도였다.
다만, ApiResult<ApiResponse> -> ApiResult<DataModel> 로 변환하는 로직이 필요하기 때문에,
기존의 보일러 플레이트 코드를 지우지 못하는 느낌이었다.
override suspend fun getEventDetail(eventId: Long): ApiResult<EventDetail> {
val result = eventDetailService.getEventDetail(eventId)
return when(result) {
is Success -> Success(result.map { it.toData() })
is Failure -> Failure
...
}
매번 변환 로직을 작성해주면서, 또 다시 Repository(or DataSource)가 비대해지기 시작했다.
이러한 공통 로직을 처리하기 위한 방법을 떠올려 보았다.
sealed class ApiResult<out T : Any> {
fun <R : Any> map(transform: (T) -> R): ApiResult<R> = when (this) {
is Success -> Success(transform(data))
is Failure -> Failure(responseCode, message)
is NetworkError -> NetworkError
is Unexpected -> Unexpected(error)
}
}
data class Success<T : Any>(val data: T) : ApiResult<T>()
data class Failure(val responseCode: Int, val message: String?) : ApiResult<Nothing>()
object NetworkError : ApiResult<Nothing>()
data class Unexpected(val error: Throwable?) : ApiResult<Nothing>()
ApiResult에 map이라는 변환 로직을 두어 공통 코드를 제거하였다.
성공인 경우에만 DTO <-> DataModel 사이의 mapper 함수를 전달하여 변환한다.
override suspend fun getEventDetail(eventId: Long): ApiResult<EventDetail> = eventDetailService
.getEventDetail(eventId)
.map(EventDetailApiModel::toData)
그 결과 코드를 위와 같이 깔끔하게 만들 수 있었다.
아래는 실제 Kerdy에서 CallAdapter를 적용하는 방법을 문서화 해둔 링크이다.
지금이 최선일까?
지금까지 커디(Kerdy)에서 서버 통신 응답을 어떻게 다루어왔는지 알아보았다.
기존 코드의 불편함을 인식하고 비로소 코드를 수정하는 방식이야 말로 오래 기억에 남는 것 같다.
하지만, 분명 이 외에도 더 효율적이고 우아한 방법이 있을 수도 있다.
지금까지 그래왔듯이 당장은 코드에 문제를 느끼지 못하지만, 늘 어느 순간이 되면 문제점을 발견하게 된다.
그때마다 코드를 수정하고, 더 나은 방법을 찾아보는 것이 개발자의 삶이라고 생각한다.
'Android' 카테고리의 다른 글
[Android] OkHttp & Retrofit (4) | 2023.06.06 |
---|---|
[Android] BroadcastReceiver 보안 이슈 (1) | 2023.05.17 |
[Android] API 33 onBackPressed() deprecated (0) | 2023.05.08 |
[Android] PendingIntent 공식문서 파헤치기 (0) | 2023.05.02 |
[Android] RecyclerView Animation (LayoutAnimation, ItemAnimator) (0) | 2023.04.28 |