본문 바로가기
Android

[Android/Kotlin] Retrofit2(+Coroutine)은 왜 Main Thread에서 사용해도 될까?

by 옹구스투스 2022. 8. 10.
반응형

아무 관련없는 짤


제가 가진 의문과 생각을 정리한 글입니다~!

틀린 내용이 있다면 알려주세요 :)


💁‍♂️???

Retrofit2과 Coroutine은 Android 프로젝트에 빠지지 않는 조합이다.

그런데 혹시, ViewModel에서 Retrofit의 API 함수를 호출할 때 Thread를 바꾸고 있진 않은가?

처음 예제를 짜 볼 땐 당연히 Networking 작업이니까 IO Dispatcher로 바꾸어주고 뿌듯해했다.

당연히 Room이나 Retrofit2같은 무거운 작업은 Main-Safety하게 해야 하지 않나? Main Thread에서 호출했다가 화면 버벅거리면 어떡해??

뭐 코루틴이야 일반적으로 호출자 스레드를 블락하지 않지만, Room이나 Retrofit2 같은 무거운 작업은 다른 Thread에서 작업하는 게 정석인데?

또 Retrofit2이야 Call을 이용해서 작업을 다른 스레드에서 처리할 수 있지만 Room은 기본적으로 Main에서 호출하면 에러가 발생하지 않나??

 

✅ Fact

해당 코드랩 내용에 따르면 Retrofit2과 Room은 Coroutine을 사용할 때 내부적으로 Main Safety를 제공하기에(비동기 처리) 처리해야 할 데이터의 양이 많거나, 다른 추가 작업이 필요한 경우가 아니라면 굳이 IO Dispatcher를 사용할 필요 없이 Main에서 바로 사용하는 것을 권장한다고 한다.

이러한 내용이 다른 코드랩에도 있었는데 그 링크는 못 찾겠다..

 

Coroutine 이전의 Retrofit2

아무튼 Retrofit2은 그럼 내부적으로 어떻게 짜여 있길래 그냥 Main Thread에서 호출하라는 걸까?

