본문 바로가기
Android/Retrofit

[Android] Retrofit CallAdapter를 통해 효과적으로 예외 처리하기

by 태크민 2025. 2. 20.

이전 포스팅에서는 Retrofit 내부 동작에 대해서 알아보았습니다.

내부적으로 CallAdapter를 생성하는 것을 확인할 수 있었는데요. 이를 커스텀해서 Retrofit에 적용할 수도 있습니다. 이번 포스팅에서 CallAdpater를 커스텀하여 적용함으로써 예외를 어떻게 효과적으로 처리할 수 있는지 알아보겠습니다.

 

다들 경험하셨다시피 앱을 제작할 때 네트워크 통신은 필수적으로 이뤄지는 경우가 많습니다.

 

프로젝트는 계속해서 늘어만 가고, 그 수에 비례해 증가하는 API, 그리고 비대해지는 Repository, Data Model은 걷잡을 수 없었습니다.. 따라서 관리 포인트 감소, 유지 보수 비용을 줄이는 것이 앱 개발자의 숙명이 아닐까라는 생각이 들었죠

.


그래서 우리는 무엇을 해야 하나요

우리는 산재되어있는 관리 포인트 최소화를 목표로 잡아야 하며, 이런 비효율을 개선하기 위한 방법이 바로 ‘네트워크 응답 모델 통합 핸들링하기’입니다.

본문으로 들어가기 전에 API를 호출하기 위해 고려했던 점을 한 번 알아볼까요?

요즘 코드는 대체로 Data / Domain / Presentation 세 가지 레이어로 구분되어 있을 거예요.

 

 

이런 환경에서 API를 한번 호출하기란 여간 귀찮은 작업이 아닙니다.

  • 각 레이어에서 Data – Presentation 사이의 통신을 위한 코드 구현
  • 각 레이어에 맞는 Data Model 작성
  • 응답 성공 실패 여부 고려하기, 다 건의 API 동시 호출이면 더 고민해보기..
  • 응답이 실패 또는 오류가 났을 경우 의도대로 처리하기
  • 여러분들이 생각하시는 많은 것들…

벌써 머리가 아파오지 않나요? 

또한, 이러한 것들을 고려하면 크게 아래 세 가지와 같은 문제가 발생할 수 있어요.

 

1️⃣ 비효율적인 반복 코드

try-catch를 네트워크 요청시 마다 사용해줘야 하며, 호출자인 UI Layer에서 또한 try-catch를 이용하여 에러 처리를 해줘야 하기 때문에 코드의 복잡성이 증가하고 생산성이 저하됩니다.

예를들어, 다음과 같은 예시 코드를 들 수 있습니다.

 

(1) Repository Layer

class UserRepository(private val apiService: ApiService) {
    suspend fun getUser(): User {
        return try {
            apiService.getUser() // 네트워크 요청
        } catch (e: IOException) {
            throw NetworkException("네트워크 오류", e)
        } catch (e: HttpException) {
            throw ServerException("서버 오류", e)
        }
    }
}

 

(2) UseCase Layer

class GetUserUseCase(private val repository: UserRepository) {
    suspend fun execute(): User {
        return try {
            repository.getUser()
        } catch (e: Exception) {
            throw e // 예외를 다시 던짐 (UI Layer에서도 처리 필요)
        }
    }
}

 

(3) UI Layer (ViewModel)

class UserViewModel(private val getUserUseCase: GetUserUseCase) : ViewModel() {
    fun fetchUser() {
        viewModelScope.launch {
            try {
                val user = getUserUseCase.execute()
                _userState.value = user
            } catch (e: NetworkException) {
                _errorState.value = "네트워크 오류 발생"
            } catch (e: ServerException) {
                _errorState.value = "서버 오류 발생"
            } catch (e: Exception) {
                _errorState.value = "알 수 없는 오류 발생"
            }
        }
    }
}

 

결국 모든 Layer에서 try-catch 블록을 추가해야하는 상황으로, 중복 코드가 발생하는 문제가 생깁니다.

 

2️⃣ 에러 다양성에 의한 처리의 난해함

네트워크 통신 중 에러는 다양합니다. 서버에서 내려준 각기 다른 Status Code를 지닌 에러일 수 있으며, 네트워크 통신 중에 발생한 IOException과 같은 경우도 존재합니다. 따라서 UI Layer에서는 에러의 처리가 불분명하며, 특정 에러와 관련된 로직을 생성하는 데 어려움이 있습니다.

suspend fun fetchUserData(): User? {
    return try {
        val response = apiService.getUserInfo()  // API 요청
        if (response.isSuccessful) {
            response.body()  // 성공 시 데이터 반환
        } else {
            // HTTP 응답 실패 처리
            when (response.code()) {
                400 -> throw BadRequestException("잘못된 요청입니다.")
                401 -> throw UnauthorizedException("인증 오류가 발생했습니다.")
                500 -> throw ServerException("서버 오류 발생.")
                else -> throw UnknownHttpException("알 수 없는 오류: ${response.code()}")
            }
        }
    } catch (e: IOException) {
        println("❌ 네트워크 오류 발생: ${e.message}")
        null
    } catch (e: TimeoutException) {
        println("⏳ 요청 시간 초과: ${e.message}")
        null
    } catch (e: Exception) {
        println("⚠️ 알 수 없는 오류 발생: ${e.message}")
        null
    }
}

 

