Android에서 REST API
통신을 지원하기 위해 사용하는 라이브러리
Type-Safe 한 HTTP 클라이언트 라이브러리
존재하는 HTTP 통신 라이브러리 중 가장 많이 사용되는 라이브러리.
Square 사에서 개발,
AsyncTask 없이 Background에서 작업 수행 후 Callback을 통해 Main Thread에서 동작.
간단한 구현 - 반복된 작업을 라이브러리에 남겨서 처리
ex) HttpUrlConnection
의 Connection
, Input / output Stream
, URL Encoding
생성 및 할당 작업.
가독성
애너테이션을 사용하여 코드의 가독성이 뛰어나고 직관적인 설계가 가능.
동기/비동기 쉬운 구현
동기
- 동시에 일어난다
는 뜻으로, 동기방식은 설계가 매우 간단하고 직관적이지만
결과가 주어질 때까지 아무것도 못하고 대기해야 하는 단점이 있다.
비동기
- 동시에 일어나지 않는다
는 뜻, 비동기방식은 동기보다 복잡하지만
결과가 주어지는데 시간이 걸리더라도 그 시간 동안 다른 작업을 할 수 있으므로
자원을 효율적으로 사용할 수 있는 장점이 있다.
DTO
Data Transfer Object
Interface
Retrofit.Builder
<uses-permission android:name="android.permission.INTERNET"/>
인터넷을 사용해야 하므로 AndroidManifest.xml
파일에 다음과 같이 퍼미션을 추가해주자.
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": [
{
"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를 만들어주자.
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
파일의 속성 값과 일치시켜주기 위해서이다.
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
뒤에 /
를 빠뜨리지 않게 주의하자.
그리고 우리는 네트워크 통신 결과 값을 받기 위해서 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
에 데이터를 가져오는 것이다.
Repository
는 DataSource
에게 데이터를 요청한다.
CategoryDataSource
는 카테고리 화면에 받아올 데이터들의 여러 파일들을 받아오는 기능을 가지고 있다.
import com.youngsun.shoppi.app.model.Category
interface CategoryDataSource {
suspend fun getCategories() : List<Category>
}
DataSource
는 인터페이스므로, 실제 여러 파일들을 받아오는 기능을 구현하기 위해 메서드를 오버라이드하여 재 구현할 클래스가 필요하다.
CategoryRemoteDataSource
는 CategoryDataSource
의 여러 파일들을 받아오는 메서드를 재구현하는 클래스이다.
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
은 Repository
에게 데이터를 요청하여 받아온다.
따라서 CategoryRepository
의 getCategories()
를 호출할 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
}
}
}
IO
스레드에서 처리하는 것이 좋다.따라서, 우리는 Repository
의 getCategories()
를 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
에 생성 방식을 추가해주어야 한다.
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
을 생성하고, 관찰한다.
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
에 전달하고, Repository
는 ViewModel
에게 데이터를 전달한다.
이 과정에서 우리는 네트워크 통신을 사용하기 때문에 IO
스레드에서 작업이 필요한데, 이 부분은 Retrofit
이 대신하여 처리해줌을 알았고, suspend
키워드를 붙여줘야 함을 알 수 있었다.
ViewModel
의 데이터는 최종적으로, UI layer의 States Holder
인 Fragment
또는 Activity
에서 데이터를 관찰하여 값이 변경 됨에 따라, View
의 속성 값이 바뀌는 것이다.