본 콘텐츠는 Kotlin, Coroutine, Retrofit을 이용하는 환경에서 이루어졌습니다.
안녕하세요. Client Team 안드로이드 개발자 차준엽입니다.
다들 경험하셨다시피 앱을 제작할 때 네트워크 통신은 필수적으로 이뤄지는 경우가 많아요.
특히 앱의 절반 이상이 도메인 / 데이터로 이뤄져 있는 앱이라면 점점 더 많아지는 API, Data Model에 치여 마치 같은 코드만 작성하는 기계가 된 것 같은 기분도 들 거예요. (물론 전 그러지 않았습니다..🥕) 그러다 보니 자연스레 이런 생각을 한 것 같아요.
“비슷한 코드를 좀 더 효율적으로 구현할 수는 없을까?”
프로젝트는 계속해서 늘어만 가고, 그 수에 비례해 증가하는 API, 그리고 비대해지는 Repository, Data Model은 걷잡을 수 없었습니다.. 따라서 관리 포인트 감소, 유지 보수 비용을 줄이는 것이 앱 개발자의 숙명이 아닐까라는 생각이 들었죠.
그래서 우리는 무엇을 해야 하나요
우리는 산재되어있는 관리 포인트 최소화를 목표로 잡아야 하며, 이런 비효율을 개선하기 위한 방법이 바로 ‘네트워크 응답 모델 통합 핸들링하기’입니다.
본문으로 들어가기 전에 API를 호출하기 위해 고려했던 점을 한 번 알아볼까요?
요즘 코드는 대체로 Data / Domain / Presentation 세 가지 레이어로 구분되어 있을 거예요.

이런 환경에서 API를 한번 호출하기란 여간 귀찮은 작업이 아닙니다.
- 각 레이어에서 Data – Presentation 사이의 통신을 위한 코드 구현
- 각 레이어에 맞는 Data Model 작성
- 응답 성공 실패 여부 고려하기, 다 건의 API 동시 호출이면 더 고민해보기..
- 응답이 실패 또는 오류가 났을 경우 의도대로 처리하기
- 여러분들이 생각하시는 많은 것들…
벌써 머리가 아파오지 않나요?
또한, 이러한 것들을 고려하면 크게 아래 세 가지와 같은 문제가 발생할 수 있어요.
1️⃣ 비효율적인 반복 코드
try-catch를 네트워크 요청시 마다 사용해줘야 하며, 호출자인 UI Layer에서 또한 try-catch를 이용하여 에러 처리를 해줘야 하기 때문에 코드의 복잡성이 증가하고 생산성이 저하됩니다.
2️⃣ 에러 다양성에 의한 처리의 난해함
네트워크 통신 중 에러는 다양합니다. 서버에서 내려준 각기 다른 Status Code를 지닌 에러일 수 있으며, 네트워크 통신 중에 발생한 IOException과 같은 경우도 존재합니다. 따라서 UI Layer에서는 에러의 처리가 불분명하며, 특정 에러와 관련된 로직을 생성하는 데 어려움이 있습니다.
3️⃣ 중간 연산자의 부재에서 오는 생산성 저하 / 네트워크 응답의 불일치
앱을 개발하며 여러 API를 한 번에 호출하거나, Chaining을 통해 호출하는 경우가 빈번하게 존재합니다. 그러나 단순 Data를 받아오기 때문에 Domain / UI Layer에서의 로직 처리 비용이 많이 들며, Data의 일관성이 없기에 확장성이 줄고, 가공하기도 어렵습니다. 이를 통해 불필요한 코드 증가 및 가독성 저하를 불러일으킵니다.
와우.. 네트워크 한 번 호출하는 게 이렇게 힘든 일이라니..
그런데! 이러한 문제에서 단순히 몇 개의 파일 구현만을 통해 사용성과 생산성을 높일 수 있다는 것이 믿어지시나요?
더 나은 효율을 위한 네트워크 응답 모델 통합 솔루션 Wrapper Class를 곁들인 CallAdapter가 그 주인공입니다!
Wrapper Class / CallAdapter
그렇다면 이 두 가지를 통해 어떻게 이러한 이점을 가져갈 수 있고, 활용할 수 있는지 확인해 볼까요?
CallAdapter 어떻게 쓰이나요
Retrofit은 간단하게 표현하자면 아래와 같이 구성됩니다.

그림과 같이 Retrofit은 callAdapterFactories 변수를 기본적으로 포함하고 있어요. API를 호출하게 되면 API Interface에서 정의한 리턴 타입과 일치하는 CallAdapter를 가져오게 되는데 이러한 CallAdapter의 모음집이 callAdapterFactories라고 생각하면 돼요. 그리고 CallAdapter를 따로 정의하지 않으면 DefaultCallAdapter를 가져와서 통신 시 이용하게 됩니다.

