(Android) Retrofit2는 어떻게 동작하는가 — 2. IO Dispatcher는 필요한가

Retrofit2 Deep Dive #2

Jaesung Lee
jaesung dev
12 min readNov 8, 2023

--

Photo by Artem Verbo on Unsplash

[목차 보기]

Retrofit2와 코루틴을 함께 사용할 경우, 별도로 Call#enqueue를 구현하지 않아도 비동기 처리를 자동으로 수행하게 됩니다. 그 이유는 내부적으로 요청 메서드가 suspend 메서드인지 검사하고 반환 타입에 따라 별도의 구현체를 반환하기 때문에, 해당 구현체에서 자동으로 enqueue를 수행할 수 있게 됩니다.

혹여나 Retrofit2을 사용할 때, IO Dispatcher에서 네트워크 작업을 수행하고 Main Dispatcher (또는 Main.immediate)에서 UI 업데이트을 해야 하는 것이 아닌가하는 의문이 들 수 있습니다. 결론부터 말하자면 Retrofit2를 사용할 때 IO Dispatcher로의 디스패치는 필요하지 않습니다.

이번 글에서는 이러한 과정이 어떻게 진행될 수 있는지, Dispatcher 변경이 필요하지 않는 이유에 대해 정리합니다.

그동안 Retrofit 구성은 어떻게 해왔나

의존성 주입 라이브러리로 Hilt를 사용할 경우, 위 예시처럼 Retrofit 인스턴스를 제공하는 provide 메서드를 구현하게 됩니다. 위 방식처럼 빌더 패턴을 이용하여 다양한 옵션들을 설정할 수 있습니다. 빌더 패턴을 사용하기 때문에 설정할 수 있는 옵션들은 Retrofit의 생성자를 통해 살펴볼 수 있습니다.

baseUrl을 제외한 나머지 옵션들은 따로 설정하지 않아도 동작합니다. Retrofit에서 자체적으로 Platform에 해당하는 기본 설정들을 지원하기 때문입니다. Retrofit#build 에서 확인해보실 수 있습니다.

1. callFactory : Call.Factory (optional)

JavaDocs에서는 HTTP 요청을 보내기 위한 OkHttp 호출을 생성하는데 사용되는 팩토리로 설명하고 있습니다. 일반적으로 OkHttpClient의 인스턴스에 해당합니다.

앞서 Retrofit을 사용하기 위한 초기 설정을 하는 모듈에서 OkHttpClient 인스턴스를 Retrofit#client 메서드를 통해 주입하게 되면, Retrofit#callFactory에 의해 Retrofit Builder에 설정되게 됩니다.

추가로, OkHttpClient에서는 Requeset Timout 설정, HttpLoggingInterceptor를 통한 로그 설정, 헤더 추가 등 네트워크 요청에 필요한 다양한 옵션들을 지정할 수 있습니다.

2. baseUrl : HttpUrl

baseUrl은 말 그대로 요청할 API의 주소를 의미합니다. Retrofit을 사용하기 위해서는 아무래도 반드시 필요한 필드라고 볼 수 있습니다. baseUrl에 대한부가적인 설명은 하지 않겠습니다. 주의해야할 점이라면 모든 API의 baseUrl은 “ / ”로 끝나야 합니다.

3. converterFactories : List<Converter.Factory> (optional)

네트워크 요청과 응답의 변환 처리를 하는 컨버터들의 리스트에 해당합니다. 일반적으로 직렬화/역직렬화를 담당하는 컨버터들에 해당하며, 대표적으로 Gson, Moshi, Kotlin-Serialization 등이 있습니다.

컨버터들의 성능 비교에 대한 글은 추후에 작성해보겠습니다.

4. callAdapterFactories : List<CallAdapter.Factory> (optional)

Retrofit을 처음 학습할 때를 생각해보면 ApiService 메서드의 반환 타입으로 Call 객체를 많이 사용했었습니다. Retrofit은 HTTP 통신에 대한 기본 반환 타입으로 Call 객체를을 사용하기 때문입니다.

이 부분은 Platform#defaultCallAdapterFactories를 통해 확인해볼 수 있습니다. 해당 메서드에서는 callbackExecutor를 생성자로 사용하는 DefaultCallAdapterFactory 인스턴스를 반환하고 있습니다. 자연스럽게, DefaultCallAdapterFactory에서 Call 타입을 검사하는 것을 유추해볼 수 있습니다.

DefaultCallAdapterFactory에 대해서는 잠시 후에 더 살펴보겠습니다.

