본문 바로가기

안드로이드 Android

안드로이드 개발 (27) paging 처리에 안드로이드 권장 architecture 적용하기

안녕하세요 Loner 입니다. 페이징 처리에 관한 이전 포스팅과 이어지는 내용입니다. 

이전 포스팅에서 했던 작업은 다음과 같습니다.

 

0. 정리

 주제

검색창에 검색어 입력시 검색 Api를 이용해서 네트워크에서 데이터를 20개씩 받아오면 화면에 렌더링하는 페이징 처리 

 

- 검색어를 변경했을 때 기존 데이터는 리셋하고 1페이지의 데이터 갯수 20개를 가져옴

- 스크롤을 데이터 끝까지 터치 했을 때 다음 페이지의 데이터 갯수 20개를 추가로 불러와서 현재 데이터와 합침 

- 검색창에서 타이핑할때 이전에 검색했던 기록들을 보여줌 

 

 

- 이전 포스팅 작업 순서-

1. paging 처리에 필요한 핵심 로직 구상

https://gift123.tistory.com/52

 

안드로이드 개발 (23) - 페이징 처리 로직

안녕하세요 Loner입니다. 오랜만에 글을 작성하게 됩니다. 최근에 많은 사건이 있었고, 수습하느라 정신이 없었네요 이포스팅은 최근에 겪은 문제를 참고해서 정리하는 글입니다. 페이징 처리를

gift123.tistory.com

2. Android에서 실제 paging 처리 구현 

https://gift123.tistory.com/53

 

안드로이드 개발 (24) - 페이징 처리 실제 구현

https://gift123.tistory.com/52 안드로이드 개발 (23) - 페이징 처리 1편 안녕하세요 Loner입니다. 오랜만에 글을 작성하게 됩니다. 최근에 많은 사건이 있었고, 수습하느라 정신이 없었네요 이포스팅은 최

gift123.tistory.com

3. Corutine Job사용으로 인한 불필요한 호출 제거, AutoCompleteTextView를 이용한 기존 검색 기록  

https://gift123.tistory.com/54

 

안드로이드 개발 (25) - Coroutine Job 사용과 AutoCompleteTextView를 사용한 검색 기록 보기

https://gift123.tistory.com/52?category=967702 안드로이드 개발 (23) - 페이징 처리 로직 안녕하세요 Loner입니다. 오랜만에 글을 작성하게 됩니다. 최근에 많은 사건이 있었고, 수습하느라 정신이 없었네요

gift123.tistory.com

 

오늘은 마지막 편으로 안드로이드 권장 아키텍쳐에 맞게 어떤 레이어를 잡으면 좋을지 정리하도록 하겠습니다.

 

1. Android 권장 아키텍쳐 심플하게 정리 

https://developer.android.com/jetpack/guide?hl=en 

 

앱 아키텍처 가이드  |  Android 개발자  |  Android Developers

이 가이드에는 고품질의 강력한 앱을 빌드하기 위한 권장사항 및 권장 아키텍처가 포함되어 있습니다. 이 페이지는 Android 프레임워크 기본을 잘 아는 사용자를 대상으로 합니다. Android 앱을 처

developer.android.com

- 액비티티, 프레그먼트에 대한 의존성을 최대한 없애버릴 것 

- 반드시 모델 기준으로 UI를 그릴 것 (스마트폰이 어떤 상황에 있어도 유저가 보던 UI를 유지하게끔 데이터 기반으로 보여줘야함)

 

 

 

 

- 위 사진 처럼 책임 분리를 나뉘어서 앱 전체의 흐름을 컨트롤 할 것

(Act,Frag -> ViewModel -> Repository ->  Local / Remote)

- 위 사진 처럼 저수준에서 고수준으로 한 방향으로만 의존 할것 (단방향 흐름) 

- Activity와 Fragment 는 최대한 멍청해야하고 ViewModel의 상태 변화 체크만 함 

- ViewModel은 모델과 커뮤니케이션하기 위한 데이터 처리에 관한 비즈니스 로직이나 UI 컴포넌트에 관한 데이터를 제공함 (하지만 뷰모델이 직접적으로 UI 컴포넌트를 알지 못함으로 UI컴포넌트가 변경되어도 뷰모델에게 아무런 영향이 없음)