3️⃣ 중간 연산자의 부재에서 오는 생산성 저하 / 네트워크 응답의 불일치

앱을 개발하며 여러 API를 한 번에 호출하거나, Chaining을 통해 호출하는 경우가 빈번하게 존재합니다. 그러나 단순 Data를 받아오기 때문에 Domain / UI Layer에서의 로직 처리 비용이 많이 들며, Data의 일관성이 없기에 확장성이 줄고, 가공하기도 어렵습니다. 이를 통해 불필요한 코드 증가 및 가독성 저하를 불러일으킵니다.

suspend fun fetchUserAndPosts(): Pair<User?, List<Post>?> {
    return coroutineScope {
        try {
            val userDeferred = async { apiService.getUserInfo() }
            val postsDeferred = async { apiService.getUserPosts() }

            val userResponse = userDeferred.await()
            val postsResponse = postsDeferred.await()

            if (!userResponse.isSuccessful) throw Exception("사용자 정보 요청 실패: ${userResponse.code()}")
            if (!postsResponse.isSuccessful) throw Exception("게시글 요청 실패: ${postsResponse.code()}")

            Pair(userResponse.body(), postsResponse.body())

        } catch (e: IOException) {
            println("❌ 네트워크 오류 발생: ${e.message}")
            Pair(null, null)
        } catch (e: Exception) {
            println("⚠️ 알 수 없는 오류 발생: ${e.message}")
            Pair(null, null)
        }
    }
}

 

와우.. 네트워크 한 번 호출하는 게 이렇게 힘든 일이라니..

 

그런데! 이러한 문제에서 단순히 몇 개의 파일 구현만을 통해 사용성과 생산성을 높일 수 있다는 것이 믿어지시나요?

더 나은 효율을 위한 네트워크 응답 모델 통합 솔루션 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가지에 대해 전달해 드렸었습니다.

  1. 비효율적인 반복 코드
  2. 에러 다양성에 의한 처리의 난해함
  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 error: ApiError) : NetworkResponse<Nothing>()
}

 

방법은 간단해요! 결국, 우리는 성공과 실패에 대한 여부를 판단해야 하기 때문에 Success (성공) / Error(실패, 오류) 두 가지를 우선 구현합니다. 이 외에도 추가로 변환시켜줄 타입을 자유롭게 구성할 수 있어요!

이제 일괄 타입을 구현하였으니, 응답을 Wrapper Class로 변환시켜줄 CallAdapter를 만들어볼게요. Retrofit에서는 이용할 CallAdapter를 returnType을 통해 가져오게 되는데, 이때 올바른 CallAdapter를 반환해주는 생성자 역할을 하는 것이 CallAdapter.Factory예요.

//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의 핵심은 returnType이 일치하는지 확인하는 것입니다! 확인이 완료되면 해당 타입의 CallAdapter를 반환하게 되거든요. 따라서 우리는 return 타입의 Upper가 NetworkResponse인지 확인시켜 주기만 하면 된답니다.

CallAdapter는 네트워크 통신 시 이용하는 객체 Call<T>를 정의한 객체인 Call<NetworkResponse<T>>로 변환시켜 주는 역할을 해요.

//CallAdapter
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)
}

 

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) {
                val error = when (throwable) {
                    is SocketTimeoutException -> ApiError.Timeout
                    is IOException -> ApiError.Network
                    is HttpException -> ApiError.Http(t.code(), t.message())
                    else -> ApiError.Unknown 
                }
                callback.onResponse(
                    this@NetworkResponseCall,
                    Response.success(NetworkResponse.Error(error))
                )
            }
        }
    )

    ..
}

 

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타입으로 선언하는 것도 잊으시면 안 돼요!

 

CallAdapter를 사용함으로써, 이제 각 계층에서 별도로 try-catch 블록을 선언할 필요가 없어졌습니다 그리고 에러나 예외를 하나의 클래스로 통합하여 관리할 수 있기 때문에 더욱 유지보수가 쉬워지게 되었습니다. 

아래는 ViewModel에서 예외를 처리하는 코드입니다. 

class UserViewModel(private val repository: UserRepository) : ViewModel() {
    fun fetchUser() {
        viewModelScope.launch {
            when (val result = repository.getUser()) {
                is ApiResult.Success -> {
                    _userState.value = result.data
                }
                is ApiResult.Failure -> {
                    when (result.error) {
                        ApiError.Network -> _errorState.value = "네트워크 연결이 원활하지 않습니다."
                        ApiError.Timeout -> _errorState.value = "서버 응답 시간이 초과되었습니다."
                        is ApiError.Http -> _errorState.value = "오류 코드: ${result.error.code}, ${result.error.message}"
                        ApiError.Unknown -> _errorState.value = "알 수 없는 오류가 발생했습니다."
                    }
                }
            }
        }
    }
}

 

