Retrofit2
Retrofit2는 서버와 클라이언트(Android)의 HTTP 통신을 위해 Square사에서 제공하는 네트워크 라이브러리입니다. 기본적으로 Type-Safe한 형태로 지원되며, 내부적으로 OkHttp 클라이언트와 함께 동작합니다.
그동안 어떻게 사용해왔었나
우리는 Retrofit2를 사용할 때 반드시 API Interface를 정의했었습니다. 보통 아래와 같은 3가지 방식으로 작성합니다.
internal interface ApiService {
// 1)
@GET("boxoffice/searchDailyBoxOfficeList.json")
fun getDailyBoxOffice(
@Query("key") apiKey: String = BuildConfig.KOBIS_API_KEY,
@Query("targetDt") targetDate: String,
): Call<KobisBoxOfficeResponse>
// 2)
@GET("boxoffice/searchDailyBoxOfficeList.json")
suspend fun getDailyBoxOffice(
@Query("key") apiKey: String = BuildConfig.KOBIS_API_KEY,
@Query("targetDt") targetDate: String,
): KobisBoxOfficeResponse
// 3)
@GET("boxoffice/searchDailyBoxOfficeList.json")
suspend fun getDailyBoxOffice(
@Query("key") apiKey: String = BuildConfig.KOBIS_API_KEY,
@Query("targetDt") targetDate: String,
): Response<KobisBoxOfficeResponse>
}
Call
가장 기본적인 방식은 1번 방식입니다. Retrofit2의 Call 인터페이스를 사용하게 되면 Call#execute, Call#enqueue를 통해 요청 및 응답 방식을 선택할 수 있습니다.
1. Call#execute
public interface Call<T> extends Cloneable {
/**
* Synchronously send the request and return its response.
*
* @throws IOException if a problem occurred talking to the server.
* @throws RuntimeException (and subclasses) if an unexpected error occurs creating the request or
* decoding the response.
*/
Response<T> execute() throws IOException;
...
}
// RemoteDataSource
fun getDailyBoxOffice(targetDate: String): List<DailyBoxOfficeModel> {
val response = kobisService.getDailyBoxOffice(targetDate = targetDate)
return response.execute().body()?.boxOfficeResponse?.dailyBoxOfficeList?.map {
it.toDataModel()
}.orEmpty()
}
Call#execute를 사용하게 되면 서버에 동기적으로 네트워크 요청을 보내고 응답을 반환하게 됩니다.
동기적으로 요청을 보내는 것이 핵심입니다. 안드로이드는 싱글 스레드(UI Thread)로 동작하기 때문에 요청을 보내고 응답을 받을 때 까지 스레드가 block되게 됩니다. 즉, 요청이 진행되는 동안 UI가 차단되는 것을 의미하며, NetworkOnMainThreadException를 발생시키기 때문에 비권장 메서드로 이해할 수 있습니다.
2. Call#enqueue
public interface Call<T> extends Cloneable {
/**
* Asynchronously send the request and notify {@code callback} of its response or if an error
* occurred talking to the server, creating the request, or processing the response.
*/
void enqueue(Callback<T> callback);
...
}
// RemoteDataSource
fun getDailyBoxOffice(targetDate: String): List<DailyBoxOfficeModel> {
val response = kobisService.getDailyBoxOffice(targetDate = targetDate)
response.enqueue(object : Callback<KobisBoxOfficeResponse> {
override fun onResponse(
call: Call<KobisBoxOfficeResponse>,
response: Response<KobisBoxOfficeResponse>,
) {
// 네트워크 연결 성공
}
override fun onFailure(call: Call<KobisBoxOfficeResponse>, t: Throwable) {
// 네트워크 연결 실패
}
})
}
Call#enqueue를 사용하면 서버에 비동기적 요청을 하고 응답을 반환하게 됩니다.
Call#execute와 달리 비동기적으로 요청을 하기 때문에 UI 차단이 발생하지 않습니다. 또한, Call#enqueue를 사용할 경우 요청 실패시 발생하던 IOException 대신 Callback을 통한 네트워크 연결 성공, 실패 분기를 통해 발생할 수 있는 예외에 대한 다양한 처리가 가능해집니다.
하지만, 비동기처리에 코루틴을 활용하고 있다면 Call#enqueue를 사용하지 않아도 됩니다. 콜백 형태의 코드를 작성할 경우 단일 코드 블럭에서 순차적인 코드 작성으로 가독성 측면의 이점을 갖는 코루틴의 장점을 활용하지 못하게 됩니다.
하지만 굳이 Call타입을 코루틴과 함께 사용하고 싶다면? 이 경우, KotlinExtension.kt의 await을 통해 Call 타입을 코루틴과 함께 활용할 수 있습니다.
Response
3번에 해당하는 Response을 사용하게 되면 요청 이후의 응답을 받을 수 있습니다. 이 방식은 suspend함수와 함께 사용하는 Corotuine 환경에서 사용할 수 있으며, suspend를 사용하지 않고 Response를 사용하거나 객체 타입을 지정하면 에러가 발생합니다.
이것이 가능한 이유는 suspend를 사용하면 내부적으로 Call<T> 객체를 생성해주기 때문인데요. 그래서 우리는 직접 Call#enqueue을 호출하지 않아도 됩니다. 내부적으로 enqueue 과정을 해주기 때문이죠.
하지만, 앞서 살펴본 Call#enqueue와 달리 응답이 성공적인지, 실패인지 확인할 수 있는 콜백이 없기 때문에 별도의 예외 처리 과정은 필요합니다.
그렇다면 Retrofit은 이 enqueue를 어떻게 구현하고 있을까요?
Retrofit#create
TL;DR
Retrofit.create로 만든 ApiInterface의 구현체인 프록시 객체의 함수를 호출하면 내부적으로 수행될 일이 InvocationHandler.invoke에 위임되고, 여기서 defaultMethod일 경우 그냥 호출되고 defaultMethod가 아닐 경우 loadService를 호출한다. 보통 false가 반환되기 때문에 loadService에서 많은 동작이 이루어진다.
앞서 살펴본 ApiService는 인터페이스입니다. 인터페이스에 정의한 메서드는 반드시 구현체를 정의해야 사용할 수 있지만, 우리는 한번도 이 인터페이스의 구현체를 만든적이 없습니다. 그렇다면 이 인터페이스가 어떻게 활용될까요?
싱글톤 객체에 Retrofit 인스턴스를 직접 생성하여 서비스 로케이터처럼 접근하는 방법도 있지만, 의존성 주입을 많이 활용하는 요즘에는 아래와 같이 코드를 작성할 것입니다.
@Module
@InstallIn(SingletonComponent::class)
internal class ServiceModule {
@Provides
@Singleton
fun provideRetrofit(
@DefaultOkHttpClient okHttpClient: OkHttpClient,
converterFactory: Converter.Factory,
): Retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(converterFactory)
.build()
@Provides
@Singleton
fun provideApiService(
retrofit: Retrofit
): ApiService = retrofit.create()
// retrofit.create(ApiService::class.java)
}
해당 Hilt 모듈에서는 빌더 패턴을 이용하여 통신에 필요한 구성을 하고 Retrofit 인스턴스를 만들어 제공하고 있습니다. 두번째 메서드를 살펴보면 create를 통해 인스턴스를 만들고 있습니다.
일반적으로는 메서드에 주석처리한 것 처럼 파라미터를 전달하여 명시적으로 사용할 수 있지만, KotlinExtension.kt에는 create 메서드가 inline으로 선언됨과 동시에 reified를 활용하여 런타임에 타입을 추론할 수 있게 됩니다.
inline fun <reified T> Retrofit.create(): T = create(T::class.java)
create 메서드는 내부적으로 리플렉션을 통해 전달하는 ApiService 인터페이스를 Java Class로 변환합니다.
내부 코드를 한번 살펴보겠습니다.
public <T> T create(final Class<T> service) {
validateServiceInterface(service);
return (T)
Proxy.newProxyInstance(
service.getClassLoader(),
new Class<?>[] {service},
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);
}
});
}
private void validateServiceInterface(Class<?> service) {
if (!service.isInterface()) {
throw new IllegalArgumentException("API declarations must be interfaces.");
}
...
}
먼저, validateServiceInterface를 통해 전달받은 ApiService java Class가 인터페이스 형태인지 확인합니다. 만약 인터페이스 타입이 아닐 경우 IllegalArgumentException을 throw하게 됩니다.
이 후, 프록시 패턴을 통해 Retrofit 인스턴스를 제공해주고 있습니다. 간단하게 프록시 패턴에 대해 알아본다면, 프록시 패턴은 특정 객체에 일부 기능을 추가하거나 수정하려고 할 때 원본 객체 대신 프록시 객체를 사용하는 디자인 패턴입니다.
프록시에 대해서 잠깐 알아보고 가겠습니다.
Proxy란?
Proxy는 타겟의 기능을 확장하거나, 타겟에 대한 접근을 제어하기 위해 사용하는 클래스를 말합니다. 아래 예시를 봅니다.
interface Hello {
fun sayHello(name: String): String
fun sayHi(name: String): String
}
class HelloTarget : Hello {
override fun sayHello(name: String): String {
return "Hello $name"
}
override fun sayHi(name: String): String {
return "Hi $name"
}
}
Hello라는 인터페이스가 있고, 이를 구현한 HelloTarget 클래스가 있습니다. 이때 Proxy를 이용하면 Hello의 메소드를 변경하지 않고 대문자로 변환하는 부가기능을 추가할 수 있습니다.
class HelloUpperCase : Hello {
private val helloTarget = HelloTarget()
override fun sayHello(name: String): String {
return helloTarget.sayHello(name).toUpperCase()
}
override fun sayHi(name: String): String {
return helloTarget.sayHi(name).toUpperCase()
}
}
HelloUppercase라는 Hello의 구현체를 따로 만들고, HelloTarget의 객체를 선언해줍니다. 그리고 타겟을 가져와 기능을 수행하고, 결과를 다시 대문자로 변환합니다.
클라이언트에서 Hello의 메소드를 호출하면 HelloTarget이 위임받아 원래의 일을 하고, toUpperCase()라는 메소드를 통해 대문자 변환이라는 부가 기능을 추가한 것입니다. 이처럼 Proxy를 사용하면 타겟 코드의 수정 없이 타겟의 기능을 확장하거나 부가기능을 추가해줄 수 있습니다!
Dynamic Proxy
그런데 위와 같은 Prxoy는 인터페이스를 직접 구현해야 하며, 중복이 발생한다는 단점이 있습다.
sayHello()와 sayHi() 중에서 한개만 확장하고 싶어도, 모든 메소드를 override해주어야 하며, 코드를 보면 중복된 코드가 많아 보입니다.
Dynamic Proxy는 위의 단점들을 보완해줍니다. Dynamic Proxy는 일일이 프록시를 생성하는게 아닌, 런타임 시점에 인터페이스를 구현하는 클래스 또는 인스턴스를 만들어주는 것을 뜻합니다.
Proxy.java의 Dynamic Proxy를 생성해주는 newProxyInstance()를 살펴겠습니다.
1 번째 인자 : 프록시를 만들 클래스 로더
2 번째 인자 : 어떤 인터페이스에 대해 프록시를 만들 것인지 명시해줌
3 번째 인자 : InvocationHandler 인터페이스의 구현체
return 값 : 동적으로 만든 프록시 객체
여기서 살펴볼 것은 InvocationHandler입니다.
Proxy#newProxyInstance에 전달하는 파라미터 중, InvocationHandler의 구현체를 살펴보겠습니다
// retrofit dynamic proxy의 invoke() 메소드
@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);
}
InvocationHandler는 invoke() 메소드 한개만 가지고 있는 인터페이스로, Dynamic Proxy로 생성될 Proxy에서 메소드가 호출되면 invoke() 메소드가 호출됩니다. 여기서 메소드가 확장될수 있으며, 사용자가 어떤 메소드를 호출했는지에 대한 정보를 인자로 전달해줍니다.
정리하자면, 앞서 정의한 ApiService를 create하게 되면 ApiService 인터페이스의 구현체를 제공하는 프록시 인스턴스(Dynamic Proxy)가 생성되며 이 안에도 ApiService의 메서드들이 포함되게 됩니다. 따라서, getDailyBoxOffice를 호출하면 프록시 인스턴스의 getDailyBoxOffice도 호출될 것이며 이에 따라 InvocationHandler의 invoke도 반드시 호출되게 됩니다.
InvocationHandler#invoke의 마지막에서는 호출된 프록시 인스턴스의 메서드가 DefaultMethod인지 아닌지에 따라 처리되고 있습니다. 아래 코드를 통해 살펴보겠습니다.
@IgnoreJRERequirement // Only called on API 24+.
boolean isDefaultMethod(Method method) {
return hasJava8Types && method.isDefault();
}
..
public boolean isDefault() {
// Android-changed: isDefault() implemented using Executable.
return super.isDefaultMethodInternal();
}
javaDocs에서는 인터페이스에 public하고 non-abstract한 메서드로 정의된 함수 body가 존재하는 메서드를 Default Method로 설명하고 있습니다. 우리가 ApiService 인터페이스를 정의할 때 함수의 구현 블록은 따로 정의 하지 않습니다. 그렇기 때문에 isDefaultMethod는 false를 반환할 것입니다.
platform은 말 그대로 플랫폼을 의미합니다. 내부 코드를 확인해보면 Android를 반환하고 있습니다.
결국, loadServiceMethod의 invoke 부분에서 실제 Retrofit의 내부 동작이 이루어진다는 것을 파악할 수 있습니다.
Retrofit#loadServiceMethod
TL;DR
serviceMethodCache를 활용하여 요청되는 메서드들을 재사용한다. 캐싱된 메서드가 없다면 직접 해당 메서드 정보를 파싱하여 serviceMethodCache에 저장한다.
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#loadServiceMethod에서는 serviceMethodCache라는 캐시를 활용합니다. serviceMethodCache는 Map으로 구현되어 있으며, 호출되는 메서드들의 캐싱이 이루어집니다. 즉, 캐싱된 메서드가 없으면 parseAnnotation을 수행하여 캐시에 저장하고, 동일한 메서드가 재요청되면 캐시에 저장된 메서드를 재사용하는 것을 확인할 수 있습니다.
초기 빌드 후 네트워크 요청시에는 당연히 캐싱된 메서드가 없을 것이기 때문에, 첫 요청을 가정하고 ServiceMethod#parseAnnotation을 살펴보겠습니다.
ServiceMethod#parseAnnotation
TL;DR
요청된 메서드의 정보들을 1차적으로 검사하여 파싱하는 역할을 수행한다. 선언한 HTTP 어노테이션, 메서드 반환 타입 등을 검사한다.
abstract class ServiceMethod<T> {
static <T> ServiceMethod<T> parseAnnotations(Retrofit retrofit, Method method) {
RequestFactory requestFactory = RequestFactory.parseAnnotations(retrofit, method);
Type returnType = method.getGenericReturnType();
if (Utils.hasUnresolvableType(returnType)) {
throw methodError(
method,
"Method return type must not include a type variable or wildcard: %s",
returnType);
}
if (returnType == void.class) {
throw methodError(method, "Service methods cannot return void.");
}
return HttpServiceMethod.parseAnnotations(retrofit, method, requestFactory);
}
abstract @Nullable T invoke(Object[] args);
}
ServiceMethod#parseAnnotation은 static 메서드로 구현되어 있습니다. 코드를 살펴보면 RequestFactory#parseAnnotation에 Retrofit 인스턴스와 해당 메서드를 전달하기 때문에 실질적인 메서드 정보 파싱은 여기서 이루어지는 것을 확인할 수 있습니다. 즉, @GET, @POST와 같은 Retrofit에서 제공하는 HTTP 어노테이션들의 파싱이 이루어진다고 유추할 수 있습니다.
또한, ApiService에 선언한 메서드들의 반환 타입을 확인합니다. 확인이 불가능한 반환타입 또는 Void와 같이 반환타입을 명시하지 않을 경우 예외를 발생시키고 있습니다.
메서드가 정상적으로 동작이 가능함이 확인되면 Retrofit 인스턴스와 선언한 메서드, 메서드 정보가 담긴 requestFactory를 전달하여 HttpServiceMethod#parseAnnotation을 실행하게 됩니다. 그에 앞서, 메서드 정보를 파싱하는 역할을 수행하는 RequestFactory#parseAnnotation을 살펴보겠습니다.
RequestFactory#parseAnnotation
TL;DR
ApiService 메서드의 Request 정보 파싱을 수행하는 메서드이다. 파싱된 메서드의 Request 정보들을 RequestFactory에 저장하여 인스턴스를 반환한다.
RequestFactory에는 요청에 필요한 Retrofit 관련 정보들이 포함되어있고, 이를 빌더 패턴으로 구성합니다.
static RequestFactory parseAnnotations(Retrofit retrofit, Method method) {
return new Builder(retrofit, method).build();
}
RequestFactory(Builder builder) {
method = builder.method;
baseUrl = builder.retrofit.baseUrl;
httpMethod = builder.httpMethod;
relativeUrl = builder.relativeUrl;
headers = builder.headers;
contentType = builder.contentType;
hasBody = builder.hasBody;
isFormEncoded = builder.isFormEncoded;
isMultipart = builder.isMultipart;
parameterHandlers = builder.parameterHandlers;
isKotlinSuspendFunction = builder.isKotlinSuspendFunction;
}
RequestFactory build() {
for (Annotation annotation : methodAnnotations) {
parseMethodAnnotation(annotation);
}
if (httpMethod == null) {
throw methodError(method, "HTTP method annotation is required (e.g., @GET, @POST, etc.).");
}
...
return new RequestFactory(this);
}
RequestFactory.Builder#build를 호출하게 되면 RequesetFactory#parseAnnotation을 통해 전달된 메서드들의 정보들을 set하고 RequestFactory 인스턴스를 반환하게 됩니다.
해당 메서드에서는 대표적으로 메서드에 선언한 HTTP 어노테이션 파싱, 헤더 및 바디 파싱, Multi-Part 선언 여부등 다양한 정보들을 파싱하고 있습니다.
메서드를 구성하는 정보들이 워낙 많기 때문에 코드 전문이 궁금하신 분은 RequestFactory.java 파일을 확인해보시면 좋을 것 같습니다.
HttpServiceMethod#parseAnnotation
TL;DR
메서드가 suspend 가능한 메서드인지에 대한 확인 및 메서드 반환 타입 검사에 따른 분기 처리를 담당한다.
앞서 살펴봤던 ServiceMethod#parseAnnotation에서 요청 메서드의 Request 정보들이 담긴 RequestFactory 인스턴스와 함께 Retrofit 인스턴스, 선언한 요청 메서드를 파라미터로 전달받게 되면 static으로 선언된HttpServiceMethod#parseAnnotation이 수행됩니다.
코드 라인수가 굉장히 길기도 하고 메서드가 갖는 책임이 굉장히 크다고 생각하기 때문에, 저의 주관적인 견해대로 메서드의 책임을 분리하여 하나씩 살펴보겠습니다.
1. suspend 체크 및 이에 따른 필요 변수 구성
static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotations(
Retrofit retrofit, Method method, RequestFactory requestFactory) {
boolean isKotlinSuspendFunction = requestFactory.isKotlinSuspendFunction;
..
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();
}
...
}
static 메서드 블록 상단을 보면 눈에 띄는 네이밍을 갖는 변수들이 있습니다. isKotlinSuspendFunction을 통해 직관적으로 해당 메서드가 suspend 가능한지 확인하고 있습니다.
만약 메서드가 suspend 함수이면 아래와 같이 반환 타입을 Call 객체로 Wraping합니다.
if (isKotlinSuspendFunction) {
..
adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
..
}
그래서 처음에 언급한 것처럼 suspend함수를 선언만 하더라도 우리는 Retrofit 인터페이스의 반환 타입을 Call로 명시할 필요가 없어집니다. 내부적으로 Wrapping을 해주기 때문이죠.
suspend함수 확인 여부는 RequestFactory에서 확인하여 전달해줍니다
잠시, RequestFactory로 돌아가 관련 코드를 살펴보겠습니다.
private @Nullable ParameterHandler<?> parseParameter(
int p, Type parameterType, @Nullable Annotation[] annotations, boolean allowContinuation) {
ParameterHandler<?> result = null;
...
if (result == null) {
if (allowContinuation) {
try {
if (Utils.getRawType(parameterType) == Continuation.class) {
isKotlinSuspendFunction = true;
return null;
}
} catch (NoClassDefFoundError ignored) {
}
}
throw parameterError(method, p, "No Retrofit annotation found.");
}
return result;
}
RequestFactory에서 메서드의 parameter를 확인할 때 Continuation 객체가 있는지 확인하여 isKotlinSuspendFunction을 true로 만들어주고 있습니다.
코루틴을 공부해보신 분들은 아시겠지만 suspend는 메서드의 시그니처를 변경하면서 동작합니다. suspend 메서드의 마지막 파라미터로 Continuation 객체가 추가되기 때문에 메서드가 일시중단 되어 다른 task를 수행하더라도 다시 중단점으로 돌아갈 수 있었습니다.
정리하면, 해당 요청 메서드가 suspend 가능한 메서드인지 확인하는 역할을 합니다.
2. CallAdapter 확인
static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotations(
Retrofit retrofit, Method method, RequestFactory requestFactory) {
...
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?");
}
if (responseType == Response.class) {
throw methodError(method, "Response must include generic type (e.g., Response<String>)");
}
// TODO support Unit for Kotlin?
if (requestFactory.httpMethod.equals("HEAD") && !Void.class.equals(responseType)) {
throw methodError(method, "HEAD method must use Void as response type.");
}
...
}
두번째로는 CallAdapter를 생성하게 됩니다. Hilt 모듈을 정의하는 과정에서 Retrofit을 구성하는 provide 메서드를 만들때 CallAdapter를 커스텀하여 추가할 수 있습니다. 별도로 추가하지 않더라도 동작할 수 있었던 이유는 Retrofit은 기본적으로 정의된 DefaultCallAdapterFactory를 사용하기 때문입니다.
3. 메서드 반환 타입에 따른 분기 처리
static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotations(
Retrofit retrofit, Method method, RequestFactory requestFactory) {
...
Converter<ResponseBody, ResponseT> responseConverter =
createResponseConverter(retrofit, method, responseType);
okhttp3.Call.Factory callFactory = retrofit.callFactory;
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);
}
}
세번째로는 메서드 반환 타입에 따른 분기처리를 하고 있습니다. 우선적으로 ResponseConverter를 생성하고 있으며 이 부분도 직접 커스텀이 가능합니다. ( ResponseConverter는 HTTP 응답 본문을 자바 객체로 변환하는 역할을 하며, Gson, Moshi 등의 라이브러리를 활용하여 JSON을 객체로 변환할 수 있습니다.)
요청 메서드가 suspend 가능하지 않다면 CallAdapted를 생성하여 리턴하고 있으며, suspend 가능하다면 요청 메서드의 반환 타입이 Response 인지 아닌지에 따라 SuspendForResponse, SuspendForBody를 반환하게 됩니다.
(okhttp3.Call.Factory callFactory는 HTTP 요청을 보내기위한 OkHttpClient의 인스턴스를 생성하는 Factory입니다.)
ServiceMethod#invoke
다시 Retrofit 인스턴스를 create하는 부분을 살펴보겠습니다. 앞서 살펴봤던 것 처럼 Retrofit#loadServiceMethod를 통해 요청 메서드에 대한 모든 정보들을 확인하고 나면 ServiceMethod#invoke가 호출되게 됩니다.
// ServiceMethod.java
abstract class ServiceMethod<T> {
...
abstract @Nullable T invoke(Object[] args);
}
// HttpServiceMethod.java
@Override
final @Nullable ReturnT invoke(Object[] args) {
Call<ResponseT> call = new OkHttpCall<>(requestFactory, args, callFactory, responseConverter);
return adapt(call, args);
}
protected abstract @Nullable ReturnT adapt(Call<ResponseT> call, Object[] args);
ServiceMethod#invoke가 호출되고 나면 콜스택에 차례대로 HttpServiceMethod#invoke가 호출되고 OkHttpCall 인스턴스와 함께 HttpServiceMethod#adapt를 호출하게 됩니다. 이 메서드는 추상 메서드이기 때문에 어디선가 상속하여 사용할 것입니다. 이 메서드를 사용하는 부분은 바로 앞서 살펴봤던 CallAdapted, SuspendForResponse, SuspendForBody 입니다.
suspend 가능한 메서드 일때 동작 가능한 SuspendForResponse, SuspendForBody 에 대해서만 확인해보겠습니다.
코루틴과 함께 Retrofit2를 사용할 때, enqueue를 어떻게 자동으로 구현하고 있는지에 대한 궁금증이 지금부터 풀리게 됩니다.
HttpServiceMethod.SuspendForResponse#adapt
static final class SuspendForResponse<ResponseT> extends HttpServiceMethod<ResponseT, Object> {
private final CallAdapter<ResponseT, Call<ResponseT>> callAdapter;
SuspendForResponse(
RequestFactory requestFactory,
okhttp3.Call.Factory callFactory,
Converter<ResponseBody, ResponseT> responseConverter,
CallAdapter<ResponseT, Call<ResponseT>> callAdapter) {
super(requestFactory, callFactory, responseConverter);
this.callAdapter = callAdapter;
}
@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);
}
}
}
adapt하는 과정을 살펴보면 KotlinExtensions#awaitResponse를 수행하면서 예외 처리를 하고 있습니다.
awaitResponse (KotlinExtensions.kt)
suspend fun <T> Call<T>.awaitResponse(): Response<T> {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
cancel()
}
enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
continuation.resume(response)
}
override fun onFailure(call: Call<T>, t: Throwable) {
continuation.resumeWithException(t)
}
})
}
}
여기서부터는 익숙한 코드들이 등장합니다. 우선, Call 타입의 확장함수로서 동작하는 awaitResponse는 enqueue를 수행할 수 있습니다.
코드에서는 해당 콜백을 코루틴처럼 동작시키고 취소 가능하도록 suspendCancellableCoroutine 블럭을 사용하고 있습니다. 이 방법을 활용하면, 응답이 오기 전까지 코루틴을 대기 시킬 수 있으며, RxJava든, 별도의 Thread든 원하는 응답을 코루틴으로 바꿔치기할 수 있습니다.
네트워크 처리 후 정상 응답을 받았을 때 Response와 함께 continuation을 resume 시키고, 실패했을 경우 resumeWithException을 수행하여 발생된 예외를 전달하게 됩니다.
continuation 객체의 역할이 중요한데, 이 continuation 객체에는 실제 ApiService의 메서드를 요청한 콜사이트의 정보가 저장되어 있습니다.
@Singleton
internal class SampleDataSource @Inject constructor(
private val apiService: ApiService
) {
suspend fun getDailyBoxOffice(targetDate: String): List<DailyBoxOfficeModel> {
val response = apiService.getDailyBoxOffice(targetDate = targetDate)
...
}
}
보통 ApiService의 메서드를 RemoteDataSource에서 요청할 것이기 때문에 continuation에는 RemoteDataSource에서 해당 메서드를 요청한 위치가 저장될 것입니다. 말 그대로 이 지점이 Suspension Point입니다. 따라서, 네트워크 요청이 정상적으로 수행되었는지 혹은 실패하였는지에 따라 결과가 전달될 것이고, 만약 정상적으로 수행되지 못해 resumeWithException이 수행된다면 Suspension Point로 예외를들고가 해당 지점에서 발생된 예외를 throw합니다.
HttpServiceMethod.SuspendForBody#adapt
static final class SuspendForBody<ResponseT> extends HttpServiceMethod<ResponseT, Object> {
private final CallAdapter<ResponseT, Call<ResponseT>> callAdapter;
private final boolean isNullable;
SuspendForBody(
RequestFactory requestFactory,
okhttp3.Call.Factory callFactory,
Converter<ResponseBody, ResponseT> responseConverter,
CallAdapter<ResponseT, Call<ResponseT>> callAdapter,
boolean isNullable) {
super(requestFactory, callFactory, responseConverter);
this.callAdapter = callAdapter;
this.isNullable = isNullable;
}
@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];
..
try {
return isNullable
? KotlinExtensions.awaitNullable(call, continuation)
: KotlinExtensions.await(call, continuation);
} catch (Exception e) {
return KotlinExtensions.suspendAndThrow(e, continuation);
}
}
}
SuspendForBody도 마찬가지로 Body가 Nullable한지에 따라 KotlinExtensions의 메서드들을 호출하고 있습니다.
await, awaitNullable (KotlinExtensions.kt)
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)
}
})
}
}
SuspendForBody의 경우 Body의 타입이 Non-Null할 경우 await을 수행하게 됩니다. 따라서, 내부에서 요청 메서드의 응답에 대한 body를 검사하는 로직이 추가됩니다. 만약 body가 null일 경우에는 Suspension Point에서 NPE를 발생시키게 됩니다.
@JvmName("awaitNullable")
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) {
continuation.resume(response.body())
} else {
continuation.resumeWithException(HttpException(response))
}
}
override fun onFailure(call: Call<T?>, t: Throwable) {
continuation.resumeWithException(t)
}
})
}
}
SuspendForBody#adapt를 수행하는 과정에서 isNullable이 true일 경우 awaitNullable이 수행되게 됩니다. 이미 nullable함을 확인했기 때문에 내부적으로 별도의 체크 로직은 없습니다.
suspendAndThrow (KotlinExtensions.kt)
// catch (Exception e) {
// return KotlinExtensions.suspendAndThrow(e, continuation);
// }
internal suspend fun Exception.suspendAndThrow(): Nothing {
suspendCoroutineUninterceptedOrReturn<Nothing> { continuation ->
Dispatchers.Default.dispatch(continuation.context, Runnable {
continuation.intercepted().resumeWithException(this@suspendAndThrow)
})
COROUTINE_SUSPENDED
}
}
앞서 살펴본 SuspendForResponse, SuspendForBody 모두 내부적으로 enqueue를 수행하며 발생될 수 있는 Exception에 대한 처리는 하고있습니다. 하지만, 그 전에 Retrofit 요청을 구성하는 과정에서에 대한 예외처리는 suspendAndThrow를 통해 하게 됩니다.
suspendAndThrow는 발생하는 예외를 throw하기 전에 강제로 suspend 하도록 설계되었습니다. 따라서 예외가 발생할 경우 enqueue를 통한 비동기 요청을 시도하지 않고 해당 Suspension Point로 돌아가 예외를 throw하게 됩니다.
Retrofit 동작의 전체적인 흐름
Retrofit을 통해 네트워크를 호출할 때 Dispatchers.IO로 꼭 지정해야할까요?
아래 두가지 중 어떤 디스패처로 해야 맞는 코드일까요? 한번 잠시 고민해보시기 바랍니다.
1. lifecycleScope
lifecycleScope.launch {
userItemList.addAll(presenter.loadDataList(seed, FIRST_PAGE))
submitList()
}
2. CoroutineScope(Dispatchers.IO)
CoroutineScope(Dispatchers.IO).launch {
userItemList.addAll(presenter.loadDataList(seed, FIRST_PAGE))
withContext(Dispatchers.Main) {
submitList()
}
}
결론 부터 말하자면, lifecycleScope로 하는 것이 맞습니다.
앞서 설명한바와 같이 Retrofit Call의 메소드로 자체적 비동기 호출을 하고, 결과에 따라 콜백을 처리해줍니다.
즉, 우리가 Dispatchers를 지정해주어도 코루틴의 흐름을 타지 않고, 자바 쓰레드 풀에서 돌게 됩니다.
(단, 응답 결과에 따른 Callback은 Android의 MainThread에서 처리됩니다.)
그래서 2번의 코드처럼 Dispatchers를 바꿔주며 코루틴을 사용해도 사실은 아무런 영향을 미치지 못합니다. 안드로이드에서는 앞으로 lifecycle에 따라 코루틴을 자동으로 취소해주는 기능까지 추가된, lifecycleScope를 이용하도록 합니다! (lifecycleScope는 내부적으로 Dispathchers.Main.immediate를 사용하고 있습니다.
정리
- 프로그래머는 Interface에 추상적인 API 함수들을 Retrofit Annotaion과 함께 정의해둔다.
- Retrofit.create(Class<T!>) 함수에 정의한 Interface의 Java Class 타입을 넘긴다. 반환 값은 해당 Interface의 구현체이다.
- 우리는 Interface의 구현체 내용을 작성하지 않았다. 이는 Retrofit이 담당한다.
- Java의 Reflection API에서 제공하는 newProxyInstance() 메서드를 이용해 동적으로 Proxy 인스턴스를 만든다.(Dynamic Proxy)
- 이 Proxy Instance는 우리가 정의한 Interface의 구현체이다.
- newProxyInstance의 3번째 인자인 InvocationHandler의 invoke함수는 프로그래머가 Interface에 정의한 함수들을 사용하고자 할 때 거치게 되는 함수로, 여기서 우리가 딸랑 Annotation만 붙여놓은 함수들의 실제 동작을 Retrofit이 정의한다. 그 부분이 LoadServiceMethod이다.
- LoadServiceMethod는 Annotation을 파싱하여 함수들의 실제 동작을 정의하고, 만들어진 함수는 캐싱 처리된다. 또한, 실제 POST, GET 등의 어노테이션을 파싱하는 것은 RequestFactory에 위임하고 Response와 Return 타입을 정의하는 것은 HttpsServiceMethod에 위임하는 등 세부적인 작업은 분리되어있다.
참고자료
(Android) Retrofit2는 어떻게 동작하는가 — 1. 내부 코드 분석
Retrofit2 Deep Dive #1
medium.com
Coroutine과 Retrofit을 같이 사용하면 enqueue를 쓰지 않아도 되는 이유는?
이 포스팅을 작성하는 이유 안드로이드를 처음 접할 때 retrofit을 이용해 서버와 통신할 경우 아래와 같은 코드를 작성하게됩니다. interface UserApi { @GET("api/") suspend fun getUserList( @Query("page") page: Int,
seokzoo.tistory.com
[Android/Kotlin] Retrofit2은 어떻게 Interface의 구현 없이 사용할 수 있을까?
제가 가진 의문과 생각을 정리한 글입니다~! 틀린 내용이 있다면 알려주세요 :) TL;DR - 프로그래머는 Interface에 추상적인 API 함수들을 Retrofit Annotaion과 함께 정의해둔다. - Retrofit.create(Class) 함수에
ongveloper.tistory.com
https://yoon-min.github.io/posts/Android-Retrofit-01/
Android Retrofit - 인스턴스 생성과 API Interface의 동작 과정
모르는 게 많으니까 기록하는 공간
yoon-min.github.io
'Android > Network' 카테고리의 다른 글
[Android] OkHttp Interceptor 정리 (0) | 2025.03.06 |
---|---|
[Android] 직렬화/역직렬화 라이브러리 (Gson, Moshi, Kotlin-serialization) (0) | 2025.02.20 |
[Android] Retrofit CallAdapter를 통해 효과적으로 예외 처리하기 (1) | 2025.02.20 |
[Android] OkHttp vs Retrofit (0) | 2023.09.13 |
[Android] Retrofit2 - REST API 통신 라이브러리 (0) | 2023.09.07 |