[Android] Retrofit ? (with MVVM)

Delight Yoon·2022년 11월 7일
1

Android

목록 보기
10/17
post-thumbnail
post-custom-banner

📌 Retrofit?


  • Android에서 REST API 통신을 지원하기 위해 사용하는 라이브러리

  • Type-Safe 한 HTTP 클라이언트 라이브러리

    • 전달받은 데이터를 Client가 필요한 형태의 객체로 전달받을 수 있음.
  • 존재하는 HTTP 통신 라이브러리 중 가장 많이 사용되는 라이브러리.

  • Square 사에서 개발,

    • Square 사에서 개발한 OkHttp 라이브러리의 상위 구현체.
  • AsyncTask 없이 Background에서 작업 수행 후 Callback을 통해 Main Thread에서 동작.

Retrofit 장점


  • 빠른 성능
    OkHttp는 AsyncTask를 사용, AsyncTask 보다 성능이 3~10배 좋다고 한다.
  • 간단한 구현 - 반복된 작업을 라이브러리에 남겨서 처리
    ex) HttpUrlConnectionConnection , Input / output Stream, URL Encoding 생성 및 할당 작업.

  • 가독성
    애너테이션을 사용하여 코드의 가독성이 뛰어나고 직관적인 설계가 가능.

  • 동기/비동기 쉬운 구현

    • 동기 - 동시에 일어난다는 뜻으로, 동기방식은 설계가 매우 간단하고 직관적이지만
      결과가 주어질 때까지 아무것도 못하고 대기해야 하는 단점이 있다.

    • 비동기 - 동시에 일어나지 않는다는 뜻, 비동기방식은 동기보다 복잡하지만
      결과가 주어지는데 시간이 걸리더라도 그 시간 동안 다른 작업을 할 수 있으므로
      자원을 효율적으로 사용할 수 있는 장점이 있다.

Retrofit 구성요소


  • DTO

    • Data Transfer Object
    • JSON 파일 타입 변환에 사용.
    • Response로 받아오는 JSON을 원하는 형태의 Object로 변환할 수 있게 하는 Data Class
  • Interface

    • 사용할 HTTP 메서드(CRUD 동작)들을 정의해놓은 Interface
    • HTTP Method ( POST / GET / PUT / DELETE )
  • Retrofit.Builder

    • Interface를 사용할 Instance
    • BaseUrl 과 Converter 설정.

📌 사용하기


Permission 설정

  <uses-permission android:name="android.permission.INTERNET"/>

인터넷을 사용해야 하므로 AndroidManifest.xml 파일에 다음과 같이 퍼미션을 추가해주자.

build.gradle 추가

implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' // ViewModel KTX
implementation 'com.google.code.gson:gson:2.10'  // Gson

// retrofit
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"

// OkHttp
implementation platform("com.squareup.okhttp3:okhttp-bom:4.9.0")
implementation "com.squareup.okhttp3:okhttp"
implementation "com.squareup.okhttp3:logging-interceptor"

ViewModel KTX 는 viewModelScope를 사용하기 위해서 추가하였다.

JSON 파일을 Gson으로 다루면 네트워크 통신의 결과값을 프로젝트에서 사용하는 객체의 형태로 변환해줄 수 있다. 따라서 받아온 데이터를 다루기가 쉽기 때문에 Gson을 추가해주고,

네트워크 통신으로 받아온 파일은 Json파일인데, JSON 파일을 Gson으로 변환해주는 GSON Converter

그리고 마지막으로 retrofit 라이브러리와 Http 통신의 결과를 로그로 편하게 알려주는 OKHttp 라이브러리를 추가해주자.

그리고 Sync Now 를 눌러주고, 라이브러리를 다운로드 받으면 된다.


우리가 받아오는 json 파일은 다음과 같다.

categories.json