- Repository는 ViewModel이 직접적으로 데이터 비즈니스로직 처리를 다해버린다면 관심사 분리에 어긋나기 때문에 Repository를 통해서 한번더 책임 분리를 함, Local,Remote 데이터 소스간의 중재자 역활을 해줌 

 

- 가능하면 Remote Data Source를 Local Data Source로 캐싱처리해서 사용하는것을 권장 (네트워크 반복 호출 방지 및 네트워크가 끊겨도 UI 변경이 심하지 않게끔)

 

- 그외 DI 라이브러리 사용 권장, 테스트환경 구축... 등등 권장

 

요약(최대한..)

 유저가 어떤 상황에서든 간에 보고 있던 UI 데이터의 흐름이 끊김이 없도록 UI는 모델 데이터로부터 렌더링을 해서 보여줘야 합니다. 각 모듈을 나뉘어서 책임을 분리하고 액티비티나 프레그먼트는 뷰모델의 상태변화만 체크해서 변화 감지시 ui의 상태를 변화시키도록 하도록 하는 것이 앱 아키텍처 가이드에서 말하는 권장 설계 패턴입니다. 

 

위 내용들로 한번 이전에 작업했던 페이징 처리에 어떤식으로 적용하면 좋을지 정리해보겠습니다.

 

2. 앱 권장 아키텍쳐 적용 

예제에서는 Room , jetpack ViewModel,LiveData, Retrofit2,Coroutine를 사용했습니다.

가능하면 추상도가 높은 고수준에서 저수준 순서로 앱을 만들어가는것이 좋습니다. 

(1) Remote Model

이 포스팅에서 페이징처리는 Daum 검색 Api를 활용해서 다음 서버에서 데이터를 가져와 사용합니다.

네트워크에서 데이터를 받을 준비를 해야합니다.  안드로이드에서 통신을 도와주는 라이브러리는 OkHttp 나 Retrofit2이 있습니다. 

 

첫번째로 서버에서 보내주는 DB구조에 맞게 클라에서 필요한 부분을 잘 맞춰줍시다. 

// 네트워크에서 받아오는 데이터 형식

@Parcelize
data class DocumentResult(
    val collection: String,
    val datetime: String,
    val display_sitename: String,
    val doc_url: String,
    val height: Int,
    val image_url: String,
    val thumbnail_url: String,
    val width: Int
):Parcelable

그 후 네트워크로 부터 데이터를 가져오는 함수를 만들어줍시다. 

// 레트로핏 사용
// 레트로핏 셋팅은 이미 해놨다는 가정

interface Api {
    @GET("v2/search/image")
    suspend fun getApiSearchList(
        @Query("query") query: String,
        @Query("page") page: Int = 1,
        @Query("size") size: Int = 20
    ): ImgSearchResult
}

이 예제에서는 네트워크에서 데이터만 가져와 로컬 데이터로 캐싱 처리할 예정입니다. 

 

(2) Local Model

안드로이드 권장 아키텍쳐에 따르면 바로 네트워크에서 받아온 데이터로 UI렌더링해서 사용하지 말고 domain Model로 캐싱처리를 해서 사용하라고 얘기를 합니다.

그렇게 하기 위해서 로컬 전용 DB를 만들어 줍시다. 이 예제에서는 Room을 사용했습니다. 

@Entity
@Parcelize
data class Document(

    @PrimaryKey(autoGenerate = true)
    val idx:Long = 0L,
    val thumbnail_url:String,
    val collection: String,
    val datetime: String?,
    val display_sitename: String?,
    val doc_url: String,
    val image_url: String,
):Parcelable
@Database(entities = [Document::class],version = 2)
abstract class DocumentDB : RoomDatabase() {
    abstract val documentDao: DocumentDao
    companion object {
        @Volatile
        private var INSTANCE: DocumentDB? = null
        fun getInstance(context: Context): DocumentDB = synchronized(this) {
            INSTANCE ?: Room.databaseBuilder(
                context,
                DocumentDB::class.java,
                "Document_Database"
            ).fallbackToDestructiveMigration()
                .build().also {
                    INSTANCE = it
                }
        }
    }
}

Room 셋팅을 한 후 데이터에 대한 CRUD 함수들도 만들어줍시다. 

