본문 바로가기

Android/Tech

[Retrofit] Call<T>.enqueue() 와 suspendable 메서드 호출 방식의 차이

Unsplash, Samuel Sianipar.

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> 가 아닌 일 경우, 통신 결과로 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 은 정말 자주 사용하는 라이브러리인데, 사용하면 할수록 '어떻게 활용할까' 만 생각하지, '내부 구조는 어떻게 되어 있을까' 를 떠올리기 쉽지 않은 것 같습니다. 지금까지 그래왔듯, 앞으로도 꾸준히 의문을 품을 수 있다면 좋겠습니다.