{
  "categories": [
    {
      "category_id": "fashion-female",
      "label": "여성패션",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/77600832/200231990-cd51a677-0ee5-4b11-a71c-84dd9f294c3c.jpg",
      "updated": false
    },
    {
      "category_id": "fashion-male",
      "label": "남성패션",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152873921-5e009324-63ce-4038-9b89-124154df4bb9.jpg",
      "updated": false
    },
    {
      "category_id": "kids",
      "label": "유아동",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152871912-78fd78fb-275f-458c-a469-f2471bff51e1.jpg",
      "updated": false
    },
    {
      "category_id": "electronics",
      "label": "가전/디지털",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152873916-9b376cdc-74a8-4ad2-8da5-8437e5f59390.jpg",
      "updated": false
    },
    {
      "category_id": "furniture",
      "label": "가구/인테리어",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152873919-2c3df161-aa72-4582-8dfa-0f2dba8141e4.jpg",
      "updated": true
    },
    {
      "category_id": "sports",
      "label": "스포츠/레저",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152873928-c5bc47d0-6b61-4722-a043-724dc66bf41e.jpg",
      "updated": false
    },
    {
      "category_id": "books",
      "label": "도서/음반",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152873901-7cca6412-30a1-4824-ab5e-d25f99aeae50.jpg",
      "updated": false
    },
    {
      "category_id": "pets",
      "label": "반려동물",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152873922-4c3d7814-22f6-4fce-bfaa-2ec11f85a134.jpg",
      "updated": false
    },
    {
      "category_id": "car",
      "label": "자동차",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152873906-afa07433-f366-4277-b96f-55d40f05690e.jpg",
      "updated": false
    },
    {
      "category_id": "fancy-goods",
      "label": "잡화",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152873925-a5be3785-622c-4195-a3aa-da80722c91b4.jpg",
      "updated": false
    },
    {
      "category_id": "food",
      "label": "식품",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152873917-c48662ef-a5f9-4306-abd9-0e738c8ebe2d.jpg",
      "updated": false
    },
    {
      "category_id": "beauty",
      "label": "뷰티",
      "thumbnail_image_url": "https://user-images.githubusercontent.com/20774764/152873877-1d09d470-585e-4820-a4bc-f9bf8dedef90.jpg",
      "updated": true
    }
  ]
}

DTO


네트워크 통신의 결과로 받아오는 파일과 같은 DTO를 만들어주자.

import com.google.gson.annotations.SerializedName

data class Category(
    @SerializedName("category_id")  val categoryId : String,
    val label : String,
    @SerializedName("thumbnail_image_url") val thumbnailImageUrl : String,
    val updated : Boolean
)

@SerializedName 을 붙이는 이유는 실제 json 파일의 속성 값과 일치시켜주기 위해서이다.

Interface

import com.youngsun.shoppi.app.model.Category
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import kotlin.math.log

// 어떤 주소와 통신을 할 것인지 선언.
interface ApiClient {

    @GET("categories.json")
    suspend fun getCategories() : List<Category>

    companion object {

        private const val baseUrl = "https://shoppi-9f47b-default-rtdb.asia-southeast1.firebasedatabase.app/"

        fun create() : ApiClient {

            // 로그 메시지의 포맷을 설정.
            val logger = HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BASIC  // 로그 레벨 BASIC
            }

            // OkHttpClient -> HTTP 통신의 결과를 로그 메시지로 출력해준다.
            val client = OkHttpClient.Builder()
                .addInterceptor(logger)
                .build()

            return Retrofit.Builder()
                .baseUrl(baseUrl)
                .client(client)
                .addConverterFactory(GsonConverterFactory.create())
                .build()
                .create(ApiClient::class.java)
        }
    }
}

애너테이션(@)을 사용하여 HTTP Method 를 정의할 수 있고, getCategories()baseUrl + categories.json 에서 GET 메서드를 수행하는 함수이다.

따라서 Base Url 뒤에 / 를 빠뜨리지 않게 주의하자.

Repository


그리고 우리는 네트워크 통신 결과 값을 받기 위해서 getCategories()를 호출할 Repository 클래스가 필요하다.

import com.youngsun.shoppi.app.model.Category
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

// CategoryViewModel 에서 CategoryRepository 를 호출할 때, viewModelScope 에서 호출을 한다.