@Dao
interface DocumentDao {
    @Query("SELECT * FROM Document")
    fun getDocumentList(): LiveData<List<Document>>


    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertDocumentList(documents: List<Document>)

    @Query("DELETE FROM Document")
    fun deleteDocumentListAll()
...
}

 

(3) Repository 

Local 저장소와 Remote 저장소를 다 가져옵시다.

interface DocumentLocalDataSource {
    fun getDocumentList(): LiveData<List<Document>>
    suspend fun insertDocumentList(documents: List<Document>)
    suspend fun deleteDocumentListAll()
}

interface DocumentRemoteDataSource {
    suspend fun getSearchResult(query: String, page: Int = 1): ImgSearchResult
}

class DocumentRepository(private val documentDao: DocumentDao) :
    DocumentLocalDataSource, DocumentRemoteDataSource {


    override fun getDocumentList(): LiveData<List<Document>> =
        documentDao.getDocumentList()

...

}

 

이제 안드로이드 권장 아키텍쳐에 따라 캐싱처리를 할겁니다.

아래처럼

네트워크에서 데이터를 받아와서 Local 모델로 변환해주고 로컬 저장소에 저장해주는 함수를 만듭니다.

    //캐싱 처리
    suspend fun convertToLocalDb(documentResults: List<DocumentResult>) {
        insertDocumentList(documentResults.asDomainModel())
    }

    //DocumentResult List를 Document List로 변환
    private fun List<DocumentResult>.asDomainModel() = this.map {
        Document(
            thumbnail_url = it.thumbnail_url,
            collection = it.collection,
            datetime = it.datetime,
            display_sitename = it.display_sitename,
            doc_url = it.doc_url,
            image_url = it.image_url,
        )
    }

이제 네트워크가 잠깐 끊겻을 때 UI 깨짐이 덜 하게끔 할수 있기도하고, 

네트워크에서 똑같은 데이터를 호출하려한다면 네트워크에서 호출하지 않고

로컬에 저장한 데이터를 꺼내 사용하는 등등.. 개발자 마음대로 캐싱의 장점을 상황에 맞게 이용할 수 있습니다.

 

(4) ViewModel 

repository를 매개변수로 가져옵니다.

그리고 ViewModel의 상태 변화를 View에게 알리기 위해 LiveData를 사용해야합니다.

class SearchViewModel(
    private val documentRepository: DocumentRepository
) : ViewModel {

    val documentList: LiveData<List<Document>>
        get() = documentRepository.getDocumentList()
...
}

위 LiveData에 Room(로컬디비)에서 DB의 모든 데이터를 리턴해주는 repository의 함수를 초기화 해줍시다.

Room DAO의 모든 데이터 리스트를 리턴하는 함수가

LiveData<T>로 리턴을 하기 때문에 로컬 DB 변화가 있을시 바로 알림을 줄수가 있습니다.

   @Query("SELECT * FROM Document")
    fun getDocumentList(): LiveData<List<Document>>

 

로컬 데이터의 변화 시 바로 LiveData의 값이 변경 될것이고 

LiveData<T>.observe로 변화 감지시 UI를 변경할 수 있게끔 합시다.

//액티비티 OR 프레그먼트 

viewModel.documentList.observe(requireActivity(), { documents ->
                updateAdapter.submitList(documents.toList())
        })

 

이렇게 ViewModel은 상태만 변화할뿐 View의 존재 사실을 모른채 열심히 자기일만 하게 됩니다.

 

(5) 네트워크로 부터 데이터 호출 

 

아래처럼 search함수로 Repository의 Api호출 함수를 사용한다음

네트워크에서 데이터를 받아서 로컬 DB 에 저장하는 로직을 만들면

    fun search(query: String, page: Int = 1, isNotNetWork: (() -> Unit) = {}) {
        viewModelScope.launch(Dispatchers.Default) {

            val repo = documentRepository
            val apiResult = runCatching { repo.getSearchResult(query, page) }

//apiResult는 네트워크에서 받아온 데이터를 가짐
            apiResult.onSuccess {
            //로컬 db에 저장 
                     repo.convertToLocalDb(it.documents)

            }

        ...
        }
    }

 