우선 코루틴을 사용하기 전의 방식먼저 보면, api의 리턴 값으로 Call<Response>를 두고, 호출 부에서 Call<T>인터페이스의 함수인 enqueue를 이용해서 네트워킹 작업을 비동기로 처리한 후 결과값을 호출자 스레드에서 받아볼 수 있었다.

  /**
   * 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);

// Api Interface
    @GET(API.SEARCH_PHOTO)
    fun searchPhotos(@Query("query") searchTerm: String) : Call<JsonElement>  //retrofit call import

// 호출부
call.enqueue(object : retrofit2.Callback<JsonElement>{
    //응답 성공시
    override fun onResponse(call: Call<JsonElement>, response: Response<JsonElement>) {
        Log.d(TAG, "onResponse(): called / response : ${response.raw()}")
        completion(RESPONSE_STATE.OKAY,response.body().toString())
    }

    //응답 실패시
    override fun onFailure(call: Call<JsonElement>, t: Throwable) {
        Log.d(TAG, "RetrofitManager - onFailure(): called /t : $t")
        completion(RESPONSE_STATE.FAIL,t.toString())
    }

})

Retrofit2 + Coroutine

이제 코루틴을 사용하는 방식을 알아보자

함수 앞에 suspend를 붙이고, 반환 값으로 Call이 아닌 Response를 직접 받는다.

이 Response는 code값에 따라 Data Layer에서 Handling하고 네트워킹의 결괏값만을 Presentation Layer로 내려주어 활용할 수 있다.

Call을 이용할 때보다 code값에 대한 유연한 처리와 콜백 지옥에서 벗어날 수 있다.

    // Api Interface
    @POST(REPORT + COMMENT)
    suspend fun reportComment(@Body reportCommentRequest: ReportCommentRequest)
    : Response<BaseResponse<String>>
    
    // 호출부
    fun reportComment(reportCommentRequest: ReportCommentModel) =
        viewModelScope.launch {
            onLoading()
            with(reportCommentUseCase(reportCommentRequest)) {
                offLoading()
                Timber.d("result $this")
                when (this) {
                    is UiState.Success -> {
                        _handleComment.value = Pair(reportCommentRequest.id, "report")
                    }
                    is UiState.Error -> {
                        setMessage(message)
                    }
                    is UiState.Fail -> {
                        setMessage(message)
                }
            }
        }
    }

대망의 Retrofit2 + Coroutine 내부 코드이다.

https://github.com/square/retrofit/blob/master/retrofit/src/main/java/retrofit2/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)
      }
    })
  }
}

내부 코드를 보면 Call의 확장 함수의 반환 값으로 T를 넘겨준다.

그리고 suspendCancellableCoroutine을 반환하는데, suspendCancellableCoroutine 내부에서 위에서 봤던 enqueue를 사용한다.

즉, Coroutine을 사용할 땐 우린 suspend 붙여서 호출하면 내부적으로 suspendCancellableCoroutine을 사용하며 그 안에서 Call<T>.enqueue를 이용해 실제 네트워크 작업을 비동기적으로 처리하고 결괏값을 호출자 스레드로 콜백한다.

suspendCancellableCoroutine
- CancellableContinuation을 사용하는 함수
- 콜백을 suspend로 변환하는 용도로 사용 (suspendCoroutine)
- suspendCancellableCoroutine은 취소 가능함
- 싱글샷 콜백 API의 결과를 기다리는 동안 코루틴을 일시 중단하고 그 결과를 호출자에게 반환하는 용도
- invokeOnCancellation{} : 코루틴이 취소될 때마다 호출되는 핸들러를 설치하고, 필수 구현 요소
- resume(value){} : Continuation.resume으로 넘겨주면서 코루틴이 재개되며 함수가 종료됨

시나리오를 생각하면 화면에 어떤 버튼을 클릭하는 것으로 API를 호출하여 결국 데이터를 가져와 다시 화면에 표시해주어야 한다.

이는 하나의 데이터 스트림으로 볼 수 있고 결국 화면에 표시해주어야 한다.

그 말은 최종 값은 Main Thread에서 활용될 것임을 의미한다.

그럼 작업 스레드로 스레드를 변경해야 할 때는 언제인가?

무거운 작업을 하는 영역에 최대한 가깝게 스레드를 변경해야 한다고 생각한다.

 

자 그러면 만약 ViewModel에서 해당 api를 호출할 때 IO Dispatcher로 변경한다고 해보자.

ViewModel에서 usecase - repository - datasource - api(Retrofit2)를 거쳐 api를 호출한다고 할 때 api를 호출하기 이전에 어떠한 추가 동작이 없다면

VIewModel에서부터  Main Thread -> Worker Thread1 
api에서 Worker Thread1 -> Worker Thread2 -> Workder Thread1
다시 ViewModel Worker Thread1 -> Main Thread

즉, 불필요한 Context Switching이 생긴다는 것이다~

2023.05.02
갑자기 생각난 건데, Call 동작을 Worker Thread1에서 실행했을 때 Worker Thread1에서 동작할까? Worker Thread2에서 동작할까??? 시간 날 때 확인해 봐야겠다. Call 코드를 타고 들어가다보면 알 수 있지 않을까
 + Retrofit2의 메서드 내부 코드는 OkHttp3의 Call 인터페이스에 정의되어 있다.
 + OkHttp3 - Call - RealCall - Dispatcher 확인하자

 

위에서 무거운 작업을 하는 영역에 최대한 가깝게 스레드를 변경해야 한다고 했는데

우리는 ViewModel에서 Thread를 변경하지 않아도

ViewModel Main Thread
api에서 Main Thread -> Worker Thread1 -> Main Thread
ViewModel Main Thread

위와 같은 흐름으로 api호출이 간소화되고 정상적으로 동작한다.

 

만약 중간에 캐싱이라든지 데이터가 많다든지 어떠한 추가 동작이 필요하다면?

그런 경우 Android Developer 코루틴 권장사항의 내용처럼 추가 동작이 필요한 부분 ex) Domain Layer, Data Layer

에서 아래와 같이 Default 등의 Dispatcher를 이용해 스레드를 변경하면 된다.

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun getBookAndAuthors(): BookAndAuthors {
        // In parallel, fetch books and authors and return when both requests
        // complete and the data is ready
        return coroutineScope {
            val books = async(defaultDispatcher) {
                booksRepository.getAllBooks()
            }
            val authors = async(defaultDispatcher) {
                authorsRepository.getAllAuthors()
            }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

 

안드로이드 프로그래밍에서 대부분의 로직은 화면에 표시될 값을 위함이다.

결국엔 Main Thread에서 처리해야 하고, 무거운 작업을 하는 부분만 작업 스레드로 변경하여 Main Safety하게, 동시성을 구현하여 효율적으로 처리하는 것이 핵심이라고 생각한다.

Retrofit2이나 Room이 실제 데이터에 접근하는 로직에 이러한 작업을 해준다고 해서, 우리가 사용할 때 무조건 Main Thread에서 호출하란 말은 아니다. Main Thread에서 시작하되, 중간에 처리할 로직이 있는 경우에는 또 적절한 Thread 변경이 필요한 것이다.

결론

단순 싱글샷 API를 호출하기만 한다면 Main Thread에서만 호출하고,

중간에 어떤 추가 동작이 필요하다면 필요에 따라 Thread를 변경해 준다.

그리고 내부적으로 suspenCancellableCoroutine을 사용하고 그 안에 enqueue 함수를 통해 비동기 처리, 결괏값을 resume하여 호출자 Thread 반환하기 때문에 Main Safety하다.

 

References

- https://developer.android.com/kotlin/coroutines/coroutines-best-practices?hl=ko

- https://github.com/square/retrofit/blob/master/retrofit/src/main/java/retrofit2/KotlinExtensions.kt

- 코드랩 내용

 

반응형

댓글