가져온 DefaultCallAdapter는 API에 대한 응답이 올 경우, 응답을 중간에 가로채어 성공 / 실패 응답 및 Network 상에서 발생하는 Exception에 대한 데이터 후처리를 하게 됩니다.
다들 눈치채셨나요?

맞아요!
모든 API 호출에 대한 응답을 Wrapper Class로 변경하기 위해 우리는 CallAdapter를 직접 구현하여 기존 응답을 변경해 줄 거예요.
이런 문제를 해결할 수 있어요
앞서 API를 호출하며 고려할 점과 이를 통해 볼 수 있는 문제점 3가지에 대해 전달해 드렸는데요.
- 비효율적인 반복 코드
- 에러 다양성에 의한 처리의 난해함
- 중간 연산자의 부재에서 오는 생산성 저하 / 네트워크 응답의 불일치
CallAdapter를 통해 응답 값을 Wrapper Class로 변경하면, 이러한 문제들을 해결할 수 있었어요.
1️⃣ 비효율적인 반복 코드 → 효율적인 코드 사용
더 이상 우리는 try-catch로 범벅이 된 코드를 이용하지 않아도 됩니다! 성공 / 실패 / 오류 여부를 판단하기 위해 이용하였던 에러 처리 코드는 CallAdapter에서 각 상태에 맞는 Wrapper Class로 변환하여 받기만 하면 되거든요! 반복된 예외 처리로 인한 코드의 수가 1.5배는 감소할 거예요.
2️⃣ 에러 다양성에 의한 처리의 난해함 → 에러 통합
SocketTimeOutException, IOException 너무 많은 오류, Status Code에 따른 실패, 이제 모두 하나의 Wrapper Class로 받아올 수 있어요. 그동안 응답을 받는 곳에서 에러 처리가 불분명했던 곳은 하나의 Class로 관리될 수 있고, 추가 에러 케이스가 나타날 경우 CallAdapter의 실패 또는 오류 상태에서 케이스 하나만 추가하면 되는 놀라운 경험을 하게 됩니다.
3️⃣ 중간 연산자의 부재에서 오는 생산성 저하 / 네트워크 응답의 불일치 → 네트워크 응답 통합 및 확장성 증가
서로 다른 타입의 응답은 가공하기도 굉장히 어렵죠. 그러나 CallAdapter는 하나의 Class로만 응답을 내려줄 것이기 때문에, 확장성이 무한하게 늘어날 수 있어요. 여러 API를 한 번에 호출할 수 있고, 응답 Chaining이 가능하며, 원하는 형태로 가공도 가능해요!
이렇게 얘기하면 Wrapper Class의 장점으로 느껴질 수 있지만 사실 맞습니다. 그러나 CallAdapter와 Wrapper Class 서로를 결합하여 더욱 가치 있는 코드로 만들 수 있죠.
앞서 API를 호출하기까지 겪어볼 수 있는 고민과 문제점들을 얘기했고, 이런 문제를 해결하기 위한 솔루션으로 CallAdapter / Wrapper Class를 알아봤어요. 이제 구현 방법을 살펴보고 정말 이런 문제를 해결할 수 있는지 확인해 보시죠!
많이 할 필요 없어요. 4가지만 구현해요.
CallAdapter와 Wrapper Class는 각각 역할이 존재해요.
- CallAdapter 네트워크 응답을 원하는 객체로 변경시켜주기 위한 역할
- Wrapper Class 동일 데이터를 통해 일관성을 보장, 쉬운 가공을 위한 역할
해당 역할을 다 할 수 있게 하기 위해서 Wrapper Class, CallAdapter.Factory, CallAdapter, Call 오직 ‘4가지 클래스만’ 구현하면 됩니다!
그럼 우선 Wrapper Class부터 구현해 볼게요.
sealed class NetworkResponse<out R> {
data class Success<out T>(val data: T) : NetworkResponse<T>()
data class Error(val exception: Throwable) : NetworkResponse<Nothing>()
}
방법은 간단해요! 결국, 우리는 성공과 실패에 대한 여부를 판단해야 하기 때문에 Success (성공) / Error(실패, 오류) 두 가지를 우선 구현합니다. 이 외에도 추가로 변환시켜줄 타입을 자유롭게 구성할 수 있어요!
이제 일괄 타입을 구현하였으니, 응답을 Wrapper Class로 변환시켜줄 CallAdapter를 만들어볼게요. Retrofit에서는 이용할 CallAdapter를 returnType을 통해 가져오게 되는데, 이때 올바른 CallAdapter를 반환해주는 생성자 역할을 하는 것이 CallAdapter.Factory예요.
class NetworkResponseCallAdapterFactory : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotation: Array<out Annotation>,
retrofit: Retrofit
): CallAdapter<*, *>? {
..
val wrapperType = getParameterUpperBound(0, returnType)
if (getRawType(wrapperType) != NetworkResponse::class.java) return null
..
val bodyType = getParameterUpperBound(0, wrapperType)
return NetworkResponseCallAdapter<Any>(bodyType)
}
}
CallAdapter.Factory
CallAdapter.Factory의 핵심은 returnType이 일치하는지 확인하는 것입니다! 확인이 완료되면 해당 타입의 CallAdapter를 반환하게 되거든요. 따라서 우리는 return 타입의 Upper가 NetworkResponse인지 확인시켜 주기만 하면 된답니다.
CallAdapter는 네트워크 통신 시 이용하는 객체 Call<T>를 정의한 객체인 Call<NetworkResponse<T>>로 변환시켜 주는 역할을 해요.
private class NetworkResponseCallAdapter<T>(
private val successType: Type
) : CallAdapter<T, Call<NetworkResponse<T>>> {
override fun responseType(): Type = successType
override fun adapt(call: Call<T>): Call<NetworkResponse<T>>
= NetworkResponseCall(call)
}
CallAdapter
responseType은 받아올 returnType인 T를 의미하며, adapt는 Call<T>를Call<NetworkResponse<T>>로 변환시켜 주는 역할을 해요. 이러한 변환 과정은 CallAdapter가 아닌 Call 객체 내부에서 위임받아 진행하게 됩니다!
이제 마지막입니다! CallAdapter의 핵심인 Call 객체를 구현해 볼게요.
private class NetworkResponseCall<T>(
private val delegate: Call<T>
) : Call<NetworkResponse<T>> {
override fun enqueue(
callback: Callback<NetworkResponse<T>>
) = delegate.enqueue(
object : Callback<T> {
private fun Response<T>.toNetworkResponse(): NetworkResponse<T> {
// convert success response
}
override fun onResponse(call: Call<T>, response: Response<T>) {
callback.onResponse(
this@NetworkResponseCall,
Response.success(response.toNetworkResponse())
)
}
override fun onFailure(call: Call<T>, throwable: Throwable) {
callback.onResponse(
this@NetworkResponseCall,
Response.success(NetworkResponse.Error(throwable))
)
}
}
)
..
}
Retrofit은 네트워크 응답이 오거나 오류가 발생하면 전달받은 Call 객체에 onResponse / onFailure 로 Callback 해주게 되죠. 여기서 이 글의 핵심인 응답을 가로채는 로직을 구현할 수 있습니다! 우리는 Call 객체를 직접 구현해 응답에 대한 Callback인 onResponse / onFailure 를 override 하여 원하는 응답 값으로 변경하는 로직만 구현하면 되는 것이죠! 이 외에도 Call 객체에서는 clone , execute, cancel과 같은 함수가 더 존재하는데, 이건 기존 Call 객체의 것들을 그대로 이용하면 돼요. 정말 간단하죠?
위 4가지만 구성한다면, 앞으로 어떤 응답이든 원하는 대로 가공할 수 있는 마법을 사용하실 수 있게 될 거예요!
사용법은 더 간단합니다. Retrofit 생성 시 아래 코드만 추가해 주면 돼요.
Retrofit.Builder()
.addCallAdapterFactory(NetworkResponseCallAdapterFactory())
.addConverterFactory(GsonConverterFactory.create())
.baseUrl({serverBaseUrl})
.client({okHttpClient})
.build()
아! 그리고 API호출 시 반환형을 NetworkResponse타입으로 선언하는 것도 잊으시면 안 돼요!
우리 이제 효율적으로 코딩해봐요
단지 4가지 클래스만 구현했을 뿐인데 효과는 굉장했습니다!
1️⃣ 생산성 / 가독성 향상
우선, CallAdapter를 도입하여 Response를 처리하기 때문에 try-catch와 같은 불필요한 보일러 플레이트를 작성할 필요가 없어졌습니다. 이에 따라 비효율적 코드를 작성하지 않아도 되며, 가독성 또한 향상됩니다.
우리의 뚱뚱한 Repository 라인 수는 극적인 다이어트에 성공하였어요.