만약 반환 타입으로 직접 커스텀한 객체 타입을 사용하고 싶다면 CallAdapter를 직접 커스텀하여 사용하는 방법도 있습니다. Custom CallAdapter를 사용하는 방법에 대해서는 아주 잘 작성된 글이 있으니 참고해보시면 좋을 것 같습니다.

5. callbackExecutor : Executor (optional)

callbackExecutor에 대해서는 바로 뒤에 더 자세히 살펴볼 예정이니 어떤 역할을 하는지만 짚고 넘어가겠습니다. 변수명만으로도 알 수 있듯이 Callback 메서드를 호출하는 Executor로 이해할 수 있습니다.

6. validateEagerly : Boolean (optional)

Retrofit 인스턴스를 구성할 때 검증 역할을 하는 플래그로 이해할 수 있습니다. true로 설정된다면 Retrofit#create에 필요한 ApiService 인터페이스에 대한 검증을 더 하게됩니다.

이전 글에서 살펴봤듯이, Retrofit#create를 호출하게 되면 해당 ApiService 인터페이스에 대한 검증 (Platform#isDefaultMethod) 을 하게 됩니다. 이 부분에 대한 검증을 추가적으로 하는 것으로 파악됩니다.

callbackExecutor?

이전 글에서 Retrofit#create를 호출하게 되면 내부적으로 enqueue callback을 수행하여 비동기 처리를 하는 것을 확인해봤습니다.

OkHttp만을 단독으로 사용하여 네트워크 통신을 할 경우에는 callback 에서드가 Worker Thread에서 동작하게 됩니다. 따라서, 응답 결과를 통해 UI 업데이트가 필요할 경우 반드시 runOnUiThread를 사용해야 합니다. 하지만, Retrofit과 함께 사용할 때 runOnUiThread를 사용하지 않아도 NetworkOnMainThreadException 이 발생하지 않았습니다. 그 이유는 Retrofit이 내부적으로 네트워크 요청 결과를 UI 스레드에서 처리하기 때문이라고 유추해볼 수 있습니다.

callbackExecutor는 Executor 타입으로 구성됩니다.

Executor는 클라이언트에서 오는 요청 정보를 받아 이를 처리하는 Handler에 정보를 담아 Runnable을 처리하는 Thread를 생성하여 처리하는 역할을 합니다. 즉, 어떤 시점에 주어진 Runnable 객체를 처리하는 작업을 담당하고 있습니다. 이 과정은 Executor 인터페이스를 구현하는 방식에 따라 달라집니다.

그렇다면, Retrofit은 이 Executor를 어떻게 구현하고 있을까요?

위 코드에서 확인할 수 있듯이 Platform#defaultCallbackExecutor를 통해 callbackExecutor를 초기화하고 있습니다.

우선, Android 플랫폼을 사용하고 있고, Platform#defaultCallbackExecutor를 호출하면 MainThreadExecutor를 리턴하는 것을 확인할 수 있습니다. 즉, Retrofit은 MainThreadExecutor로 Executor 인터페이스를 구현하고 있으며 여기서 UI 스레드에서 동작할 수 있도록 MainLooper를 사용하는 것을 확인할 수 있습니다.

결과적으로, enqueue 콜백 처리 과정에서 OkHttp 단독 사용이 아닌 Retrofit과 함께 사용할 경우 runOnUiThread를 사용하지 않아도 UI 업데이트가 가능했던 이유는, Retrofit이 MainThreadExecutor를 사용하기 때문으로 정리할 수 있게 됩니다.

단순 백그라운드 작업에서 defaultCallbackExecutor는 안전할까?

조금 다른 접근을 해볼 수도 있습니다. Retrofit으로 네트워크 요청 작업 후 UI 업데이트와 전혀 상관없는 단순히 무거운 통신 작업을 한다면 어떨까요?

살펴봤던 것처럼 Retrofit은 UI 스레드를 통해 enqueue 콜백을 처리하기 때문에, defaultCallbackExecutor를 통해 MainThreadExecutor를 사용하는 방식은 적절하지 않을 수도 있습니다.

이 경우에는 Retrofit.Builder#callbackExecutor를 통해 직접 Executor 인터페이스를 구현한 Custom CallbackExecutor를 사용하는 방안을 고려해볼 수 있습니다. 하지만, 아직까지 저의 경험으로는 대부분의 네트워크 통신 작업은 UI 업데이트와 관련 있어 보입니다. 제 생각에는 SSOT (Single Source of Truth)를 위한 캐싱이 필요할 경우에는 고려해볼 수 있다고 생각합니다.

DefaultCallAdapterFactory

Custom CallAdapter를 사용하지 않을 경우 DefaultCallAdapterFactory가 사용되는 것을 확인했었습니다. 이 부분을 조금 더 깊게 살펴보겠습니다.

