📌 Paging이란?
- Paging이란 데이터를 가져올 때 한 번에 모든 데이터를 가져오는 것이 아니라 일정한 덩어리로 나눠서 가져오는 것을 뜻한다.
- 예를 들어 구글 같은 포털 사이트에서 검색을 할 때 모든 데이터를 한꺼번에 가져오는 것이 아니라 페이지 단위로 데이터를 가져오는 것을 볼 수 있다.
- 페이징을 사용하면 성능, 메모리, 비용 측면에서 효율적이다.
📌 Paging3 라이브러리
- Android Jetpack에서는 페이징을 위한 Paging3 라이브러리를 제공하고 있다.
- Paging3 라이브러리는 로컬 저장소에서나 네트워크를 통해 데이터를 나누어 효율적으로 로딩할 수 있게 도와준다.
- Paging3 라이브러리는 구글에서 권장하는 Android App Architecture에 맞게 설계되어있다.
- 다른 Jetpack 구성 요소와 깔끔하게 통합되며 코틀린 우선 지원을 제공한다.
📌 Paging3의 장점
- 페이징 된 데이터에 대한 인-메모리 캐싱을 지원한다. 앱이 페이징 된 데이터로 작업하는 동안 시스템 리소스를 효율적으로 사용할 수 있게 된다.
- 인-메모리 캐싱이란 애플리케이션 운영을 위한 데이터를 하드웨어가 아닌 메모리에서 저장 및 수행하는 것
- 데이터 처리시에 발생하는 하드디스크의 비용을 절감할 수 있으며 처리 속도가 향상되어 성능을 개선시킬 수 있다.
- 내장된 요청 중복 제거를 통해 앱이 네트워크 대역폭과 시스템 리소스를 효율적으로 사용하도록 한다.
- 사용자가 로드 한 데이터의 끝으로 스크롤 할 때 데이터를 자동으로 요청하는 구성이 가능한 RecyclerView 어댑터 즉 PagingDataAdapter를 제공한다.
- 코틀린 코루틴과 Flow를 우선적으로 지원하며, LiveData 및 RxJava를 지원한다.
- 새로 고침 및 재시도 기능을 포함하여 오류 처리를 위한 기본적인 방법을 제공한다.
라이브러리 구조
Paginig3 라이브러리는 총 3개의 Layer로 구성된다.
- Repository Layer
- ViewModel Layer
- UI Layer
간략히 설명하면 PagingSource나 RemoteMediator와 PagingConfig의 정보를 토대로 Pager를 통해 PagingData를 생성한 뒤, 해당 인스턴스를 PagingDataAdapter를 활용하여 UI에 그리는 구조로 설계되어 있다.
📌 Repositroy Layer
Repositroy Layer 계층의 가장 기본적인 구성요소는 PagingSource이다. Paging3에서 크게 달라진 점은, 기존 Paging2에서 여러가지로 나뉘었던 DataSource를 처리하던 부분이 PagingSource 하나로 통합되었다는 점이다. PagingSource 개체는 데이터 소스와 해당 소스에서 데이터를 검색하는 방법을 정의한다. PagingSource 개체는 네트워크 소스 및 로컬 데이터베이스를 포함하여 전체 데이터로부터 부분적으로 데이터를 로드 할 수 있다.
PagingSource는 데이터 소스를 정의하기 위한 클래스로 Key타입과, 반환할 데이터 형을 Generic으로 받는다.
class PagingSourceExample: PagingSource<Int, PagingDataExample>() {
override fun getRefreshKey(state: PagingState<Int, TestPagingData>): Int? {
TODO("Not yet implemented")
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, TestPagingData> {
TODO("Not yet implemented")
}
}
- getRefreshKey 함수는 초기 key값이나 데이터 로드 중단 후 재 로드시 이전 position에서 중단된 key값을 가져오는 등 load에서 사용할 key 값을 가져오는 로직 구현을 위한 함수이다.
- load 함수는 실제 데이터를 가져오는 로직구현을 위한 함수이다.
또 다른 구성요소로 RemoteMediator가 있다. RemoteMediator 개체는 네트워크로부터 받은 데이터를 로컬 데이터베이스(SQLite, Room과 같은)를 통해 캐시 하는 경우 페이징 하는데 함께 사용할 수 있다. 즉, Paging3에서 지원하는 내부 DB캐싱에 관련된 역할을 수행하는 클래스라고 할 수 있다.
📌 ViewModel Layer
// Paging Source만 사용하는 형태의 Pager생성
val pagingData: Flow<PagingData<TestPagingData>> = Pager(
config = PagingConfig(pageSize = 10)
) {
PagingSourceExample()
}.flow.cachedIn(viewModelScope)
// RemoteMediator를 사용하는 형태의 Pager생성
val pagingDataFlow: Flow<PagingData<TestPagingData>> = Pager(
config = PagingConfig(pageSize = 10),
remoteMediator = TestRemoteMediator()
) {
PagingSourceExample()
}.flow
Pager
- Pager는 PagingSource 나 RemoteMediator와 PageConfig의 정보를 토대로 PagingData를 생성한 뒤 스트림화 해주는 클래스이다.
- Pager는 PagingSource 개체 및 PagingConfig 개체를 기반으로 반응형 스트림에서 사용되는 PagingData 인스턴스를 구성하기 위한 공용 API를 제공한다.
- 스트림화 시에는 Flow, LiveData, RxJava와 같은 Flowable 유형과 Observable유형 모두를 지원한다.
PagingData
- ViewModel Layer를 UI에 연결하는 구성 요소이다.
- PagingData 개체는 페이지가 매겨진 데이터의 스냅 샷을 위한 컨테이너로, PagingSource 개체를 쿼리하고 결과를 저장한다.
📌 UI Layer
UI 계층의 기본 Paging 라이브러리 구성 요소는 PagingDataAdapter로 페이지가 매겨진 데이터를 처리한다. 만약 PagingDataAdapter가 아닌 RecyclerView.Adapter 등을 확장하는 커스텀 어댑터를 구현하려면 AsyncPagingDataDiffer를 사용하면 된다.
class ExampleAdapter(diffCallback: DiffUtil.ItemCallback<ExampleModel>) :
PagingDataAdapter<ExampleModel, ExampleViewHolder>(ExampleDiffCallback) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): ExampleViewHolder {
return ExampleViewHolder(parent)
}
override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item)
}
}
object ExampleDiffCallback : DiffUtil.ItemCallback<ExampleModel>() {
override fun areItemsTheSame(oldItem: ExampleModel, newItem: ExampleModel): Boolean {
return oldItem.id == newItem.id // 만약 ExampleModel에 id 요소가 없다면 hashcode로 대체한다.
}
override fun areContentsTheSame(oldItem: ExampleModel, newItem: ExampleModel): Boolean {
return oldItem == newItem
}
}
- RecyclerView를 통해 데이터를 보여주기 위해서는 어댑터를 설정해야 한다.
- Paging 라이브러리는 이를 위해 PagingDataAdapter 클래스를 제공한다.
- 어댑터에서 onCreateViewHolder() 및 onBindViewHolder() 메서드를 재정의하게 되는데 추가적으로 DiffUtil.ItemCallback을 지정해야 한다. 기존에 ListAdapter에서 수행하는 것과 동일하게 작동한다.
https://jminie.tistory.com/146?category=1040997
💻 코드 예제 (실습)
📌 Room DB에서 데이터 페이징 하기
의존성 추가
build.gradle(Module)
dependencies {
// Room에서의 Paging3
implementation 'androidx.room:room-paging:2.4.3'
// 네트워크 API에서의 Paging3
implementation 'androidx.paging:paging-runtime-ktx:3.1.1'
}
이번 실습에서는 Room DB에서 불러온 데이터와 API에서 불러온 데이터를 모두 페이징 처리해 볼 것이기 때문에 Room에서의 Paging 라이브러리와 일반 Paging 라이브러리를 모두 추가해준다.
또한 해당 버전은 2022 9월 기준이다. 버전은 지속해서 올라갈 수 있다.
PagingSource 정의
@Dao
interface BookSearchDao {
...
@Query("SELECT * FROM books")
fun getFavoritePagingBooks(): PagingSource<Int, Book> // Room 은 쿼리 결과를 PagingSource 타입으로 반환받을 수 있다.
}
원래 기본적인 로직은 Repository로부터 PagingSource를 반환받아야 하는데 Room 같은 경우는 쿼리 결과를 PagingSource로 받아올 수 있다. 이런 것이 위에서 말한 Paging3 라이브러리의 특징 중 "다른 Jetpack 구성 요소와 깔끔하게 통합"이라는 특징에 부합하는 것 같다.
Pager 정의
다음은 Repository에 페이징 데이터를 반환하는 Pager를 정의해보겠다. 이를 위해 우선 Repositroy 인터페이스부터 정의해준다.
interface BookSearchRepository {
...
// Paging
fun getFavoritePagingBooks(): Flow<PagingData<Book>>
}
또한 각 페이징을 통해 한번에 가져올 데이터 수를 Constants에 정의해준다.
object Constants {
...
const val PAGING_SIZE = 15 // 페이징에서 한번에 가져올 데이터 수
}
이제 Repository 인터페이스에 명세되어 있는 함수를 RepositroyImpl을 통해 구현해보자.
class BookSearchRepositoryImpl(
private val db: BookSearchDatabase,
private val dataStore: DataStore<Preferences>
) : BookSearchRepository {
...
override fun getFavoritePagingBooks(): Flow<PagingData<Book>> {
val pagingSourceFactory = {
db.bookSearchDao().getFavoritePagingBooks()
}
return Pager(
config = PagingConfig(
pageSize = PAGING_SIZE,
enablePlaceholders = false, // true 라면 전체 데이터사이즈를 미리 받아와서 RecyclerView 에 미리 홀더를 만들어 놓고 나머지를 Null 로 만든다.
maxSize = PAGING_SIZE * 3 // Pager 가 메모리에 최대로 가지고 있을 수 있는 항목의 개수
),
pagingSourceFactory = pagingSourceFactory
).flow
}
}
Pager를 구현하기 위해서는 우선 PagingConfig를 통해 파라미터를 전달해주어야 한다. 이때 PagingConfig는 3가지 파라미터를 갖는다.
- pageSize : 한 번에 불러올 데이터의 수로 어떤 기기가 대상이 되더라도 ViewHolder가 표시할 데이터가 부족하지 않도록 설정해준다.
- enablePlaceholders : 만약 이 파라미터가 true로 되어 있다면 Repository 전체 데이터 사이즈를 불러와서 RecyclerView에 PlaceHolder를 미리 만들어 놓고 화면에 표시되지 않는 항목은 null로 처리한다. 나는 데이터가 필요할 때마다 그때그때 불러올 것이기 때문에 false로 설정해주었다.
- maxSize : Pager가 메모리에 최대로 가지고 있을 수 있는 항목의 개수이다. 여기서는 pageSize의 3배로 설정해 주었다.
마지막으로 위에서 구현하였던 DAO의 getFavoritePagingBooks()의 결과를 pagingSourceFactory로 전달해주고 flow연산자를 통해 Pager데이터를 Flow로 만들어주면 된다.
다음으로 ViewModel에서 이 PagingData를 사용하기 위한 함수를 정의해보자.
ViewModel 정의
class BookSearchViewModel(
private val bookSearchRepository: BookSearchRepository
) : ViewModel() {
...
...
// Paging
val favoritePagingBooks: StateFlow<PagingData<Book>> =
bookSearchRepository.getFavoritePagingBooks()
.cachedIn(viewModelScope) // 코루틴이 데이터흐름을 캐시하고 공유 가능하게 만든다.
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PagingData.empty())
// UI 에서 관찰해야하는 데이터이기 때문에 stateIn을 써서 StateFlow 로 만들어준다.
}
우선 StateFlow를 하나 선언해주고 그 안에 PagingData로 묶어준 Book 데이터를 담아준다.
- 이때 Repository에서 가져온 데이터를 코루틴이 공유 가능하게 만들기 위해 cachedIn 연산자를 써준다.
- cachedIn() 연산자는 데이터 스트림을 공유 가능하게 만들고 제공된 CoroutineScope를 사용하여 로드된 데이터를 캐시 한다.
- cachedIn() 연산자는 데이터 스트림을 공유 가능하게 만들고 제공된 CoroutineScope를 사용하여 로드된 데이터를 캐시 한다.
- 또한 UI에서 Observe 해주어야 하는 데이터이기 때문에 StateIn 연산자를 써서 이를 StateFlow로 만들어준다.
다음으로 페이징 데이터를 처리할 수 있는 RecyclerViewAdapter 즉 PagingDataAdapter를 정의해보도록 하자.
RecyclerViewAdapter(PagingDataAdapter) 정의
class BookSearchPagingAdapter :
PagingDataAdapter<Book, BookSearchAdapter.BookSearchViewHolder>(BookDiffCallback) {
override fun onBindViewHolder(holder: BookSearchAdapter.BookSearchViewHolder, position: Int) {
val pagedBook = getItem(position)
pagedBook?.let { book ->
holder.bind(book)
holder.itemView.setOnClickListener {
onItemClickListener?.let { it(book) }
}
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BookSearchAdapter.BookSearchViewHolder {
return BookSearchAdapter.BookSearchViewHolder(
ItemBookPreviewBinding.inflate(LayoutInflater.from(parent.context), parent, false)
)
}
private var onItemClickListener: ((Book) -> Unit)? = null
fun setOnItemClickListener(listener: (Book) -> Unit) {
onItemClickListener = listener
}
object BookDiffCallback : DiffUtil.ItemCallback<Book>() {
override fun areItemsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem.isbn == newItem.isbn
}
override fun areContentsTheSame(oldItem: Book, newItem: Book): Boolean {
return oldItem == newItem
}
}
}
기존에 우리가 많이 사용하던 ListAdapter와 거의 유사하다. ListAdpater 대신 PagingDataAdapter를 상속받고 있다는 점과 getItem(position) 부분이 null이 될 수 있기 때문에 null 처리를 해주어야 한다는 점 말고는 사용 방식은 완전히 동일하다.
이제 페이징 된 데이터를 보여줄 준비가 끝났다. UI에서 데이터를 보여주기만 하면 된다.
View(Fragment) 정의
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
...
...
// 페이징 데이터는 시간에 따라 변화하는 특성을 가지고 있기 때문에
// 반드시 collectLatest 를 사용해서 기존의 값을 취소하고 새 값을 가져오도록 한다.
collectLatestStateFlow(bookSearchViewModel.favoritePagingBooks) {
bookSearchAdapter.submitData(it)
}
}
여기서 2가지를 주의해서 보아야 한다.
- 기존에 RecyclerViewAdapter에서 방식인 submitList가 아니라 submitData로 데이터를 넣어주어야 한다.
- 페이징 데이터는 시간에 따라 변화하는 특성을 가지고 있기 때문에 collect가 아니라 collectLatest를 사용해서 기존의 값을 취소하고 새 값을 가져오도록 해야 한다. (실제로 collect로 작동해보니 데이터가 제대로 받아와지지 않았다.)
참고로 collectLatestStateFlow는 collectLatest를 통해 StateFlow를 옵저빙 할 수 있도록 만든 확장 함수이다.
fun <T> Fragment.collectLatestStateFlow(flow: Flow<T>, collector: suspend (T) -> Unit) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
flow.collectLatest(collector)
}
}
}
📌 네트워크 응답에서 데이터 페이징 하기
이번에는 API 응답에서 데이터를 페이징을 해보자. 전체적인 흐름은 Room DB에서 페이징을 하는 것과 동일하지만 결괏값을 알아서 PagingSource로 반환해주던 Room과 달리
// Room은 결과값을 알아서 PagingSource로 반환해준다.
@Query("SELECT * FROM books")
fun getFavoritePagingBooks(): PagingSource<Int, Book>
네트워크 API 응답은 우리가 직접 PaigingSource를 가공하는 과정이 추가되어야 한다.
PagingSource는 크게 Key를 만드는 부분과 PagingSource를 만드는 부분으로 나뉜다.
- Key는 읽어올 페이지 번호로 사용된다.
- 이때 이 Key를 전달해서 받아온 데이터로 PagingSorce를 작성하게 된다.
PagingSource
class BookSearchPagingSource(
private val query: String,
private val sort: String,
) : PagingSource<Int, Book>() {
// 여러가지 이유로 페이지를 갱신해야 될 때 사용하는 함수
override fun getRefreshKey(state: PagingState<Int, Book>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
// Pager 가 데이터를 호출할 때 마다 불리는 함수
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Book> {
return try {
val pageNumber = params.key ?: STARTING_PAGE_INDEX
val response = api.searchBooks(query, sort, pageNumber, params.loadSize)
val endOfPaginationReached = response.body()?.meta?.isEnd!!
val data = response.body()?.books!!
val prevKey = if (pageNumber == STARTING_PAGE_INDEX) null else pageNumber - 1
val nextKey = if (endOfPaginationReached) {
null
} else {
pageNumber + (params.loadSize / PAGING_SIZE)
}
LoadResult.Page(
data = data,
prevKey = prevKey,
nextKey = nextKey,
)
} catch (exception: IOException) {
LoadResult.Error(exception)
} catch (exception: HttpException) {
LoadResult.Error(exception)
}
}
companion object {
const val STARTING_PAGE_INDEX = 1
}
}
PagingSource는 abstrack class 타입의 PagingSource를 상속받게 되는데 이 안에는 페이지 타입과 데이터 타입이 들어가게 된다. 이때 페이지 타입은 1, 2, 3.. 과 같은 정수형이 들어갈 것이기 때문에 Int로 선언해주고 데이터 타입에는 우리가 받아올 데이터 타입을 넣어주면 된다.
그리고 위 그림에서 본 것처럼 Paging3 라이브러리의 디폴트 시작 페이지는 null이기 때문에 companion object를 통해 시작 페이지를 1로 지정해주었다.
load 함수
- load함수는 Pager가 데이터를 호출할 때마다 불리는 함수이다.
- LoadResult.Page에는 반환받을 데이터, 이전 페이지, 다음 페이지 값을 정의해 넣어준다.
- 이때 이전 페이지는 현재 페이지 넘버가 1 즉 첫 번째 페이지라면 null일 것이고 1이 아니라면 현재 페이지에서 1을 뺀 값일 것이다.
- 또한 다음 페이지는 받아오는 API에서 다음 데이터가 있다 없다를 판별해주는 Boolean 값을 받아와 마지막 페이지라는 것을 인식하면 null을 넣어준다. 그 말은 즉 페이징 라이브러리를 사용하려면 받아오는 데이터에 페이지의 마지막 여부를 알려주는 Boolean 타입의 값이 필요하다는 말이다.
getRefreshKey 함수
- 여러 가지 이유로 페이지를 갱신해야 할 때 수행되는 함수
- 가장 최근에 접근한 페이지를 anchorPosition으로 그 주위에 페이지를 읽어오도록 Key를 반환해주는 역할을 한다.
Pager 정의
Room때와 마찬가지로 Repository에 페이징 데이터를 반환하는 Pager를 정의해야 한다. 인터페이스 즉 Repository에 명세를 작성해주고 RepositoryImpl에서 명세를 구현해준다.
class BookSearchRepositoryImpl(
private val db: BookSearchDatabase,
private val dataStore: DataStore<Preferences>
) : BookSearchRepository {
...
...
override fun searchBooksPaging(query: String, sort: String): Flow<PagingData<Book>> {
val pagingSourceFactory = { BookSearchPagingSource(query, sort) }
return Pager(
config = PagingConfig(
pageSize = PAGING_SIZE,
enablePlaceholders = false,
maxSize = PAGING_SIZE * 3
),
pagingSourceFactory = pagingSourceFactory
).flow
}
}
- Room을 변환할 때와 동일하게 pageSize와 enablePlaceholder, maxSize를 정의해준다.
- PagingSource의 결과는 pagingSourceFactory를 통해 전달해주고 Flow를 반환하도록 한다.
다음으로 ViewModel에서 이 PagingData를 사용하기 위한 함수를 정의해보자.
ViewModel 정의
class BookSearchViewModel(private val bookSearchRepository: BookSearchRepository) : ViewModel() {
...
...
private val _searchPagingResult = MutableStateFlow<PagingData<Book>>(PagingData.empty())
val searchPagingResult: StateFlow<PagingData<Book>> = _searchPagingResult.asStateFlow()
fun searchBookPaging(query: String) {
viewModelScope.launch {
bookSearchRepository.searchBooksPaging(query, getSortMode())
.cachedIn(viewModelScope)
.collect {
_searchPagingResult.value = it
}
}
}
}
MutableStateFlow 타입의 PagingData에 Repository에서 받아온 페이징 데이터를 넘겨주어 갱신할 수 있도록 하고 UI에서는 변경 불가능한 StateFlow의 데이터를 Observe 할 수 있도록 한다.
그다음으로는 페이징 데이터를 UI에서 처리할 수 있게 해주는 RecyclerViewAdapter가 필요한데 이는 Room에서 사용했던 Adapter를 그대로 사용하면 된다.
View(Fragment) 정의
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
...
...
collectLatestStateFlow(bookSearchViewModel.searchPagingResult) {
bookSearchAdapter.submitData(it)
}
}
ViewModel에서 UI에서 관찰할 수 있도록 만들어준 StateFlow타입의 searchPagingResult를 확장 함수를 통해 Observe 해주고 Room때와 마찬가지로 submitData를 통해 데이터를 넣어준다.
📌 Paging에서의 예외처리
프로젝트를 진행하던 중 Paging3을 사용하며 한 가지 이슈 상황이 있었는데
- 프로젝트에서는 기본적으로 모든 API에서의 예외처리는 Coroutine Exception Handler(CEH)에서 하고 있었다.
- 하지만 페이징에서 발생한 예외는 CEH에서 잡히지 않는 이슈가 발생했다.
디버깅을 통해 원인을 분석해보니 API 요청 중 발생한 에러 즉 Exception이 CEH가 아닌 PagingSource 안의 LoadResult.Error에서 잡히고 있어 우리가 생각한 대로 예외처리(예외가 발생하면 해당 에러를 사용자에게 공지)가 되지 않던 문제였다. 물론 LoadResult.Error가 예외를 잡아줘 앱이 죽는 불상사가 발생하지는 않았지만 사용자에게 발생한 에러를 공지하기 위한 방법이 필요했다.
따라서 우리는 이를 해결하기 위해 addLoadStateListener 메서드를 사용했다.
private fun handlePagingError(wantHomeResultAdapter: WantHomeResultAdapter) {
wantHomeResultAdapter.addLoadStateListener { loadState ->
val errorState = when {
loadState.prepend is LoadState.Error -> loadState.prepend as LoadState.Error
loadState.append is LoadState.Error -> loadState.append as LoadState.Error
loadState.refresh is LoadState.Error -> loadState.refresh as LoadState.Error
else -> null
}
when (val throwable = errorState?.error) {
is HttpException -> {
if (throwable.code() == 401) {
Toast.makeText(this, "401 에러입니다.", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "Http에러가 발생했습니다.", Toast.LENGTH_SHORT).show()
}
}
is ConnectException -> {
Toast.makeText(this, "네트워크 연결이 불안정합니다.", Toast.LENGTH_SHORT).show()
}
}
}
}
위와 같이 발생할 수 있는 에러 상황들에 따른 Toast 메시지를 정의하고 이를 Adapter에 달아주어 에러 상황에 따라 사용자에게 예외상황을 공지할 수 있도록 처리했다.
'Android' 카테고리의 다른 글
안드로이드 [Kotlin] - ViewModel 파헤쳐보기 (0) | 2022.10.03 |
---|---|
안드로이드 [Kotlin] - Task와 Launch Mode 그림으로 이해하기 (2) | 2022.09.20 |
안드로이드 [Kotlin] - DataStore를 이용한 자동로그인 (0) | 2022.08.16 |
안드로이드 [Kotlin] - Room을 사용해보자 (1) | 2022.07.31 |
안드로이드 [Kotlin] - SharedPreferences 를 이용해 Retrofit2 Header에 JWT 담기 (0) | 2022.07.09 |