평균적으로 무려 36%나 코드가 제거되는 효과를 볼 수 있었어요. 반복 코드로 인해 저하되었던 생산성 또한 크게 증가하였어요.
2️⃣ 응답의 일관성 보장 / 코드 복잡도 감소
두 번째로, Wrapper Class를 이용하면서 원하는 상황을 일관성 있는 데이터로 보장할 수 있게 되었어요. 또한, 응답이 동일하기 때문에 확장함수와 같이 일괄적으로 데이터를 가공 / 처리하는 로직을 구현하기 용이하며, 각 레이어에서 다른 응답으로 인한 번거로움 또한 해소되었습니다!
Wrapper Class의 가공이 쉽다는 이점을 이용해, 아래와 같이 공통으로 이용할 수 있는 확장함수를 구현할 수도 있어요.
inline fun <T> NetworkResponse<T>.collectData(
onSuccess: (value: T) -> Unit,
onFailure: (exception: Throwable) -> Unit
) {
when (this) {
is NetworkResponse.Error -> {
// implementation onFailure
}
is NetworkResponse.Success -> {
// implementation onSuccess
}
}
}
성공 실패여부를 가져오는 확장함수
suspend inline fun <T1, T2, R> zip(
crossinline source1: suspend () -> NetworkResponse<T1>,
crossinline source2: suspend () -> NetworkResponse<T2>,
crossinline block: (T1, T2) -> R
): NetworkResponse<R> {
return coroutineScope {
val response1 = async { source1() }.await()
val response2 = async { source2() }.await()
val responseList = listOf(response1, response2).toTypedArray()
when {
isSuccess(*responseList) -> {
// implementation onSuccess
}
isFailure(*responseList) -> {
// implementation onFailure
}
else -> {
// implementation other case
}
}
}
}
여러 API를 한번에 호출하는 확장함수
inline fun <R, T> NetworkResponse<T>.map(transform: (T) -> R): NetworkResponse<R> {
return when (this) {
is NetworkResponse.Success -> {
// implementation onSuccess
}
is NetworkResponse.Error -> {
// implementation onFailure
}
}
}
데이터를 가공할 수 있는 확장함수
이 외에도 피플펀드 코드에서는 가공이 실패했을 때 기본값을 반환해 주는 getOr, 좀 더 많은 API Source를 받을 수 있는 zip과 같은 확장 함수를 구현해 두었어요. 이처럼 Wrapper Class를 이용하면 더욱 많은 확장성의 기회가 열려있답니다.
3️⃣ 목적 분리 / 유지 보수 비용 감소
마지막으로, 이제는 try-catch에서 성공 / 실패 여부를 판단하는 것을 CallAdapter에 위임함으로써, 산재하어 있던 응답 판단 로직을 CallAdapter 한 군데로 모을 수 있었어요! 추가로 특수한 에러 케이스나 전반적으로 처리되어야 하는 로직의 경우도 CallAdapter에서 로직을 구현하면 되니 유지 보수 비용도 크게 줄었어요.
그리고 쉬운 사용성까지!
CallAdapter를 이용하지 않을 이유가 없죠.
글을 마무리하며
CallAdapter를 적용하면서 많은 것들을 배울 수 있었어요. 실제로 내부적으로는 아래와 같이 작동하는 것을 볼 수 있습니다.

