Retrofit은 안드로이드에서 네트워크 통신 작업을 위해 가장 많이 사용하는 라이브러리이다. 하지만 많은 사람들이 Retrofit이 내부적으로 어떻게 동작하는지 모른채 사용하고 있다. 물론 내부 동작을 모두 알 필요는 없지만, 어느정도는 알아야 반환되는 값이 어떤 과정을 거쳐 검증되었는지, 발생할 수 있는 에러는 어떤 게 있는지, 어떤 상황에 발생하는지 등을 알고 대응할 수 있다.
그리고 Retrofit 을 사용하면서 자주 보는 클래스는 Call 이다. RxJava 나 Coroutine 을 사용하면서 Call 을 직접 사용하지 않는 경우도 많다. 하지만 Call 은 항상 내부적으로 중요하게 사용되고 있는 클래스이다. 이를 중심으로 Retofit 의 동작 방식에 대해 알아보고, Call 을 활용할 수 있는 방법을 살펴보고자 한다.
Call
사용해본 사람들은 알겠지만, HTTP API 인터페이스 함수의 반환 타입은 기본적으로 Call이다.
interface GitHubService {
@GET("users/{user}/repos")
fun listRepos(@Path("user") user: String): Call<List<Repo>>
}
해당 함수의 반환값이기 때문에, 우리가 원하는 데이터 (여기서는 List<Repo>)를 단순히 wrapping 하고 있는 객체라고 생각할 수 있다. 하지만 Call 은 해당 함수의 HTTP 요청과 응답 쌍을 갖고만 있는 객체이다. 실제로 응답 데이터를 얻기 위해서는 맴버 함수인 execute()나 enqueue()를 호출해서 실제로 요청을 실행해야한다.
HTTP 요청
Call 은 HTTP API 요청에 대한 정보와 응답에 대한 정보만을 갖고 있을 뿐, 실행하기 전까지는 실제로 데이터를 가지고 있지는 않다. 요청을 실행하는 방법은 크게 두 가지가 있다.
execute()
HTTP 요청을 동기로 실행한다. 즉, 해당 함수의 호출 결과 값이 HTTP 요청에 대한 응답 데이터이다. 그 자리에서 원하는 데이터를 얻을 수 있다. 다만 별도의 스레딩 없이 호출하면 에러가 발생할 수 있다. 안드로이드에서는 메인 스레드에서 네트워크 작업을 차단하고 있기 때문이다.
enqueue()
HTTP 요청을 비동기로 실행한다. 따라서 결과 값을 얻기 위해 콜백 인스턴스를 파라미터로 넘겨줘야한다. 내부적으로는 execute()를 사용한다.
Call 을 그대로 사용하면 안되는 이유
Call 의 enqueue() 를 사용하면, 별도로 스레딩 처리를 할 필요 없이 알아서 백그라운드 스레드에서 작업을 수행하고, 메인 스레드에서 콜백을 호출해준다. 따라서 Coroutine, RxJava 등 별도 비동기를 위한 라이브러리를 사용하지 않아도 된다는 장점이 있다.
gitHubService.listRepos("winter223")
.enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
val body = response.body()
if (body == null) {
// 호출은 성공했으나 body()가 null인 상태
// 에러 처리
} else {
// API 호출 성공 및 정상 응답
// UI 반영
}
} else {
// Http status code 가 200대가 아님
// 에러 분석 및 처리
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
// 네트워크 에러 및 파싱 실패 등
// 에러 처리
}
})
하지만 단점도 존재한다.
콜백 사용
기본적으로 콜백을 사용하기 때문에 API를 연쇄, 혹은 병렬적으로 호출해야할 때 한계가 있다. 콜백을 사용한다는 것 자체가 이런 문제를 일으키는 건 아니다. RxJava도 콜백 형태를 사용하지만, 각종 operator 로 이를 극복하고 있다.
하지만 Call 은 RxJava와 같은 대응책이 마련되어있지 않기 때문에 콜백 지옥에 빠질 수 있고, 병렬 호출을 구현하기가 무척 어렵다. 콜백을 사용하지 않는 이유에 대해서는 Callback지옥으로부터 Coroutine까지의 긴 여정을 참고하면 좋다.
번거로운 에러 처리
위 코드에서 볼 수 있듯이, 우리가 필요한 데이터를 꺼내기 위해 많은 분기문이 필요하다. onResponse() 는 말 그대로 호출 성공 및 응답이 있는 경우에 불려지고, onFailure() 는 그 외의 모든 경우에 불려진다. 즉, 네트워크 에러가 발생하거나 응답 Json 파싱을 실패하는 등 호출 과정 자체에서 문제가 발생한 경우에 호출된다.
onResponse() 내에서도 Http status code 가 200대인지, body()가 null 아닌지를 검사하고 나서야 최종적으로 데이터를 꺼내올 수 있다. 그 외의 경우에는 실패로 간주하고 에러 처리를 직접 해줘야한다. onFailure()에서는 Retrofit 에서 정의한 에러로 매핑되어 넘겨받지만, Http status code가 200 대가 아닌 경우에는 Response.rawResponse 를 가지고 직접 어떤 에러인지 분석해야한다. 이걸 매 API 호출 마다 작성해주면 코드량이 증가할 뿐만 아니라 중복 코드가 발생하게 된다.
해결 방안
번거로운 에러 처리 -> 확장 함수
코드량 증가 및 중복코드를 해결하기 위한 가장 쉬운 방법은 별도의 함수로 묶어두고 사용하는 것이다. 코틀린을 사용한다면 확장함수를 통해 이를 더욱 간편하게 구현할 수 있다.
fun <T : Any> Call<T>.enqueue(
onSuccess: (data: T) -> Unit,
onFailure: (throwable: Throwable) -> Unit
) {
enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
val body = response.body()
if (body == null) {
val invocation = call.request().tag(Invocation::class.java)!!
val method = invocation.method()
val e = KotlinNullPointerException(
"Response from " +
method.declaringClass.name +
'.' +
method.name +
" was null but response body type was declared as non-null"
)
onFailure.invoke(e)
} else {
onSuccess.invoke(body)
}
} else {
onFailure(HttpException(response))
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
onFailure(t)
}
})
}
onSuccess(), onFailure() 라는 고차함수로 우리가 정말 "성공"했다고 여기는 케이스와 그 외의 케이스를 나눌 수 있다. onFailure()에는 위에서 언급한 것 처럼 에러를 적절하게 감싸거나 매핑해서 넘겨준다. 따라서 성공 시에는 데이터 사용, 실패 시에는 각 에러 타입마다 알맞은 처리를 해줄 수 있다.
성공
HTTP status code in (200 ~ 300) && body() != null |
Payload 반환 |
실패
HTTP status code not in (200 ~ 300) | HttpException |
body() == null | NullPointerException |
onFailure() | Retrofit 정의 Exception |
콜백 -> suspend fun
앞서 콜백을 사용하면 연쇄 호출이나 병렬 호출에 있어서 한계가 있다고 했다. 코틀린을 사용한다면, 현시점에서 이를 해결하기에 가장 이상적인 방법은 Coroutine 을 사용하는 것이다.
Coroutine 을 사용하면 비동기 함수를 마치 동기 함수인 것처럼 콜백 없이 사용할 수 있다. 다만 해당 함수를 실행하는 동안, 다음 라인으로 진행되지 않고 중단(suspend) 된다. 이런 함수들을 각종 CoroutineScope 안에서 호출하여 스레딩을 구현할 수 있기 때문에 Scope를 달리하여 메인 스레드를 차단하지 않을 수 있고, 병렬 호출도 구현할 수 있다.
Retrofit 에서는 자체적으로 API 인터페이스 함수를 Coroutine suspend function 으로 다룰 수 있도록 제공하고 있다.
interface GitHubService {
@GET("users/{user}/repos")
suspend fun listRepos(@Path("user") user: String): List<Repo>
}
위 처럼 단순히 suspend 키워드만 붙여주면 된다. 그럼 해당 함수는 CoroutineScope 안에서 스레드를 차단하고 결과 값을 바로 가져올 수 있게 된다. 결과 값을 바로 가져오는 것이기 때문에 반환 타입은 Call 을 사용하지 않는다.
viewModelScope.launch {
try {
val listRepos = gitHubService.listRepos("winter223")
// UI에 적용
} catch (e: Exception) {
//에러 처리
}
}
그런데 여기서 의문점이 하나 든다. 아까 Call과 enqueue()를 직접 사용할 때에는 실제로 성공하고 데이터를 받아올 수 있는 경우를 개발자가 직접 구분해주어야했다. HTTP status code 검사, body null-check 등 말이다. 심지어 이런 경우에는 rawResponse 를 보고 에러를 판단해야했다. 이런 작업이 어떻게 이루어지고 있는걸까 ? 저 suspend 함수가 반환하는 데이터는 과연 유효할까? try-catch 문에서 잡히는 에러는 어떤 에러일까 ? 이걸 알지 못하고 사용하면 각종 예외 상황들에 대해 완전히 대처할 수 없다.
Retrofit의 API 인터페이스 함수 호출 과정
suspend 키워드를 사용한 API 인터페이스 함수의 반환값과 발생하는 에러를 알기 위해서는 Retrofit이 해당 함수를 통해서 API를 호출하는 과정을 살펴봐야한다.
Retrofit
create() 로 API 인터페이스 구현체를 생성할 때, 프록시를 통해 invoke() 함수를 재정의한다. 여기서는 이를 처리할 ServiceMethod 를 찾고 ServiceMethod 가 함수를 호출하도록 한다.
//Retrofit.java
public <T> T create(final Class<T> service) {
validateServiceInterface(service);
return (T)
Proxy.newProxyInstance(
service.getClassLoader(),
new Class<?>[] {service},
new InvocationHandler() {
//...
@Override
public @Nullable Object invoke(Object proxy, Method method, @Nullable Object[] args)
throws Throwable {
//...
return platform.isDefaultMethod(method)
? platform.invokeDefaultMethod(method, service, proxy, args)
// 처리할 ServiceMethod를 찾아 invoke() 하도록 함
: loadServiceMethod(method).invoke(args);
}
});
ServiceMethod<?> loadServiceMethod(Method method) {
// ServiceMethod 를 캐싱하고 있는 자료구조 -> serviceMethodCache
ServiceMethod<?> result = serviceMethodCache.get(method);
if (result != null) return result;
synchronized (serviceMethodCache) {
result = serviceMethodCache.get(method);
// 캐시에 해당하는 ServiceMethod 가 없다면
if (result == null) {
// 이를 처리할 적절한 ServiceMethod 를 생성 후 캐싱
result = ServiceMethod.parseAnnotations(this, method);
serviceMethodCache.put(method, result);
}
}
return result;
}
ServiceMethod
ServiceMethod의 parseAnnotation() 는 주어진 함수의 어노테이션 및 반환 타입 등을 사용해서 주어진 함수를 호출할 적절한 ServiceMethod 를 찾는다.
abstract class ServiceMethod<T> {
static <T> ServiceMethod<T> parseAnnotations(Retrofit retrofit, Method method) {
RequestFactory requestFactory = RequestFactory.parseAnnotations(retrofit, method);
//...
if (returnType == void.class) {
throw methodError(method, "Service methods cannot return void.");
}
return HttpServiceMethod.parseAnnotations(retrofit, method, requestFactory);
}
// HTTP API 인터페이스 함수를 호출할 때 동작을 위임받을 함수
// 실제로 API를 호출하는 역할을 함
abstract @Nullable T invoke(Object[] args);
}
이때, RequestFactory 를 생성하고, HttpServiceMethod 의 parseAnnotations()를 호출해서 나머지 동작(주어진 함수를 처리할 ServiceMethod 찾기)을 위임한다.
ServiceMethod 는 invok() 라는 추상 메서드를 갖고 있다. HTTP 서비스 인터페이스 함수를 호출할 때 실질적으로 실행될 동작을 구현해야한다.
HttpServiceMethod
위에서 살펴본 ServiceMethod를 상속하는 추상 클래스이다. parseAnnotation() 는 ServiceMethod.parseAnnotation()
가 생성해서 넘겨준 RequestFactory를 통해서 주어진 함수가 suspend 키워드를 사용하고 있는지 확인한다. suspend 키워드를 사용했다면 인터페이스 함수의 반환값을 Call 로 감싼 후에 이를 처리할 CallAdapter 를 찾는다. 반면에 suspend 키워드를 사용하지 않았다면 Call 로 감싸지 않고 CallAdapter를 찾는다. CallAdapter 에 대해서는 다음 포스팅에서 자세히 알아본다.
//HttpServiceMethod.java
static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotations(
Retrofit retrofit, Method method, RequestFactory requestFactory) {
boolean isKotlinSuspendFunction = requestFactory.isKotlinSuspendFunction;
// 함수 반환 타입 찾기 for CallAdapter
Type adapterType;
// suspend 함수는 함수 형태가 다르다 -> 반환타입 찾는 방법도 다르다
if (isKotlinSuspendFunction) {
Type[] parameterTypes = method.getGenericParameterTypes();
Type responseType =
Utils.getParameterLowerBound(
0, (ParameterizedType) parameterTypes[parameterTypes.length - 1]);
// Call로 감싸기
adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
} else {
adapterType = method.getGenericReturnType();
}
CallAdapter<ResponseT, ReturnT> callAdapter =
createCallAdapter(retrofit, method, adapterType, annotations);
if (!isKotlinSuspendFunction) {
// suspend 함수가 아닌 경우
return new CallAdapted<>(requestFactory, callFactory, responseConverter, callAdapter);
} else {
// suspend 함수인 경우
// Call 로 반환 타입 감싸기
adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
// SuspendForBody 반환
return (HttpServiceMethod<ResponseT, ReturnT>) new SuspendForBody<>(
requestFactory, callFactory, responseConverter,
(CallAdapter<ResponseT, Call<ResponseT>>) callAdapter, continuationBodyNullable);
}
}
// 실제로 HTTP API 인터페이스 함수를 호출할 때 실행될 동작
// Call 객체 생성 (OkHttpCall)
@Override
final @Nullable ReturnT invoke(Object[] args) {
Call<ResponseT> call = new OkHttpCall<>(requestFactory, args, callFactory, responseConverter);
// Call 변조
return adapt(call, args);
}
// Call 을 변조할 수 있는 추상 메서드
protected abstract @Nullable ReturnT adapt(Call<ResponseT> call, Object[] args);
그리고 parseAnnotation() 의 반환 값도 suspend 키워드 사용 유무에 따라 달라진다. 사용했다면 SuspendForBody 혹은 SuspendForResponse (코드에선 생략)를 반환한다. 반면에 사용하지 않았다면 CallAdapted 를 반환한다. 이 클래스들은 모두 HttpServiceMethod 를 상속한 클래스이다.
HttpServiceMethod 는 부모 클래스인 ServiceMethod 가 가지고 있는 추상 메서드인 invoke() 를 구현하고 있다. Call을 상속하는 OkHttpCall 을 생성하고, adapt() 함수를 거쳐 반환하고 있다.
adapt() 는 HttpServiceMethod 가 가지고 있는 추상 메서드로, 각 구체 클래스들(CallAdapted, SuspendForBody, SuspendForResponse)이 adapt()를 구현하고 있다. 이 함수의 목적은 위 코드 주석에서도 써놨듯이, HttpServiceMethod 가 생성한 Call 객체를 변조하는 것이다.
이를 통해 suspend 키워드를 사용하면서 Call 을 사용하지 않더라도, 결국은 내부적으로 Call 로 wrapping 된다는 것을 알 수 있다.
SuspendForBody
HttpServiceMethod 를 상속하기 때문에 adapt() 를 구현하고 있다. 이는 HttpServiceMethod.parseAnnotation() 에서 찾은 CallAdapter 를 사용해서 Call 을 변조한다. 그 다음 변조된 Call 을 가지고 Call.await() 확장함수를 호출한다. KotlinExtensions.await() 가 확장함수를 호출하는 부분이다. 자바 코드라서 이런 형태로 호출하는 것이다.
@Override
protected Object adapt(Call<ResponseT> call, Object[] args) {
call = callAdapter.adapt(call);
Continuation<ResponseT> continuation = (Continuation<ResponseT>) args[args.length - 1];
try {
return isNullable
? KotlinExtensions.awaitNullable(call, continuation)
: KotlinExtensions.await(call, continuation);
} catch (Exception e) {
return KotlinExtensions.suspendAndThrow(e, continuation);
}
}
Call.await()
해당 함수는 내부적으로 주어진 Call 에 대해 enqueue() 를 호출하고 있다. 따라서 HTTP 서비스 인터페이스 함수를 호출하면 바로 결과값을 얻을 수 있는 것이다. 구현부를 자세히 살펴보자.
suspend fun <T : Any> Call<T>.await(): T {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
cancel()
}
enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
val body = response.body()
if (body == null) {
val invocation = call.request().tag(Invocation::class.java)!!
val method = invocation.method()
val e = KotlinNullPointerException("Response from " +
method.declaringClass.name +
'.' +
method.name +
" was null but response body type was declared as non-null")
continuation.resumeWithException(e)
} else {
continuation.resume(body)
}
} else {
continuation.resumeWithException(HttpException(response))
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
continuation.resumeWithException(t)
}
})
}
}
아마 익숙해보일 것이다. 위에서 enqueue()의 복잡한 에러 처리를 위해 만들었던 확장함수와 형태가 동일하다. 다만 콜백 형태를 suspend 함수의 형태로 바꾸기 위해서 suspendCancellableCoroutine 을 사용하고 있다. 이에 대한 자세한 내용은 [Coroutine] suspendCancellableCoroutine - Callback을 coroutine으로 변경 을 참고하면 좋다.
즉, 결론을 이야기 하자면 Retrofit 을 통해 HTTP API 인터페이스 함수를 호출하면 항상 Call 을 사용한다. 다만 suspend 키워드를 사용하면 내부적으로 suspendCancellableCoroutine 을 사용해서 Call 을 enqueue() 하고, Payload 에 대한 유효성을 검증하고 반환한다. 유효하지 않는 경우, 위에 enqueue() 확장함수와 동일한 조건으로 에러를 던진다.
viewModelScope.launch {
try {
val listRepos = gitHubService.listRepos("winter223")
// UI에 적용
} catch (e: Exception) {
//에러 처리
}
}
이제 다시 한 번 API 인터페이스 함수를 호출하는 방법을 살펴보면, 반환값인 listRepos 는 null이 아니며 HTTP status code 200 대의 정상 응답만으로 전달된 값임을 확실히 알 수 있다. 그리고 catch 에서 잡힌 에러는 NullPointerException, HttpException, 혹은 Retrofit 이 정의한 요청 에러 중 하나일 것이라는 것 또한 알 수 있다.
Retrofit suspend 함수의 CoroutineContext
흔히 Coroutine 으로 비동기 작업을 수행할 때, CoroutineContext 를 지정한다. 대부분 ViewModel 에서 suspend 함수를 호출할 텐데, viewModelScope는 기본적으로 Dispatcher.Main Context를 가진다. 따라서 다음과 같이 Context 를 변경하는 코드를 함께 사용한다. (데이터 레이어를 구분해서 사용한다면, Repository 에서 해당 작업을 하는 경우도 많다.)
viewModelScope.launch {
try {
val listRepos = withContext(Dispatcher.IO) {
gitHubService.listRepos("winter223")
}
// UI에 적용
} catch (e: Exception) {
//에러 처리
}
}
작업이 오래 걸리는 suspend 함수를 호출할 때에는 백그라운드 스레드에서 실행하고, 결과 값 등은 다시 메인 스레드에서 처리하도록 하는 것이다.
그런데 Retrofit 의 HTTP API 인터페이스 함수도 이렇게 Context 를 바꿔줘야할까?
앞서 Retrofit 의 suspend 함수가 내부적으로 어떻게 실행되는지 함께 살펴봤다. 결국은 Call 의 enqueue()를 사용하고 있는데, 해당 함수가 알아서 백그라운드 스레드에서 비동기 작업을 수행하고, 메인 스레드에서 콜백을 호출하기 때문이다. 따라서 CoroutineContext 를 변경할 필요가 없다. 그냥 호출하더라도 Main -> IO -> Main 으로 알아서 스레딩이 된다.
다음 포스팅 - CallAdapter 활용하기
'Android' 카테고리의 다른 글
Retrofit Call, 제대로 사용하기 - (2) (0) | 2022.04.04 |
---|