※ 2023-10-15 Retrofit 관련된 새로운 글을 작성했습니다. 해당 글을 읽어주시길 바라며, 이 글은 더 이상 읽지 않을 것을 권장합니다.
※ 주의사항(2023-06-19 코멘트 추가) 이 글은 과거에 작성된 글로 조금 잘못된 지식을 설명하고 있습니다. 곧 수정할 예정이지만 혹시 그 전에 읽는다면 반드시 아래 내용을 유의하여 읽어주세요.
- viewModel에서는 코루틴 스코프를 직접 만드는 것이 아닌 viewModelScope를 이용해야 합니다. viewModelScope는 Main 스레드를 이용합니다.
- Retrofit2의 serviece를 만들 때 즉, api interface를 정의할 때 단순히 fun 앞에 suspend 키워드를 붙여주면 별도의 Adapter를 정의하지 않고 코루틴을 이용하여 Retrofit2를 이용할 수 있습니다. 이 경우 Retrofit2 내부적으로 IO스레드를 붙여주기 때문에 명시적으로 withContext(Dispatchers.IO)로 스레드를 변경할 필요가 없습니다.
※ 이 글은 상세한 설명보다는 각 코드에 대한 주관적인 생각만 담고 있습니다.
이 글에서 사용된 코드는 이 리포지토리에 작성되어 있다. ServerConfig 싱글톤(kotlin 의 object 키워드 이용) 객체로 baseUrl 만 담고 있고, .gitignore 에 추가되어 깃허브에서는 확인할 수 없다. Retrofit 의 Call 객체와 Coroutine 을 이용하여 5가지 방법으로 Retrofit 라이브러리를 이용한다. RxJava 는 사용하지 않았다.
기반 코드
object RetrofitFactory {
fun <T> createRetrofitService(service: Class<T>): T {
return Retrofit.Builder()
.baseUrl(ServerConfig.baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(service)
}
}
Serviece 객체를 만들기 위한 Factory Method 이다. 이 클래스는 service 인터페이스를 받아 service 객체를 반환하는 하나의 메소드만 구현되어 있다.
data class Memo(val id: Long, val content: String, val statue: String)
데이터 모델이다. 서버에서 데이터를 받아올 Entity 로 사용한다. 모델의 필드를 이렇게 정의한 특별한 이유는 없고, 내가 이미 올려두고 지금 돌아가고 있는 서버에서 사용하는 모델이라 채택했다.
interface CallService {
@GET("memo/{id}")
fun getMemo(@Path(value = "id") id: Long): Call<Memo>
@POST(".")
fun writeMemo(
@Body requestBody: Memo
): Call<Memo>
}
interface CallResponseService {
@GET("memo/{id}")
suspend fun getMemo(@Path(value = "id") id: Long): Response<Memo>
@POST(".")
suspend fun writeMemo(
@Body requestBody: Memo
): Response<Memo>
}
두 가지 service 를 정의했다. 필요에 따라 둘을 바꿔 끼워가며 사용할 예정이라 위에서 정의한 Factory Method 도 제네릭을 이용했다. 하나는 Call 객체를 반환해 enqueue 할 때 onResponse 와 onFailure 메소드를 오버라이드 하여 사용한다. 다른 하나는 Response 객체만 이용한다. Response 를 이용해 onFailure 와 같은 효과를 내고 싶다면 try catch 로 감싸면 될 것이다.
1. 가장 먼저 공부한 Retrofit 사용 방법
fun getMemoWithCallAsync() {
val service = getRetrofitService(CallService::class.java)
val call = service.getMemo(1)
call.enqueue(object : retrofit2.Callback<Memo> {
override fun onResponse(call: Call<Memo>, response: Response<Memo>) {
if(response.isSuccessful) {
Log.d(tag, "가장 기본적인 Retrofit 사용 방법")
Log.d(tag, "${response.body()?.toString()}")
}
else {
Log.e(tag, "onResponse, status : ${response.code()}, message : ${response.message()}")
}
}
override fun onFailure(call: Call<Memo>, t: Throwable) {
Log.e(tag, "onFailure")
t.printStackTrace()
}
})
}
이 코드가 응애시절에 처음 배운 Retrofit 사용법이다. 이런 코드를 이용한다면 투두리스트보다 복잡한 프로젝트 하나만 해 봐도 코드가 매우 더러워진다는 것을 알 수 있다. 로직에 따라서는 response 값을 받은 후 다른 서버 요청을 보내야할 수도 있다. 이럴 경우 콜백 지옥에 빠진다. JavsScript 에서의 콜백지옥보다 몇 배는 복잡하다고 생각한다.
object Callback : retrofit2.Callback<Memo> {
override fun onResponse(call: Call<Memo>, response: Response<Memo>) {
if(response.isSuccessful) {
Log.d("tag", "가장 기본적인 Retrofit 사용 방법")
Log.d("tag", "${response.body()?.toString()}")
}
else {
Log.e("tag", "onResponse, status : ${response.code()}, message : ${response.message()}")
}
}
override fun onFailure(call: Call<Memo>, t: Throwable) {
Log.e("tag", "onFailure")
t.printStackTrace()
}
}
물론 대부분의 예제에서 사용하고 있는 onResponse 와 onFailure 의 구현은 위처럼 별도로 분리할 수 있다. kotlin 을 처음 사용한다면 조금 당황스러울 수 있지만, Callback<Memo> 인터페이스를 구현한 익명 싱글톤 객체일 뿐이다. 위 코드는 그 객체에 이름만 붙여줬을 뿐이다.
비록 무지성 코드를 작성해왔어도, JavaScript 6개월 프로젝트와 Dart 외주 프로젝트로 단련해온 나는 그렇게 호락호락하지 않다. 이 두 언어는 JVM 계열 언어보다 스레드는 부족하지만 비동기는 매우 잘 처리했다고 생각한다. 둘 다 async & await 를 지원한다. 이것과 비슷한 개념을 kotlin 에서도 쓸 수 있을 거라 생각했던 게 계기가 되었다. 찾아보면 Coroutine 을 이용하는 방법과 RxJava 를 이용하는 방법이 있고 지금은 Coroutine 을 먼저 공부하고 있다.
동기와 비동기 처리 혹은 그와 비슷해보이게 동작하는 코드는 코틀린 코드로도 생각보다 많이 찾을 수 있었다. JS 를 사용하는 것보다는 조금 복잡하긴 하다.
2. Call 객체와 서브 스레드를 이용하는 방법
fun getMemoWithCallSync() {
val service = getRetrofitService(CallService::class.java)
val call = service.getMemo(1)
/**
* Main Thread 에서 사용하면 Exception 발생.
*/
Thread {
val result = call.execute()
if(result.isSuccessful) {
Log.d(tag, "스레드 + 동기를 이용한 Retrofit 사용 방법")
Log.d(tag, "${result.body()?.toString()}")
}
else {
Log.e(tag, "onResponse, status : ${result.code()}, message : ${result.message()}")
}
}.start()
}
DB 접근과 통신은 느리다. 언어에서도 기본적으로 메인 스레드에서 통신할 수 없게 되어 있다. 하지만 서브 스레드를 이용하면 통신도 동기적으로 처리할 수 있다. 물론 서브 스레드 안에서만. 만약 메인 스레드에서 5초 이상 기다리는 일이 발생하면 ANR 에러가 발생한다. 스레드 객체를 만드는 과정에 의문이 생긴다면 람다를 찾아보면 된다.
Call 객체를 만드는 메소드에서 통신하는 게 아니라, call.execute 할 때 통신하게 된다. 따라서 이 부분부터 스레드로 감싸주면 된다.
3. Coroutine 과 Response 객체를 이용하는 방법
fun getMemoWithResponse() {
val service = getRetrofitService(CallResponseService::class.java)
CoroutineScope(Dispatchers.IO).launch {
val response = service.getMemo(1)
if(response.isSuccessful) {
Log.d(tag, "suspend function 과 코루틴 이용")
Log.d(tag, "${response.body()?.toString()}")
}
else {
Log.e(tag, "onResponse, status : ${response.code()}, message : ${response.message()}")
}
}
}
이전 코드와는 다른 service 를 사용하고 있음에 유의하라.
코루틴은 세 가지 Dispatcher 를 제공한다. ( 참고자료 ) 코루틴 스코프 안에서 실행되는 로직은 비동기 작업은 만나면 잠시 중단 됐다가 비동기 작업이 끝나면 다시 실행된다. 이때 어떤 스레드에서 실행될 것인지 Dispatcher 를 통해 지정할 수 있다. UI 작업을 하는 게 아니라면 굳이 Main 스레드를 사용할 필요는 없는 것 같다.
Response 객체를 만드는 시점에서 통신이 이루어진다. service interface 에서 getMemo 메소드를 suspend 로 만들었다. suspend 메소드는 반드시 다른 suspend 메소드 안에서 호출되거나 코루틴 스코프 안에서 호출되어야 한다.
4. Coroutine 과 async & await 를 이용한 구현
suspend fun getMemoWithAwait() {
val service = getRetrofitService(CallResponseService::class.java)
val deferred = CoroutineScope(Dispatchers.IO).async {
service.getMemo(1)
}
val result = deferred.await()
Log.d(tag, result.body().toString())
}
// 사용하는 곳
CoroutineScope(Dispatchers.IO).launch {
getMemoWithAwait()
}
이 코드가 다른 언어와 비슷하면서도 다른 언어보다 사용하기 어렵다고 생각하는 부분이다. suspend 키워드가 다른 언어에서의 async 와 비슷하다. async + await 가 다른 언어에서의 await 와 비슷한 것 같다. 이렇게 사용하지 않고 Deferred 객체를 반환하거나 deferred.await() 를 반환할 수도 있을 것 같다. 그런데 이 경우 그냥 Response 객체를 반환하는 것에 비해 이점이 없다. 만약 이 방법을 사용한다면 예외 처리 코드를 await 를 호출하는 쪽에서 작성하고 response.body() 를 반환하는 게 사용하기에 좋아보인다.
여기서 Deferred 객체가 JavaScript 의 Promise 객체와 비슷해 보인다. 다만 Promise 의 경우 .then 을 이용해 쉽게 사용할 수 있지만 Deferred 는 스코프로 감싸야 하는 번거로움이 있다.
5. 4번 방법을 조금 더 간단히
suspend fun fetchMemo() = withContext(Dispatchers.IO) {
val service = getRetrofitService(CallResponseService::class.java)
service.getMemo(1)
}
// 사용하는 곳
CoroutineScope(Dispatchers.Main).launch {
val result = fetchMemo()
if(result.isSuccessful) {
Log.d(tag, result.body().toString())
}
}
여기부터 01-13 에 글을 수정하면서 가장 많이 달라진 부분이다.
withContext 는 두 개의 파라미터를 받는다. 첫 번째는 코루틴 컨텍스트이고 두 번째는 수신 객체 타입의 코루틴 스코프이다. 수신 객체 타입은 확장 함수처럼 동작한다. 여기선 코루틴 컨텍스트로 Dispatchers.Main 을 넘겨 줬는데, Dispatchers.Main 의 상속 구조를 타고 올라가면 코루틴 컨텍스트를 구현하고 있다. 수신 객체 타입으로 CoroutineScope. 를 받고 있으므로 이 메소드는 반드시 코루틴 스코프 안에서 사용되어야 한다. (혹은 suspend) 실제로는 CoroutineScope 라는 클래스 안에서 this.withContext(Dispatchers.Main) 을 호출한다고 생각하면 된다.
6. withContext 를 이용하는 또 다른 방법
suspend fun fetchAndDisplay() {
val service = getRetrofitService(CallResponseService::class.java)
coroutineScope {
// UI 와 관련된 데이터를 얻는다.
val memo = async(Dispatchers.IO) { service.getMemo(1) }
withContext(Dispatchers.Main) {
// doSomeWork
val result = memo.await()
// 얻은 data 를 이용해 UI 에 뿌려준다.
}
}
}
coroutineScope 는 코루틴 스코프를 만들고 람다를 실행한다. 마찬가지로 수신 객체 타입을 파라미터로 받는다.
위 코드처럼 작성하면, doSomeWork 부분과 네트워크를 통해 데이터를 가져오는 부분을 병렬로 실행할 수 있다. 또한 네트워크에서 가져오는 부분은 IO 스레드에서, soSomeWork 부분은 Main 스레드에서 실행한다.
기반 코드에서 추가된 부분 (22-01-13)
object OkHttp3Util {
private val client = OkHttpClient()
fun createClientWithLogInterceptor() : OkHttpClient {
return OkHttpClient.Builder().addInterceptor(LogInterceptor.createLogInterceptor()).build()
}
}
object LogInterceptor {
fun createLogInterceptor() : HttpLoggingInterceptor {
val interceptor = HttpLoggingInterceptor()
interceptor.level = HttpLoggingInterceptor.Level.BODY
return interceptor
}
}
object RetrofitFactory {
fun <T> createRetrofitService(service: Class<T>): T {
return Retrofit.Builder()
.baseUrl(ServerConfig.baseUrl)
.client(OkHttp3Util.createClientWithLogInterceptor()) // 이 부분 추가
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(service)
}
}
로그 인터셉터를 추가했다. 로그 인터셉터를 추가하면 헤더, 바디, 응답 코드 등의 로그를 쉽게 관리할 수 있다.
참고 자료
https://developer.android.com/kotlin/coroutines-adv?hl=ko (번역에 주의)
https://developer88.tistory.com/67
'안드로이드' 카테고리의 다른 글
[안드로이드] 리사이클러뷰 데이터 바인딩 (RecyclerView data binding) (1) | 2022.01.15 |
---|---|
[안드로이드] ViewModel 에서 이벤트 처리하는 방법 (SingleLiveEvent) (0) | 2022.01.14 |
[안드로이드] 보일러 플레이트 (상용구 코드) (0) | 2021.12.31 |
[안드로이드] 화면 전환 - Navigation (0) | 2021.12.25 |
[안드로이드 #6] Fragment, ViewPager2 (0) | 2021.05.27 |