CallAdapter와 Wrapper Class, 단순 적용은 가성비가 좋지만, 적용하기 위한 내부적인 동작 방식을 알아보는 것도 많은 도움이 될 수 있어요! 그리고 한 번 적용해 두면 굉장히 유용하게 이용할 수 있답니다.
끝으로, 제가 대표로 글을 쓰고 있긴 하지만 같은 팀이신 용훈님, 영회님께서 기술 적용 및 글을 작성하기까지 많은 도움을 주셨어요! 덕분에 이렇게 글까지 쓰고 있답니다. 정말 감사드리고 앞으로도 잘 부탁해요.
이전에는 남들이 해보는 건 다 해보자의 취지가 강했던 Android 팀이지만, 이제는 우리 팀의 고민을 모두와 함께 나눌 기술력을 지닌 팀이 되었습니다. 본 글의 주제도 원래는 ‘비효율을 개선하기 위한 노력’이었는데요. 이런 기능을 포함해 꾸준히 새로운 경험을 하기 위한 노력을 하고 있답니다. (심지어 팀 내의 슬로건이 아름답고 섹시하고 효율적으로 일하기예요..🤣)
앞으로도 저희가 비효율과 싸워 이길 수 있게 많은 관심과 응원 부탁드릴게요!
️
written by Junyeob
비효율을 해결해나가는 길, 함께하고 싶다면?