Retrofit 구성 시, 별도의 CallAdapter를 사용하지 않는다면 Platform#defaultCallAdapterFactories를 사용하고 있습니다. 이때 callbackExecutor를 인자로 전달하고 있으며, callbackExecutor는 앞서 살펴봤던 Platform#defaultCallbackExecutor를 통해 가져온 MainThreadExecutor가 사용될 것입니다.

즉, Retrofit은 별도의 CallAdapter를 적용하지 않을 경우, DefaultCallAdapterFactory를 사용하며 여기서의 콜백 처리는 UI 스레드에서 이루어지는 것을 유추할 수 있게 됩니다.

내부 코드를 살펴보면, 마찬가지로 전달받은 callbackExecutor를 인자로 전달하여 DefaultCallAdapterFactory 인스턴스를 생성하게 됩니다.

DefaultCallAdapterFactory 클래스를 살펴보겠습니다.

DefaultCallAdapterFactory는 CallAdapter.Factory를 상속하고 있습니다. DefaultCallAdapterFactory#get을 보면 반환 타입이 Call 객체 타입인지 확인하고 있으며, 새로운 CallAdapter 인스턴스를 생성하여 반환하게 됩니다.

CallAdapter 내부에는 저번 글에서 살펴봤던 adapt 메서드가 등장하게 되는데, CallAdapted, SuspendForResponse, SuspendForBody와 같은 클래스에서 이 메서드를 통해 Call 객체를 생성하여 비동기 처리를 하게 됩니다.

그렇기 때문에 adapt를 호출하면 당연히 enqueue 콜백 동작과 같은 로직을 수행할 것을 유추해볼 수 있게 되는데, 마침 CallAdapter에서 ExecutorCallbackCall이라는 인스턴스를 생성하여 반환하고 있습니다.

ExecutorCallbackCall

ExecutorCallbackCall은 Call 인터페이스를 구현하고 있습니다. 생성자로 callbackExecutor와 Call 타입의 delegate를 갖게 되는데, 이 delegate가 담당하는 역할이 중요해 보입니다. 우선 코드를 먼저 살펴보겠습니다.

우선 ExecutorCallbackCall의 모든 메서드에서 delegate라는 Call 타입 변수가 사용되고 있습니다. 어떤 인스턴스를 Delegate 하여 쓰고 있다는 의미인데 이 인스턴스를 찾아보겠습니다.

Retrofit#create를 호출하게 되면 ApiService 인터페이스의 프록시 객체를 통해 HttpServiceMethod#invoke를 호출하게 됩니다. 해당 메서드에서는 OkHttpCall 인스턴스를 생성하게 되고, adapt 메서드를 통해 delegate 변수가 초기화 됩니다.

따라서, ExecutorCallbackCall#enqueue에서 사용되는 delegate 변수는 OkHttpCall 인스턴스에 해당하며, OkHttpCall#enqueue를 사용하는 것을 확인할 수 있습니다.

OkHttpCall

OkHttpCall#enqueue는 어떤 역할을 하고있는지 살펴보겠습니다.

위 코드를 통해 Retrofit이 내부적으로 OkHttp를 사용하는 것을 확인할 수 있게 됩니다. 우선, OkHttpCall#enqueue를 수행하게 되면 OkHttpCall#createRawCall 메서드를 통해 OkHttp3.Call 타입의 call 변수를 동기화하여 초기화하는 것을 확인할 수 있습니다.

초기화된 call 변수를 통해 다시 enqueue를 수행하는 것을 볼 수 있는데, 해당 enqueue 메서드를 찾아보겠습니다.

RealCall#enqueue를 살펴보면 다시 enqueue를 수행하고 있고, OkHttpClient의 특정 Dispatcher에서 수행하는 것을 확인할 수 있습니다. 또한 비동기로 동작하기 위해 AsyncCall이라는 객체를 생성하여 enqueue를 수행하고 있습니다.

정리

개발을 하면서 몇몇 샘플들을 참고하다보면 아래와 같은 코드들을 심심치않게 볼 수 있습니다.

사실 위 코드처럼 작성하더라도 예기치 못한 동작을 하거나, 예외가 발생하지는 않습니다. 코드를 작성한 의도는 네트워크 요청 작업을 별도의 IO Dispatcher에서 수행시키는 것으로 이해할 수 있지만, 실제로 IO Dispatcher로 수행되지 않는 것을 이번 글을 통해 이해할 수 있습니다.

그렇다고 네트워크 요청 작업이 UI 스레드에서 이루어진다고 오해하지 않으시길 바랍니다. 다음 글에서는 실제 비동기 로직이 어떻게 수행되는지 살펴보겠습니다.

--

--