그러면 아까 ViewModel의 LiveData와 로컬DB를 연결했기 때문에

DB변화시 ViewModel의 LiveData에 알림이가고 LiveData의 알림을 받아서 UI를 변경 하게 됩니다. 

(모델로 부터 UI가 변경되는 작업)

 

검색어를 새로 입력하거나 스크롤 맨 아래 터치를 하는 부분에 위 함수를 사용하면 언제든지 LiveData의 상태변화 시킬 수 있습니다.

 

 

(6) Activity or Fragment

액티비티나 프레그먼트는  ViewModel의 LiveData를 관찰하다가 변화 감지시 obseve() 함수를 실행하면서 매개변수로 Local DB도 같이 넘어오게 됩니다. 받은 데이터로 UI로 렌더링해주면 됩니다. 이렇게 ViewModel의 상태만 관찰했다가 변화가 생긴다면 그 즉시 UI가 변경 합니다. 

그러니 ViewModel의 상태를 관찰하거나 사용자로부터 받은 이벤트를 뷰모델에 알리는 준비만 하면 됩니다.

 

관찰 대기

fun setUpObserver() {
        
        viewModel.documentList.observe(requireActivity(), { documents ->
            rvSearchList.adapter ?: setAdapter()
         

        })
   ...
    }

이벤트를 ViewModel에 전달

 private fun itemFetch( query: String) {
            viewModel.apiSearch(query)
            ...
            }

 

(번외) 데이터바인딩 사용을 사용한다면..

Activity나 Fragment에 LiveData<T>.obseve를 셋팅하지 않고

LiveData 변화 감지시 BindingAdapter에서 바로 값을 넘겨줄수도 있습니다.

(xml을 위주로 View로 취급해서 사용하는 구조라면 많이 쓰는 방법입니다.)

 

바인딩 어댑터

@BindingAdapter("app:setAutoTextList")
fun AutoCompleteTextView.setAutoTextList(textList: List<String>?) {
    this.setAdapter(
        ArrayAdapter(
            this.context,
            android.R.layout.simple_dropdown_item_1line,
            textList!!
        )
    )
}

뷰모델

  private var _autoTextList = MutableLiveData<MutableList<String>>()
    val autoTextList: LiveData<MutableList<String>>
        get() = _autoTextList

 

XML

<AutoCompleteTextView
   android:id="@+id/et_search"
    app:setAutoTextList="@{vm.autoTextList}"
     ...
     />

 

(7) 총 정리

-사용자가 무언가 클릭해서 데이터를 화면에 비춰줘야할 때-

 View ->  ViewModel ->  Model -> ViewModel(상태 변경!) 

 

viewModel의 상태가 변화하게 된다면 View는 UI를 변경합니다. 

 

이제 이론적으로 각각 책임 분리를 해서 코딩을 했습니다.

Activity가 신경 쓸 일은 Activity에게만 맡기고 ViewModel이 신경 쓸 일은 뷰모델 에게 맡겼습니다.

 

즉, 유지보수를 할때 데이터 처리 관련은 Model 레이어를 살펴보면 되고 UI관련 레이너는 View단을 보면 되고

각각 책임에 맞는 레이어를 찾아서 필요한 부분만 살펴볼 수 있게 되었습니다.

 

3. 주의할 점 

프로그래밍 역사가 늘 그래왔듯이 완벽한 개발론, 완벽한 설계는 없습니다.

설계는 이론적인 이야기이고 실전 프로그래밍은 다양한 변수가 존재합니다.

 

설계 이론은 프레임워크 환경에 따라서 소소한 부분에서 항상 이론을 따라가지 못하는 경우도 있습니다. 

 

프레임워크 환경, 프로젝트의 주제, 프로젝트의 규모, 프로젝트 일정, 사용하는 API 등등을 고려해서 최적의 설계를 만드는 것이 가장 중요하다고 생각합니다.

 

어디까지 기술 부채를 허용하고 항상 깔끔한 컨벤션으로 만들고

이론적으로 탄탄한 소프트웨어를 만들 수 있는지는 개발자들의 영원한 숙제입니다. 

 

이상 페이징 처리를 이용한 아키텍쳐까지 알아봤습니다.

페이징처리 편은 여기서 마무리하도록 하겠습니다.