각 계층에서 했던 try-catch 방식을 ViewModel에서 한번만 함으로써 훨씬 더 코드 라인 수가 줄어들 것을 예상할 수 있겠죠?


우리 이제 효율적으로 코딩해봐요

단지 4가지 클래스만 구현했을 뿐인데 효과는 굉장했습니다!

1️⃣ 생산성 / 가독성 향상

우선, CallAdapter를 도입하여 Response를 처리하기 때문에 try-catch와 같은 불필요한 보일러 플레이트를 작성할 필요가 없어졌습니다. 이에 따라 비효율적 코드를 작성하지 않아도 되며, 가독성 또한 향상됩니다.

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 fun fetchUserData() {
    val response: NetworkResponse<User> = apiService.getUserInfo()

    response.collectData(
        onSuccess = { user ->
            println("✅ 사용자 정보: ${user.name}")
        },
        onFailure = { error ->
            println("❌ 오류 발생: ${error.message}")
        }
    )
}

 

여러 API를 한번에 호출하는 확장함수

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
            }
        }
    }
}
suspend fun fetchUserAndPosts() {
    val result: NetworkResponse<Pair<User, List<Post>>> = zip(
        source1 = { apiService.getUserInfo() },
        source2 = { apiService.getUserPosts() }
    ) { user, posts ->  
        Pair(user, posts) // 결과를 Pair로 변환
    }

    result.collectData(
        onSuccess = { (user, posts) ->
            println("✅ 사용자: ${user.name}, 포스트 개수: ${posts.size}")
        },
        onFailure = { error ->
            println("❌ 오류 발생: ${error.message}")
        }
    )
}

 

데이터를 가공할 수 있는 확장함수

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
        }
    }
}
suspend fun getUserData() {
    val response = fetchUserInfo() // API 호출

    val transformedResponse = response.map { user -> 
        // User 객체를 String으로 변환
        "Name: ${user.name}, Age: ${user.age}" 
    }

    when (transformedResponse) {
        is NetworkResponse.Success -> {
            println("Transformed User Data: ${transformedResponse.data}") // 변환된 데이터 출력
        }
        is NetworkResponse.Error -> {
            println("Error occurred: ${transformedResponse.exception.message}") // 오류 메시지 출력
        }
    }
}

 

 이처럼 Wrapper Class를 이용하면 더욱 많은 확장성의 기회가 열려있답니다.

 

3️⃣ 목적 분리 / 유지 보수 비용 감소

마지막으로, 이제는 try-catch에서 성공 / 실패 여부를 판단하는 것을 CallAdapter에 위임함으로써, 산재하어 있던 응답 판단 로직을 CallAdapter 한 군데로 모을 수 있었어요! 추가로 특수한 에러 케이스나 전반적으로 처리되어야 하는 로직의 경우도 CallAdapter에서 로직을 구현하면 되니 유지 보수 비용도 크게 줄었어요.

그리고 쉬운 사용성까지!

CallAdapter를 이용하지 않을 이유가 없죠.

 


글을 마무리하며

CallAdapter를 적용하면서 많은 것들을 배울 수 있었어요. 

이러한 작업도 사실 공수가 들고 귀찮을 떄가 있습니다.

이러한 수고를 덜어주는 라이브러리도 있다고 하네요.

https://github.com/skydoves/sandwich

 

GitHub - skydoves/sandwich: 🥪 Sandwich is an adaptable and lightweight sealed API library designed for handling API response

🥪 Sandwich is an adaptable and lightweight sealed API library designed for handling API responses and exceptions in Kotlin for Retrofit, Ktor, and Kotlin Multiplatform. - skydoves/sandwich

github.com

 

한번 사용해보는 것도 좋을 것 같습니다.


참고자료

https://blog.pfct.co.kr/network-calladapter-wrapper-class/

 

Network 응답 처리, 이렇게 해보는건 어떤가요?: CallAdapter / Wrapper Class

다들 Retrofit 잘 이용하고 있으신가요? Network 응답 이거 하나면 처리 완료! 한 번 적용하면 라이브러리 변경 직전까지 사용할 수 있는 유용한 응답 핸들링 방법을 가져왔어요. 아직 적용하지 않으

blog.pfct.co.kr

https://velog.io/@skydoves/retrofit-api-handling-sandwich

 

안드로이드 Retrofit + Coroutines의 API 응답 및 에러 핸들링 - Sandwich

데이터 커뮤니케이션 횟수가 증가함에 따라 애플리케이션 아키텍처의 복잡성도 함께 증가합니다. 오픈소스 라이브러리 Sandwich를 활용하여 multi-layered 아키텍처에서 API 응답 및 에러 핸들링을 하

velog.io