안드로이드

레트로핏은 어떤식으로 비동기처리를 할까?

강한 맷돼지 2023. 7. 17. 02:34

부제: 레트로핏의 Thread pool 관리 및 어떤 Thread가 작업에 관여하는가?

서론

Retrofit은 아주 강력한 라이브러리라고 하는데 내부적으로 어떻게 쓰레드 관리,비동기처리를 하는지 궁금한적이 있지 않는가?

Okhttp3를 사용하다가 Retrofit을 사용한 경험이 있는 사람들은 OkHttp에서는 callBack으로 넘겨주는 함수가 worker Thread에서 동작하여 ui를 변경하는 작업을 매번 runOnUiThread()를 붙여서 작업해야 했던 경험이 있을것이다.

신기하게도 Retrofit을 사용한 이후 callback 함수가 Main쓰레드에서 동작해서 runOnUiThread를 안붙여도 되었는데 그 이유는 무엇일까?

또한 enqueue의 인자로 넘겼던 callback 들은 어떤 과정을 거쳐서 처리될지 궁금한적이 있지않는가? (효율적인 쓰레드관리를 어떻게할까?)

그냥 레트로핏이 부리는 마법이라고 생각했다면 경기도 오산이다.

이제는 마법에서 깨어나서 내부동작이 어떻게 이루어지는지 알아볼 때이다.

우리가 레트로핏을 통해서 서버통신을 요청하면 관련 비동기 처리가 어떻게 이루어지는지 Retrofit과 Okhttp의 코드를 살펴보며 동작하는 원리를 살펴보자.

callback은 어떤 쓰레드가 잡아서 처리할까?

우선 Okhttp3를 사용할때는 callback이 worker Thread에서 동작했는데 Retrofit에서는 왜 runOnUiThread를 붙이지않아도 ui관련 로직이 동작했을까? 라는 의문부터 풀어보자.

 

Retrofit을 사용하기위해 기초세팅을 할때