class CategoryRepository(
    private val remoteDataSource: CategoryRemoteDataSource
) {
    // suspend 키워드를 통해 coroutineScope 에서만 실행할 수 있게끔 강제한다.
    suspend fun getCategories() : List<Category> {

        /**
         * 네트워크 통신은 IO 스레드에서 작업하는 것이 좋다.
         * withContext - 작업할 스레드를 지정할 수 있다.
         *
         * 하지만 이러한 작업은 retrofit 라이브러리가 대신 해주므로 진행하지 않아도 된다.
         */
        /*withContext(Dispatchers.IO) {
            remoteDataSource.getCategories()
        }*/

        return remoteDataSource.getCategories()
    }

}

실제 데이터를 받아오는 기능을 하는 메서드는 RemoteDataSource에 구현되어있으므로, 생성자로 받아, RemoteDataSource의 메서드를 호출하여 Repository에 데이터를 가져오는 것이다.

DataSource


RepositoryDataSource에게 데이터를 요청한다.

CategoryDataSource는 카테고리 화면에 받아올 데이터들의 여러 파일들을 받아오는 기능을 가지고 있다.

import com.youngsun.shoppi.app.model.Category

interface CategoryDataSource {
    suspend fun getCategories() : List<Category>
}

RemoteDataSource


DataSource는 인터페이스므로, 실제 여러 파일들을 받아오는 기능을 구현하기 위해 메서드를 오버라이드하여 재 구현할 클래스가 필요하다.

CategoryRemoteDataSourceCategoryDataSource의 여러 파일들을 받아오는 메서드를 재구현하는 클래스이다.


import com.youngsun.shoppi.app.model.Category
import com.youngsun.shoppi.app.network.ApiClient

class CategoryRemoteDataSource(private val apiClient : ApiClient) : CategoryDataSource {
    override suspend fun getCategories(): List<Category> {
        return apiClient.getCategories()
    }
}

ApiClient를 생성자로 받는 이유는, 네트워크 통신의 결과 값을 받아오기 위함이다.

ViewModel


ViewModelRepository에게 데이터를 요청하여 받아온다.
따라서 CategoryRepositorygetCategories() 를 호출할 CategoryViewModel을 생성하자.

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.youngsun.shoppi.app.model.Category
import com.youngsun.shoppi.app.repository.CategoryRepository
import kotlinx.coroutines.launch

class CategoryViewModel( private val categoryRepository: CategoryRepository) : ViewModel() {

   private val _items = MutableLiveData<List<Category>>()
   val items : LiveData<List<Category>> = _items

   init {
       loadCategory()
   }

   private fun loadCategory() {
       /**
        * repository 에 데이터 요청
        * ViewModelScope 은 어느 스레드에서 실행되고 있을까 ?
        * ViewModel 은 CategoryFragment 에서 생성되기 때문에 CategoryFragment 와 같			은 UI 스레드에서 실행이 되고 있다.
        */
       viewModelScope.launch {
           val categories = categoryRepository.getCategories()
           _items.value = categories
       }
   }
}
  • repository 에 데이터 요청 => 네트워크 통신의 결과 값이므로 IO 스레드에서 처리하는 것이 좋다.
  • ViewModelScope 은 어느 스레드에서 실행되고 있을까 ?
  • ViewModel 은 CategoryFragment 에서 생성되기 때문에 CategoryFragment 와 같은 UI 스레드에서 실행이 되고 있다.

따라서, 우리는 RepositorygetCategories()IO스레드에서 수행되도록 해야한다.


import com.youngsun.shoppi.app.model.Category
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

// CategoryViewModel 에서 CategoryRepository 를 호출할 때, viewModelScope 에서 호출을 한다.

class CategoryRepository(
    private val remoteDataSource: CategoryRemoteDataSource
) {
    // suspend 키워드를 통해 coroutineScope 에서만 실행할 수 있게끔 강제한다.
    suspend fun getCategories() : List<Category> {

        /**
         * 네트워크 통신은 IO 스레드에서 작업하는 것이 좋다.
         * withContext - 작업할 스레드를 지정할 수 있다.
         *
         * 하지만 이러한 작업은 retrofit 라이브러리가 대신 해주므로 진행하지 않아도 된다.
         */
        /*withContext(Dispatchers.IO) {
            remoteDataSource.getCategories()
        }*/

        return remoteDataSource.getCategories()
    }

}

Repository의 코드이다. 위와 같이 withContext를 사용하여 스레드를 Dispatcher를 사용하여 변경했다. 이와 같은 과정이 필요할까 ?

