Retrofit
안드로이드의 네트워크 통신은 Retrofit 등장 이전과 이후로 나뉜다고 해도 과언이 아니라고 생각합니다. 그만큼 Retrofit 은 안드로이드 프로그래밍 생태계에 지대한 영향을 미쳤습니다.
Retrofit 을 사용하는 방법은 크게 두 가지로 나뉠 텐데요. 첫 번째는 Retrofit 이 제공하는 Callback<T> 클래스를 이용하는 방법이고, 두 번째는 Kotlin Coroutines 와 함께 사용하는 방법이 되겠습니다.
저는 Coroutines 에 익숙해지고 나서부터 보통 두 번째 방법을 이용해왔습니다. 그 전에는 (Java 로 개발 공부하던 시절) 당연히 첫 번째 방법을 이용했고요. HttpUrlConnection 과 AsyncTask 도 활용해보고 그랬었는데, 확실히 Retrofit 을 사용하고부터는 개발 속도가 빨라졌던 것 같습니다.
Coroutines 를 통해 간편하게 비동기 작업을 처리할 수 있으니 깊은 고민 없이 두 번째 방법을 이용했던 것 같은데, 문득 더 이상 enqueue() 메서드를 실행하지 않는 스스로를 보면서 '각 방법은 어떤 방식으로 동작하는가' 에 대한 의문이 생겼습니다.
Retrofit 이 네트워크 통신을 수행하는 과정
Retrofit 을 이용한다면, 몇 가지 규칙이 적용된 인터페이스를 생성하는 것만으로 우리는 쉽게 Http 통신을 수행할 수 있습니다. Retrofit 이 우리가 선언한 인터페이스의 코드를 기반으로 통신 관련 코드를 생성해주기 때문인데요.
Hilt 코드와 같이 컴파일 타임에 생성될 것 같지만, 눈 씻고 찾아봐도 프로젝트 패키지 내에 생성된 코드는 확인할 수 없습니다. 이유는 간단합니다. Java 의 Reflect 를 활용하여 런타임에 코드가 생성됩니다.
메서드 생성
ServiceMethod<?> loadServiceMethod(Method method) {
ServiceMethod<?> result = serviceMethodCache.get(method);
if (result != null) return result;
synchronized (serviceMethodCache) {
result = serviceMethodCache.get(method);
if (result == null) {
result = ServiceMethod.parseAnnotations(this, method);
serviceMethodCache.put(method, result);
}
}
return result;
}
Retrofit 소스 중 일부입니다. 메서드 명에 create 와 같이 생성을 의미하는 접두사가 붙지 않은 것은, 내부적으로 Reflect 를 이용하여 단 한 번만 호출 코드를 생성하고 이를 재활용하는 형태이기 때문입니다.
코드를 생성하고 재활용하는 이유는 사실 간단한데, Java 의 Reflect 는 꽤 무거운 작업을 수행합니다. 이에 관한 설명은 ServiceMethod.parseAnnoatations() 메서드의 반환 타입인 ServiceMethod<T> 에 주석으로 작성되어 있습니다.
abstract class HttpServiceMethod<ResponseT, ReturnT> extends ServiceMethod<ReturnT> {
/**
* Inspects the annotations on an interface method to construct a reusable service method that
* speaks HTTP. This requires potentially-expensive reflection so it is best to build each service
* method only once and reuse it.
*/
ServiceMethod.parseAnnotations() 메서드는 해당 추상 클래스의 구현체인 HttpServiceMethod<T> 의 parseAnnotations() 를 연이어 호출합니다.
static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotations(
Retrofit retrofit, Method method, RequestFactory requestFactory) {
boolean isKotlinSuspendFunction = requestFactory.isKotlinSuspendFunction;
boolean continuationWantsResponse = false;
boolean continuationBodyNullable = false;
Annotation[] annotations = method.getAnnotations();
Type adapterType;
if (isKotlinSuspendFunction) {
Type[] parameterTypes = method.getGenericParameterTypes();
Type responseType =
Utils.getParameterLowerBound(
0, (ParameterizedType) parameterTypes[parameterTypes.length - 1]);
if (getRawType(responseType) == Response.class && responseType instanceof ParameterizedType) {
// Unwrap the actual body type from Response<T>.
responseType = Utils.getParameterUpperBound(0, (ParameterizedType) responseType);
continuationWantsResponse = true;
} else {
// TODO figure out if type is nullable or not
// Metadata metadata = method.getDeclaringClass().getAnnotation(Metadata.class)
// Find the entry for method
// Determine if return type is nullable or not
}
adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
annotations = SkipCallbackExecutorImpl.ensurePresent(annotations);
} else {
adapterType = method.getGenericReturnType();
}
... (생략)
}
앞서 기술했던 두 가지 방법에 대한 분기 처리를 해주고 있는 코드가 존재하는데요.
isKotlinSuspendFunction 프로퍼티를 통해 suspend 키워드가 붙었는지 확인하고, 해당 프로퍼티가 true 라면 Response<T> 를 활용하였는지 추가로 검사합니다.
if (!isKotlinSuspendFunction) {
return new CallAdapted<>(
requestFactory,
callFactory,
responseConverter,
callAdapter);
} else if (continuationWantsResponse) {
//noinspection unchecked Kotlin compiler guarantees ReturnT to be Object.
return (HttpServiceMethod<ResponseT, ReturnT>)
new SuspendForResponse<>(
requestFactory,
callFactory,
responseConverter,
(CallAdapter<ResponseT, Call<ResponseT>>) callAdapter);
} else {
//noinspection unchecked Kotlin compiler guarantees ReturnT to be Object.
return (HttpServiceMethod<ResponseT, ReturnT>)
new SuspendForBody<>(
requestFactory,
callFactory,
responseConverter,
(CallAdapter<ResponseT, Call<ResponseT>>) callAdapter,
continuationBodyNullable);
}
Http 통신을 위해 생성된 메서드의 반환에는 다음 3가지의 분기에 따라 서로 다른 타입을 반환합니다.
1. suspend 키워드가 없을 때
2. suspend 키워드가 있고, 반환 타입이 Response<T> 일 때
3. suspend 키워드가 있고, 반환 타입이 T 일 때
1. suspend 키워드가 없을 때
suspend 키워드를 붙이지 않았다면 반환 타입을 Call<T> 로 설정하여야만 합니다. 왜 꼭 그래야만 할까요? 내부 구현을 살펴봅니다.
CallAdapter<ResponseT, ReturnT> callAdapter =
createCallAdapter(retrofit, method, adapterType, annotations);
Type responseType = callAdapter.responseType();
if (responseType == okhttp3.Response.class) {
throw methodError(
method,
"'"
+ getRawType(responseType).getName()
+ "' is not a valid response body type. Did you mean ResponseBody?");
}
createCallAdapter() 메서드를 찾아가면 nextCallAdapter() 메서드를 확인할 수 있고, 해당 메서드는 CallAdapter<?, ?> 를 반환하도록 선언되어 있습니다.
public CallAdapter<?, ?> nextCallAdapter(
@Nullable CallAdapter.Factory skipPast, Type returnType, Annotation[] annotations) {
Objects.requireNonNull(returnType, "returnType == null");
Objects.requireNonNull(annotations, "annotations == null");
int start = callAdapterFactories.indexOf(skipPast) + 1;
for (int i = start, count = callAdapterFactories.size(); i < count; i++) {
CallAdapter<?, ?> adapter = callAdapterFactories.get(i).get(returnType, annotations, this);
if (adapter != null) {
return adapter;
}
}
Retrofit 의 프로퍼티인 callAdapterFactories 를 순회하며 적절한 CallAdapter 가 있는지 검사한 뒤 있으면 반환해주고, 그렇지 않으면 예외를 발생시킵니다. CallAdapter<R, T> 는 Retrofit 인스턴스에 설치된 팩토리에 의해 생성되는데, 기본적으로 Retrofit.Builder 의 build() 메서드가 실행될 때 callAdapterFactories 에 추가됩니다.
callAdapterFactories.addAll(platform.defaultCallAdapterFactories(callbackExecutor));
DefaultCallAdapterFactory() 가 생성되어 추가되며, 다시 nextCallAdapter() 메서드의 구현을 보면 각 팩토리마다 get() 메서드를 호출하고 있음을 확인할 수 있습니다.
@Override
public @Nullable CallAdapter<?, ?> get(
Type returnType, Annotation[] annotations, Retrofit retrofit) {
if (getRawType(returnType) != Call.class) {
return null;
}
if (!(returnType instanceof ParameterizedType)) {
throw new IllegalArgumentException(
"Call return type must be parameterized as Call<Foo> or Call<? extends Foo>");
}
final Type responseType = Utils.getParameterUpperBound(0, (ParameterizedType) returnType);
final Executor executor =
Utils.isAnnotationPresent(annotations, SkipCallbackExecutor.class)
? null
: callbackExecutor;
return new CallAdapter<Object, Call<?>>() {
@Override
public Type responseType() {
return responseType;
}
@Override
public Call<Object> adapt(Call<Object> call) {
return executor == null ? call : new ExecutorCallbackCall<>(executor, call);
}
};
}
get() 메서드의 구현을 보면, 반환 타입이 Call<T> 인 경우에만 추가적으로 코드를 진행하고, 그렇지 않으면 null 을 반환하도록 하여 이것이 예외를 일으키도록 작성되어 있습니다.
복잡하게 찾아왔지만, CallAdapter 가 중요한 이유는 내부적으로 생성되는 백그라운드 스레드에서 통신 작업을 수행하고 이를 다시 메인 스레드로 돌려줄 때 Callback 을 사용하도록 구현되어 있고, 이와 같은 구조를 전달하기 위해 CallAdapter 를 컨테이너로 활용하기 때문입니다.
이러한 구조 덕분에, 개발자는 '백그라운드 스레드를 직접 생성하고, 해당 스레드에서 통신 작업을 수행하고, 이를 다시 메인 스레드로 돌려주는' 일련의 작업을, 인터페이스에 선언하는 메서드의 반환 타입을 Call<T> 로 선언하는 것만으로도 달성할 수 있습니다.
2. suspend 키워드가 있고, 반환 타입이 Response<T> 일 때
if (getRawType(responseType) == Response.class && responseType instanceof ParameterizedType) {
// Unwrap the actual body type from Response<T>.
responseType = Utils.getParameterUpperBound(0, (ParameterizedType) responseType);
continuationWantsResponse = true;
}
반환 타입이 Response<T> 인 경우를 알아내기 위해 별도의 조건문을 수행합니다. 내부에서는 실질적인 타입을 얻어 responseType 이라는 변수에 저장해두고, continuationWantsResponse 변수의 값을 true 로 할당합니다.
if (!isKotlinSuspendFunction) {
return new CallAdapted<>(requestFactory, callFactory, responseConverter, callAdapter);
} else if (continuationWantsResponse) {
//noinspection unchecked Kotlin compiler guarantees ReturnT to be Object.
return (HttpServiceMethod<ResponseT, ReturnT>)
new SuspendForResponse<>(
requestFactory,
callFactory,
responseConverter,
(CallAdapter<ResponseT, Call<ResponseT>>) callAdapter);
}
이후 continuationWantsResponse 변수를 다시 활용하는데요. 이 때는 CallAdapter 의 두 타입 파라미터에 대해 ResponseT, Call<ResponseT> 타입으로 캐스팅하여 반환합니다.
3. suspend 키워드가 있고, 반환 타입이 T 일 때
} else {
//noinspection unchecked Kotlin compiler guarantees ReturnT to be Object.
return (HttpServiceMethod<ResponseT, ReturnT>)
new SuspendForBody<>(
requestFactory,
callFactory,
responseConverter,
(CallAdapter<ResponseT, Call<ResponseT>>) callAdapter,
continuationBodyNullable);
}
2. suspend 키워드가 있고, 반환 타입이 Response<T> 일 때 와 비슷하게 진행됩니다. 다른 점은 continuationBodyNullable 이 추가되는데요. 반환되는 타입의 명칭을 보면 2번은 Response, 3번은 Body 로 되어 있습니다. 또한, continuationBodyNullable 은 최초에 false 로 할당되고 변하지 않습니다. 즉, continuationBody 가 null 일 수 없다고 가정한 상태로 프로세스가 진행됩니다. 인터페이스 메서드의 반환 타입이 Response<T> 가 아닌 T 일 경우, 통신 결과로 null 이 반환되면 문제가 발생합니다.
3가지 분기를 간략하게 정리하자면 다음과 같습니다.
1. suspend 키워드가 없을 때 - CallAdapter 타입으로 반환받기 위해서는 반환 타입을 Call<T> 로 지정해야 한다.
2. suspend 키워드가 있고, 반환 타입이 Response<T> 일 때 - 추가로 취해야 할 조치는 없다.
3. suspend 키워드가 있고, 반환 타입이 T 일 때 - 통신 수행의 결과로 null 이 들어 올 수 있으니, 이에 대한 조치를 취해야 한다.
Retrofit 의 create() 메서드를 호출할 때, 인터페이스에 선언한 메서드들을 검증합니다. 검증 과정에서 생성된 메서드들은 그대로 serviceMethodCache 라는 ConcurrentHashMap 에 캐싱됩니다.
for (Method method : service.getDeclaredMethods()) {
if (!platform.isDefaultMethod(method) && !Modifier.isStatic(method.getModifiers())) {
loadServiceMethod(method);
}
}
메서드 호출
new InvocationHandler() {
private final Platform platform = Platform.get();
private final Object[] emptyArgs = new Object[0];
@Override
public @Nullable Object invoke(Object proxy, Method method, @Nullable Object[] args)
throws Throwable {
// If the method is a method from Object then defer to normal invocation.
if (method.getDeclaringClass() == Object.class) {
return method.invoke(this, args);
}
args = args != null ? args : emptyArgs;
return platform.isDefaultMethod(method)
? platform.invokeDefaultMethod(method, service, proxy, args)
: loadServiceMethod(method).invoke(args);
}
});
인터페이스에 선언한 메서드를 호출할 수 있도록 만드는 데 사용되는 객체입니다. 캐싱된 각 메서드들은 호출될 때 캐시에서 참조됩니다. 메서드 본문 마지막줄의 loadServiceMethod(method).invoke(args) 를 통해 개발자가 코드를 작성할 때 인터페이스에 선언한 메서드를 실행하는 것만으로 Http 통신이 가능하게 합니다.
생성 시 분기하여 반환한 타입에 따라 호출 시 행위도 조금 씩 다릅니다. 세 타입 모두 HttpServiceMethod 클래스를 구현하는 구현체이지만 invoke() 메서드는 final 로 선언되어 있기 때문에, 각 메서드에서 invoke() 해도 모두 같은 invoke() 메서드를 실행하게 됩니다.
@Override
final @Nullable ReturnT invoke(Object[] args) {
Call<ResponseT> call = new OkHttpCall<>(requestFactory, args, callFactory, responseConverter);
return adapt(call, args);
}
invoke() 메서드 실행 시 반환되는 adapt() 메서드는 추상 메서드이므로, 각 타입에서 오버라이딩해야 합니다.
각 타입에서 오버라이딩하는 것은 맞지만, CallAdapter 의 adapt() 메서드를 실행하는 건 모두 같은데요.
DefaultCallAdapterFactory 에 정의된 adapt() 는 다음과 같습니다.
@Override
public Call<Object> adapt(Call<Object> call) {
return executor == null ? call : new ExecutorCallbackCall<>(executor, call);
}
@SkipCallbackExecutor 어노테이션을 작성한 경우가 아니라면 ExecutorCallbackCall 객체를 생성하여 enqueue() 메서드를 수행할 수 있도록 준비합니다.
다음은 분기에 따른 타입들이 재정의한 adapt() 메서드 입니다.
1. CallAdapted (suspend 키워드가 없을 때)
@Override
protected ReturnT adapt(Call<ResponseT> call, Object[] args) {
return callAdapter.adapt(call);
}
단순하게 adapt() 를 실행하여 enqueue() 됩니다.
2. SuspendForResponse (suspend 키워드가 있고, 반환 타입이 Response<T> 일 때)
@Override
protected Object adapt(Call<ResponseT> call, Object[] args) {
call = callAdapter.adapt(call);
//noinspection unchecked Checked by reflection inside RequestFactory.
Continuation<Response<ResponseT>> continuation =
(Continuation<Response<ResponseT>>) args[args.length - 1];
// See SuspendForBody for explanation about this try/catch.
try {
return KotlinExtensions.awaitResponse(call, continuation);
} catch (Exception e) {
return KotlinExtensions.suspendAndThrow(e, continuation);
}
}
Continuation<T> 객체를 생성하고 Kotlin.Extensions.awaitResponse() 메서드의 파라미터로 call 과 함께 넘겨줍니다. 이후 Continuation<T> 객체에 결과를 담아 리턴합니다.
3. SuspendForResponseBody (suspend 키워드가 있고, 반환 타입이 T 일 때)
@Override
protected Object adapt(Call<ResponseT> call, Object[] args) {
call = callAdapter.adapt(call);
//noinspection unchecked Checked by reflection inside RequestFactory.
Continuation<ResponseT> continuation = (Continuation<ResponseT>) args[args.length - 1];
// Calls to OkHttp Call.enqueue() like those inside await and awaitNullable can sometimes
// invoke the supplied callback with an exception before the invoking stack frame can return.
// Coroutines will intercept the subsequent invocation of the Continuation and throw the
// exception synchronously. A Java Proxy cannot throw checked exceptions without them being
// declared on the interface method. To avoid the synchronous checked exception being wrapped
// in an UndeclaredThrowableException, it is intercepted and supplied to a helper which will
// force suspension to occur so that it can be instead delivered to the continuation to
// bypass this restriction.
try {
return isNullable
? KotlinExtensions.awaitNullable(call, continuation)
: KotlinExtensions.await(call, continuation);
} catch (Exception e) {
return KotlinExtensions.suspendAndThrow(e, continuation);
}
}
다소 긴 주석이 포함되어 있는데, 주석의 내용은 try-catch 문이 포함되어 있는 이유에 대한 설명입니다. Java Proxy 는 인터페이스 메서드에 선언되지 않은 예외는 던질 수 없기 때문에, 통신이 제대로 수행되지 않더라도 CoroutineScope 가 취소되지 않을 수 있습니다. 이러한 문제를 피하기 위해 try-catch 문이 존재하고, 예외가 발생하여 catch 블록이 수행될 때 suspendAndThrow 를 통해 CoroutineScope 를 취소할 수 있도록 합니다. 즉, Java 와 Kotiln 모두에서 상호 운용 가능하도록 구현해두었다는 것입니다.
전체적인 동작은 2번과 유사합니다.
2번과 3번 모두, retrofit 패키지 내부의 KotlinExtensions.kt 파일에 선언된 Call<T> 의 확장 함수를 실행합니다. 약간의 차이는 있지만, Call<T> 를 enqueue() 하여 통신을 수행하는 것은 같습니다.
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)
}
})
}
}
Call<T> 객체의 enqueue() 메서드를 실행하는 것, 그리고 suspend 키워드 + T 로 타입 선언하여 통신하는 것의 차이가 늘 궁금했는데, 이번 기회에 제대로 알아봤습니다.
Retrofit 은 정말 자주 사용하는 라이브러리인데, 사용하면 할수록 '어떻게 활용할까' 만 생각하지, '내부 구조는 어떻게 되어 있을까' 를 떠올리기 쉽지 않은 것 같습니다. 지금까지 그래왔듯, 앞으로도 꾸준히 의문을 품을 수 있다면 좋겠습니다.
'Android > Tech' 카테고리의 다른 글
Activity Lifecycle 의 각 콜백 메서드에서는 어떤 처리를 하는가? (0) | 2023.10.24 |
---|---|
LayoutInflater 가 xml 을 객체화 하는 과정 (0) | 2023.10.17 |
RecyclerView 의 업데이트와 DiffUtil (0) | 2023.09.29 |
Android ViewModel 의 onCleared() 는 언제 호출되는가? (0) | 2023.09.14 |
@Retention 은 어떻게 동작하는가? (0) | 2023.09.05 |