Android

Retrofit Call, 제대로 사용하기 - (1)

winter223 2022. 4. 1. 23:43

 

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 

ServiceMethodparseAnnotation() 는 주어진 함수의 어노테이션 및 반환 타입 등을 사용해서 주어진 함수를 호출할 적절한 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 를 생성하고, HttpServiceMethodparseAnnotations()를 호출해서 나머지 동작(주어진 함수를 처리할 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 를 상속한 클래스이다.

ServiceMethod 상속관계

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