필요하지 않다. Retrofit 라이브러리에서 대신 처리해준다.

대신에, 우리는 getCategories() 메서드가 코루틴스코프 내에서만 실행 가능하게 하도록 suspend 키워드를 붙여주어야 한다.

다음으로, 우리는 ViewModel의 생성 방식을 정의하기 위해 ViewModelFactory에 생성 방식을 추가해주어야 한다.

ViewModelFactory


package com.youngsun.shoppi.app.ui.common

import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.youngsun.shoppi.app.AssetLoader
import com.youngsun.shoppi.app.network.ApiClient
import com.youngsun.shoppi.app.repository.CategoryRemoteDataSource
import com.youngsun.shoppi.app.repository.CategoryRepository
import com.youngsun.shoppi.app.repository.HomeAssetDataSource
import com.youngsun.shoppi.app.repository.HomeRepository
import com.youngsun.shoppi.app.ui.category.CategoryViewModel
import com.youngsun.shoppi.app.ui.home.HomeViewModel

// 여러 ViewModel 이 추가되면서 ViewModel 을 여러 개 생성할 수 있으므로, common 패키지로 이동.
class ViewModelFactory( private val context: Context ): ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return when {
            modelClass.isAssignableFrom(CategoryViewModel::class.java) -> {
                val repository = CategoryRepository( CategoryRemoteDataSource(ApiClient.create()))
                CategoryViewModel(repository) as T
            }
            else -> {
                throw IllegalArgumentException("Failed to create ViewModel : ${modelClass.name}")
            }
        }
    }
}

이제 ViewModel의 생성 방식을 다음과 같이 정의했으므로, 우리는 직접 ViewModel 을 생성하고, Observing 하는 단계만 남았다.

UI layer의 States Holder 를 담당하는 Activity 또는 Fragment에서 ViewModel을 생성하고, 관찰한다.

CategoryFragment


private val viewModel : CategoryViewModel by viewModels { ViewModelFactory(requireContext()) }

다음과 같은 코드로 ViewModel 객체를 생성하고,

viewModel.items.observe(viewLifecycleOwner) {
	Log.d("CategoryFragment", "items=$it")
}

데이터의 값을 Observer가 관찰하면서, 값이 바뀔 때마다 로그를 찍어보자.

전체 코드이다.

package com.youngsun.shoppi.app.ui.category

import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import com.youngsun.shoppi.app.R
import com.youngsun.shoppi.app.ui.common.ViewModelFactory

class CategoryFragment : Fragment() {

    private val viewModel : CategoryViewModel by viewModels { ViewModelFactory(requireContext()) }


    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.fragment_category, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewModel.items.observe(viewLifecycleOwner) {
            Log.d("CategoryFragment", "items=$it")
        }
    }

}

다음과 같은 Log를 확인할 수 있다.

ViewModel에 받아온 데이터 값이 변경됨에 따라, 로그를 띄우는 코드가 아닌 View에 렌더링하는 코드를 작성하면, View의 속성 값을 업데이트 할 수 있고,
OkHttp 라이브러리릍 통해서, 네트워크 통신의 결과 값을 로그 창을 통해 확인할 수 있다.

📌 정리


지금까지 Retrofit을 공부하고, MVVM 패턴에서 Retrofit이 어떻게 동작하는 지 알아보았다.

마지막으로 정리하면, Retrofit의 객체를 생성하고, 네트워크 통신 결과를 받아오는 곳은 RemoteDataSource 이며, 데이터를 받아와 Repository에 전달하고, RepositoryViewModel에게 데이터를 전달한다.

이 과정에서 우리는 네트워크 통신을 사용하기 때문에 IO 스레드에서 작업이 필요한데, 이 부분은 Retrofit이 대신하여 처리해줌을 알았고, suspend 키워드를 붙여줘야 함을 알 수 있었다.

ViewModel의 데이터는 최종적으로, UI layer의 States HolderFragment 또는 Activity에서 데이터를 관찰하여 값이 변경 됨에 따라, View 의 속성 값이 바뀌는 것이다.

📌 References


profile
Yoon's Dev Blog
post-custom-banner

0개의 댓글