fun setRetrofit(baseURL: String) {
    retrofit = Retrofit.Builder()
        .baseUrl(baseURL)
        .client(shoppingOkHttpClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()
}

이런식으로 builder를 통해 다양한 옵션들을 설정해서 사용하게된다.

 

Retrofit.java내부에 있는 Builder클래스를 살펴보면 platform 지정 부터 Retorfit 인스턴스를 생성하는 다양한 로직이있지만 일단 생략하고

Builder는 결국 Retrofit 인스턴스를 platform등의 상황에 맞도록 생성해서 내놓는 로직임으로 Retrofit의 생성자를 살펴본다면 어떠한 것들을 사용자가 커스텀하여 세팅할수 있는지 볼 수 있다.

 

 

Retrofit.java

Retrofit(
    okhttp3.Call.Factory callFactory,
    HttpUrl baseUrl,
    List<Converter.Factory> converterFactories,
    List<CallAdapter.Factory> callAdapterFactories,
    @Nullable Executor callbackExecutor,
    boolean validateEagerly) {
  this.callFactory = callFactory;
  this.baseUrl = baseUrl;
  this.converterFactories = converterFactories; // Copy+unmodifiable at call site.
  this.callAdapterFactories = callAdapterFactories; // Copy+unmodifiable at call site.
  this.callbackExecutor = callbackExecutor;
  this.validateEagerly = validateEagerly;
}

retrofit의 생성자는 다음과 같은데 각각을 살펴보면 익숙한것도 생소한것도 있을것이다.

생성자에서 받는 인자들은 우리가 레트로핏을 커스텀하거나 설정 해줄 수 있도록 열려있는 창구나 다름없다.

이중 우리의 현재 관심사는 callbackExecutor이다.

 

callbackExecutor 뭔가 생소하지않는가? 굳이 우리가 설정해주지 않아도 레트로핏이 잘돌아가기 때문이다.

하지만 이것이 Retrofit에 전달한 callback이 mainThread에서 동작하게 만들어주는 키워드이다.

 

글의 후반에서 자세히 다루겠지만 OkHttp는 Dispatcher라는 클래스를 통해서 OkHttp가 작동할 Thread와 Threadpool을 정의하는데 callback 또한 여기서 생성되는 Thread에서 처리하게된다. 그러므로 callback이 workerThread에서 동작하게 되었고 runOnUiThread 함수를 통해 매번 UiThread에서 동작하도록 변경해줘야 했던것이다.

 

이러한 불편함점을 개선하기 위해서 retrofit은 callbackExecutor를 사용자가 직접 지정할수 있게 해두었고 안드로이드 플랫폼이라면 default로 설정되는 callbackExecutor는 Mainlooper를 통해 UIThread(mainThread)에서 동작하도록 설정되어있다.(callbackExecutor가 어디서 동작하는지 예시를 보고싶다면 defaultCallbackExecutor를 살펴보라)

이로 인하여 callback에서 Ui로직을 실행하여도 정상적으로 동작할수 있었던것이다.

 

결론적으로 서버에 요청을 날리고 받아오는 비동기적인 로직은 OkHttp3의 Dispatcher에서 관리하는 Thread에서 실행하고 callback은 Retrofit에 설정되어 있는 callbackExecutor에서 처리하므로 서버의 요청과 그에 관한 응답에 대한 처리가 다른 Thread에서 이루어지고 있는 것을 인식한다면 viewModelScope에서 왜 굳이 Dispatcher.IO를 지정해주지 않아도 잘돌아갔는지 이런것에대한 의문점 또한 자연히 사라질것이다.(Retrofit이 코루틴을 지원하는데 내부적으로 enqueue를 통하는것 또한 알아야한다.)

 

어쨋든 이제 우리가 설정해주지 않아도 잘 돌아가게 만들어줬던 defaultCallbackExecutor이 어디에 있고 어떻게 생겼는지 살펴보자

 

 

Retrofit.java

public Retrofit build() {
​
  .... 기타 로직
​
  Executor callbackExecutor = this.callbackExecutor;
  if (callbackExecutor == null) {
    callbackExecutor = platform.defaultCallbackExecutor();
  }
  
  .... 기타 로직
}

다음과 같이 Retrofit의 build 함수에는 callbackExecutor를 설정하는 부분이있는데

사용자가 Retofit을 생성할때 callbackExecutor를 전달하지 않는다면 Platform클래스의 defaultCallbackExecutor를 이용한다.

그렇다면 Platform 클래스를 살펴보자

 

Platform 클래스는 플랫폼에 따라 디폴트 값 혹은 개별적으로 달라지는 부분을 명시한다.

https://github.com/square/retrofit/blob/master/retrofit/src/main/java/retrofit2/Platform.java

Retrofit의 git저장소를 들어가보면 플랫폼으로 RoboVm,java8,java14,java16,Android21,Android24 이렇게 플랫폼이 여러개 존재한다.

이렇게 각각의 플렛폼에서 적절하게 처리할수 있도록 default 설정값 등이 명시되어있다.

안드로이드도 버전별로 나뉘어 있는듯하지만 막상 레트로핏을 안드로이드 환경에서 의존성을 추가하고 사용하면 버전에 맞는것만 알아서 받아오는지 안드로이드 24에 해당하는 코드만 Android라는 클래스 명으로 존재한다.(안드로이드 버전이 다르더라도 어짜피 MainThreadExecutor은 같은것을 사용한다.)

 

이에 해당하는 코드를 살펴보면

 

Platform.java

static final class Android extends Platform {
  Android() {
    super(Build.VERSION.SDK_INT >= 24);
  }
​
  @Override
  public Executor defaultCallbackExecutor() {
    return new MainThreadExecutor();
  }
​
  // 다른 로직 블라블라 ...
​
  static final class MainThreadExecutor implements Executor {
    private final Handler handler = new Handler(Looper.getMainLooper());
​
    @Override
    public void execute(Runnable r) {
      handler.post(r);
    }
  }
}

우선 defaultCallbackExecutor라는 함수를 통해 Android 플랫폼에 알맞은 callbackExecutor를 반환하는데 이때 MainThreadExecutor를 반환하고있다.

 

MainThreadExecutor를 살펴보면 Executor를 상속받아 메인스레드의 핸들러를 생성하여 callback 으로 오는 로직들을 mainThread로 토스해버리는 역할을 하고있다. -> 즉 이렇게 default 핸들러가 mainThread를 이용하고 있었으므로 callback에서 UI를 수정하여도 정상적으로 작동하고 있었던 것이다.

 

또한 여기서 내릴수있는 결론으로 물론 이런 환경이 있을까 싶지만 만약 서버통신후 UI와 전혀 상관없이 callback 무거운 연산만하고 이를 저장하는 환경이라면 callback이 Ui쓰레드에서 동작하게 하는 defaultCallbackExecutor는 적절하지 못할것이다. (UI쓰레드에서 무거운 연산을 건다면 ANR이 발생할수도??) 어쩃든 이렇게 환경에 맞춰서 Executor를 적절히 생성해서 집어 넣어줄수도 있다는 것이다.

 

물론 일반적인 안드로이드 앱에서 이럴일은 거의없으니 디폴트값을 대부분 쓰게되겠지만 알고쓰는것과 모르고 쓰는것은 확연히 다르니 알아두고가자.

 

자 이렇게 callback 이 처리되는 과정을 보았으니 이제 서버에게 요청을하고 이것을 전달하기 과정에서 사용되는 OkHttp의 Dispatcher에서 다루는 Thread와 ThreadPool에 대해서 살펴보자

OKHttp의 ThreadPool

우리는 현재 Retrofit을 사용하여 서버통신을 하지만 Retrofit이 OkHttp를 기반으로 동작하기 때문에 알게모르게 OkHttp의 기능들을 죄다 사용하고있다.

 

그래서 Retrofit을 사용하더라도 작업이 일어나는 Thread및 Threadpool 관리는 OkHttp3의 Dispatcher에서 제공하는데 Dispatcher의 코드를 살펴보며 Thread pool을 어떻게 관리하는지 이부분에 있어 우리가 커스텀 할 수 있는 부분은 없는지에 대해 살펴보자

 

 

우선 코드를 조각조각내서 설명하겠지만 만약 전체 코드가 궁금하다면

https://github.com/square/okhttp/blob/master/okhttp/src/jvmMain/kotlin/okhttp3/Dispatcher.kt 를 살펴보자

 

Dispatcher.kt

@get:Synchronized var maxRequests = 64
  set(maxRequests) {
    require(maxRequests >= 1) { "max < 1: $maxRequests" }
    synchronized(this) {
      field = maxRequests
    }
    promoteAndExecute()
  }

Dispatcher에서는 maxRequests 필드를 관리하는데 이는 OkHttpClient 를 빌드할때 Dispatcher 를 생성해서 넘겨주어 변경할수있다.

 

기본값은 64이고 OkHttp클라이언트당 최대 동시 요청수를 제한하는 숫자이다.(정확히 말하자면 요청을 관리하는 ArrayDeque 중 현재 요청이 진행중인것만 모아놓은 runningAsyncCalls에 들어갈수있는 최대 요청수 를 제한한다.)

 

일반적으로 OkHttp인스턴스를 싱글톤 형태로 많이 관리하는데 이렇게 Okhttp는 클라이언트당 최대요청수를 제한하고 있으므로 요청시 매번 OkHttp 인스턴스를 생성하는 것과 같이 인스턴스 생성을 아무렇게나 남발하면 안되며 싱글톤으로 유지하는 이유중 하나이기도 하다.

 

 

Dispatcher.kt

@get:Synchronized var maxRequestsPerHost = 5
  set(maxRequestsPerHost) {
    require(maxRequestsPerHost >= 1) { "max < 1: $maxRequestsPerHost" }
    synchronized(this) {
      field = maxRequestsPerHost
    }
    promoteAndExecute()
  }

maxRequestPerHost 라는 필드는 한 호스트(Url의 호스트 이름 혹은 동일한 Ip 주소로 구분)당 요청수 제한으로

간단하게 이야기하여 하나의 서버에 대해 동시에 요청이 들어갈 수 있는 갯수 제한을 두는것이다.

이 또한 OkHttpClient 를 빌드할때 Dispatcher 를 생성해서 넘겨줘서 변경할수있다.

기본값은 5이며 내부적으로 AtomicInteger(쓰레드가 동시접근 하더라도를 문제없이 처리할수 있도록 만든 자바객체) 통하여 호스트당 요청수가 관리되고있다.

 

 

Dispatcher.kt

private var executorServiceOrNull: ExecutorService? = null
​
@get:Synchronized
@get:JvmName("executorService") val executorService: ExecutorService
  get() {
    if (executorServiceOrNull == null) {
      executorServiceOrNull = ThreadPoolExecutor(0, Int.MAX_VALUE, 60, TimeUnit.SECONDS,
          SynchronousQueue(), threadFactory("$okHttpName Dispatcher", false))
    }
    return executorServiceOrNull!!
  }

이제 OkHttp에서 사용하는 Threadpool을 생성하는 부분이 나오는데

 

자바에서 제공하는 ThreadPoolExecutor를 통해서 Threadpool을 구성하고있다.

ThreadPoolExecutor의 인자를 하나하나 순서대로뜯어보면서 살펴보자.

   

    1 .corePoolSize = 0

코어 스레드 갯수이다.. 스레드 풀이 유지하는 최소한의 스레드 수를 의미한다. ThreadPoolExecutor의 경우 최초에 corePoolSize 만큼의 Thread수를 가지고 시작하는데 만약 corePoolSize보다 많은 요청이 들어온다면 MaximumPoolSize이내의 갯수만큼 Thread를 더 생성한다. 또한 설정한 유휴시간만큼 Thread의 작업이 없다면 corePoolSize에 설정한 수가 될때까지 Thread가 제거된다.즉 최소한의 Thread 유지갯수이며 OKHttp의 경우 0으로 설정되어 최초 유지하는 Thread의 갯수가 0개이다.(서버요청이 주기적으로 항상 있는것이 아니기 때문에 쓰레드 생성비용이 유지비용보다 적다고 판단한것 같다. 합당한 방법이라고 생각한다.)

 

    2 .maximumPoolSize = Int.MAX_VALUE

Threadpool이 가질수있는 최대의 Thread의 갯수이다. 21억 어쩌구 이므로 사실상 Thread갯수의 제한을 두고 있지 않다.(이 부분에서는 이미 최대 요청수 제한 및 호스트별 요청수 제한을 두었으므로 쓰레드 풀의 생성갯수는 무한으로 풀어놓았다고 생각한다.)

 

    3 .keepAliveTime = 60,TimeUnit.SECONDS

유휴 시간을 나타낸다. 스레드가 일을 하지 않아도 제거하지않고 살려두는 시간을 뜻한다.(요청이 일어난후 재 요청이 일어나는 시기가 60초 이내라면 Thread 제거와 생성비용을 줄일수 있는데 이렇게 연쇄적으로 일어날 수 있는 요청의 시간을 60초로 기준잡은것 같다.)

   

    4 .workQueue

ThreadPool의 작업큐를 뜻한다.당연히 Thread Pool이니 작업들이 들어오는 queue가 필요할테고 그것을 명시해주는 인자인데 자바 SynchronousQueue 를 사용한다. 이런 자료구조에 대한것은 젬병이며 사실 아직 깊게 알고싶지도 않으니 유료결제한 강력한 ChatGpt의 답변에 의지해보자

SynchronousQueue의 주요 특징은 다음과 같습니다:

  1. 큐의 크기가 0: SynchronousQueue는 내부에 아무런 요소를 저장하지 않고 작업을 즉시 다른 스레드로 전달합니다. 따라서 큐의 크기가 0이며, 작업이 큐에 대기하지 않습니다.
  2. 직접 전달: SynchronousQueue는 작업을 생성한 스레드가 직접 다른 스레드에게 작업을 전달합니다. 즉, 작업을 큐에 추가하지 않고 다른 스레드에게 바로 전달하므로 작업을 처리하는 스레드는 큐에서 작업을 가져옵니다.
  3. 블로킹 동작: SynchronousQueue는 put() 메서드를 호출한 스레드가 작업이 가져가질 때까지 블로킹되는 특징이 있습니다. 마찬가지로 take() 메서드를 호출한 스레드는 다른 스레드가 작업을 전달할 때까지 블로킹됩니다. 이를 통해 스레드 간의 동기화와 작업의 직접 전달을 가능하게 합니다.

SynchronousQueue는 주로 스레드 풀(Thread Pool)에서 사용되며, 작업 큐의 역할을 수행합니다. 작업이 동시에 발생하면 SynchronousQueue는 작업을 대기하고 있는 스레드로 직접 전달하여 처리됩니다. 이를 통해 작업을 큐에 저장하거나 관리하는 추가적인 오버헤드 없이 효율적인 작업 처리를 가능하게 합니다.

이렇다고 한다.

   

    5 .threadFactory = threadFactory("$okHttpName Dispatcher", false)

Thread를 만드는 방법을 설정하는 ThreadFactory이다. Thread를 생성하는 방법은 여러가지 이지만 Thread를 내가 아닌 ThreadPoolExecutor 가 직접 생성 하므로 Thread를 생성하는 방법을 넘기는것이다.

threadFactory의 인자를 살펴보면 okHttpName Dispatcher라는 이름을 붙여서 Thread를 생성하도록 하였고 데몬스레드가 아니도록 설정되어있다.

데몬스레드(daemon thread)는 background에서 실행되는 낮은 우선순위를 가진 스레드입니다. 주로 보조적인 역할을 담당하는데 사용되며, 자바에서 메모리 정리를 해주는 가비지 컬렉터(garbage collector)가 대표적인 데몬스레드입니다. 데몬스레드와 사용자스레드의 가장 큰 차이점은 JVM이 데몬스레드가 작업이 끝날때까지 기다리지 않는다는 점입니다. 사용자스레드가 모두 종료되면 데몬스레드는 자동으로 종료됩니다.

 

 

여기 까지 살펴봐도 OkHttp의 ThreadPool이 어떻게 관리되고있는지 알수있고 그에 대한 해석을 해봄으로 나중에 효율적인 ThreadPool을 구성해야하는 상황에서 참고용으로 쓰일 수 있을것이다.(Square의 코드는 개쩌니까)

 

분명 이런걸 어디다 써먹어 하실 분들이 있을것 같은데 써먹을곳은 생각보다 많다 OKHttp의 최적화를 위한 커스텀,coroutine Dispatcher 설정 등등 성능을 생각하면 이런 학습을 해야하는것은 당연하다.

 

또한 이런 참고사항으로 CS책을 뒤지고 다녀야 하겠지만 나는 현재 그런것을 할만한 여력은 안되니 내가 본 제일 믿을만한 소스를 제공하는 제이슨의 코루틴 강의에서 물리적 코어수 -1이 효율을 극대화할수있는 Thread 수라고 하니 예상 사용자들의 핸드폰 코어수 -1 을 유지하도록 설정해보면 되지않을까? 라는 막연한 생각을 해본다.

 

어쩃든 총평으로 현재까지 OkHttp의 ThreadPool 관리의 핵심을 살펴보자면 Request수를 제한하는 것을 통하여 Thread의 갯수를 관리하여 유동적이며 core Thread 수가 0이므로 사용되지 않을때 쓰레드수가 낭비되지 않는다.(UI 등에 비해 적은 빈도로 발행하기 때문에 적절하다고 생각된다.) 또한 Request수를 통해 Thread가 무한 생성되는 것을 제한함으로 ThreadPool 자체의 Thread 수 제한은 풀어놓았다.

 

이 이후에 각 요청들을 관리하는 deque(ready,runningAsync,runningSync) 3가지가 나오고

 

deque에서 ready 상태의것을 어떤 과정을 거쳐서 running으로 승격시키는지 관여하는 promoteAndExecute() 함수등이 나오는데(중간에 host별 요청 갯수 알아오기 등등 뭐가 많음) 사실상 이번글의 주제인 Threadpool 과는 거리가 있고 글이 너무 길어져 정신을 혼미하게 할수있으니 이만 생략하겠다.

 

만약 ThreadPool을 관리하는 방법이 알고싶다면 모두 읽어보는것이 좋을것이다.

 

이렇게 하여 Retrofit 과 OkHttp에 동작원리에 한층더 가까워졌다. 독자 여러분도 궁금했다면 이글을 읽고 싹 풀리는 계기가 되었으면 좋을것같다.