[Background]
안드로이드 앱 개발은 화면이 몇 개 밖에 되지 않던 과거와 달리 점점 복잡해지면서 요구사항도 증가하고 있습니다. 이에 대응하기 위해서는 체계적인 아키텍처와 모듈화 된 접근 방식이 필수적입니다. 그래서 저는 이번 글을 통해 안드로이드 앱 개발을 혁신적으로 개선하는 방법으로 MVVM 아키텍처, Clean Architecture, 그리고 Multi-Module를 조합하는 방법에 대해 살펴보겠습니다.
코드는 어느정도 저번 글에 이어서 작성돼서 저번 글을 한번 빠르게 읽는 것을 추천드립니다.
이번 글은 각각 아키텍처가 뭔지 알아보는 시간은 아니라서 각각에 대해 간단하게만 소개하고 넘어가겠습니다
https://jcodingcraft.tistory.com/4
이 글에 사용된 버전들
- Java 17
- Gradle 8.0
- kotlin 1.8.20
- Navigation 2.6.0
- Hilt 2.46.1
- Retrofit 2.9.0
- gson 2.8.9
[MVVM]
MVVM 아키텍처는 Model, View, ViewModel의 약자로 UI와 로직을 분리하는 방법을 제공해 줍니다. 각각
- Model: Application에서 사용되는 모든 데이터와 데이터 처리를 담당한다. View를 알고 있으면 안 된다
- View: 사용자가 보고 조작할 수 있는 화면. application 로직을 가지고 있는 걸 최대한 피해야 한다
- ViewMode: Model와 View사이의 중간 역할을 하고. View를 위해서 데이터를 가공한 상태로 제공해 준다.
[Clean Architecture]
Clean Architecture는 시스템을 여러 레이어로 나누고 각 레이어 간의 의존성을 줄이고 더 유연하고 견고하게 만든다. 안드로이드의 경우에는 3개 레이어를 가지게 된다
- 프레젠테이션 계층:
- UI: 화면 표시 및 입력 담당
- Presenter: 뭐를 화면에 표시할지 그리고 입력에 대한 반응을 담당한다
- 도메인 계층:
- UseCase: 비즈니스 로직이 들어가 있다
- Entity: 앱의 데이터
- 데이터 계층:
- Repository: UseCase가 필요한 데이터의 저장 및 수정 등 담당하며, DB 및 API 통신을 자유롭게 한다.
- Data Source: 실제 데이터 입출력데이터 계층
[Multi-Module]
멀티 모듈은 모듈을 어러 개 나눠서 개발하는 방법이다. 각 모듈은 독립적으로 개발하고 테스트할 수 있게 한다.
모듈을 나누면 내용이 바뀐 모듈만 빌드하기 때문에 속도가 더 빠르고 테스트에 용이하다는 점이 있다.
그리고 모듈을 나누면 일방향 의존성, 역햘을 나누는 것을 강제할 수 있기 때문에 같이 도입하면 좋습니다.
[프로젝트 소개]
앞에서 소개한 Clean Architecture나 MVVM을 보면 어떻게 모듈을 나눠야 할지 감이 좀 올 수도 있다.
레이어로 나누어 있으니깐 각각 레이어를 모듈을 만들면 되지 않을까 생각할 수 있는데 거의 다 왔습니다
저는 프로젝트를 이렇게 나누었습니다.
- app
최상위 모듈로 Application, MainActivity, DI이 있는 모듈입니다, 모든 모듈에 대한 의존성을 가지고 있다 - data (data 레이어)
DB, API에 실직적으로 통신을 진행하는 모듈로 앱에 필요한 정보를 저장하거나 받아오는 역할을 한다.
DataStore, Entity, Repository의 구현을 가지고 있다. - domain (domain 레이어)
비즈니스 로직을 가지고 있다. 이 모듈은 Android에 의존하면 안 된다. 무조건 kotlin/java만 의존하는 모듈이야 합니다.
UseCase, Model 그리고 Repository의 인터페이스를 가지고 있다. - feature (Presentation 레이어)
Home 하고 Setting의 Fragment, viewModel이 담겨 있는 모듈이며 모듈 사이에 의존성은 없습니다
View 하고 ViewModel를 가지고 있다 - common-ui
feature에 있는 중복되는 정보 (string, theme, view)을 담기 위한 모듈입니다 - navigation
Navigation에 필요한 정보를 담고 있는 모듈입니다.
저는 이 모듈들을 이용해서 StackOverFlow에 있는 글들을 가져와 RecyclerView에 보여주는 프로젝트를 만들었습니다
각각 모듈에서 어떤 역할을 하고 있는지 코드로 간단하게 살펴보겠습니다
App 모듈
최상위 모듈로 모든 모듈을 알고 있다. 이 프로젝트는 액티비티가 한 개라 최상위에 MainActivity을 넣어놨다.
모든 모듈을 알고 있기 때문에 DI를 여기서 진행을 해줘야 합니다
데이터 모듈
실질적으로 데이터를 가져오기 저장하는 모듈로 지금 프로젝트에서는 Retrofit을 이용해 stackoverflow API를 호출해서 글들을 가져오고 있다.
interface StackOverFlowAPI {
@GET("/questions?key=" + Constants.STACKOVERFLOW_API_KEY + "&order=desc&sort=activity&site=stackoverflow")
suspend fun lastActiveQuestions(@Query("pagesize") pageSize: Int?): Response<QuestionListDTO>
}
서버에서 가져오기 때문에 DTO (Data Transfer Object)에 담았고 오류를 대비하여 Nullable로 처리하고 화면에 보여줄 있는 VO(Value Object)으로 변환하면서 Null 처리도 해준다.
data class QuestionListDTO(
@SerializedName("items")
val questions: List<QuestionDTO>?,
) {
fun toVO() = QuestionListVO(
questions = questions?.map { it.toVO() } ?: listOf()
)
}
서버는 항상 변조에 유의하여 클라이언트에서 올라오는 정보들을 검증해야 하는 만큼, 클라이언트에서도 서버 쪽 오류나 실수를 대비하여 Null로 내려줄 수 있다는 가능성을 항상 신경을 써줘야 한다. Crash가 터지는 것이 정말로 민감하는 만큼 NPE가 터질 것을 조심해서 앱에 사용하기 전 empty string이나 empty list로 변환하여 다른 모듈에 넘겨준다.
Data Source (Datastore)
Data Source는 데이터 작업을 위한 애플리케이션과 시스템 사이의 다리 역할을 하고 있으며 이름에 있는 것처럼 데이터를 불러오는 출처를 의미한다. 한 개의 DataSource는 반드시 한개의 출처만 가질 수 있다 예를 들어서 API 통신을 위한 RemoteDataSource, sharedPreference, Room을 위한 LocalDataSource 이렇게 나눠져야 한다.
interface QuestionDataSource {
suspend fun getQuestionList(pageSize: Int?): Response<QuestionListDTO>
}
class QuestionDataSourceImpl @Inject constructor(
private val api: StackOverFlowAPI,
) : QuestionDataSource {
override suspend fun getQuestionList(pageSize: Int?): Response<QuestionListDTO> =
api.lastActiveQuestions(pageSize)
}
현재 프로젝트에서는 크게 하는 일 없이 단지 Repository에 값을 넘겨주는 역할을 하고 있다.
원래라면 network status code에 따른 예외처리를 여기서 같이 할 수 있다.
Repository
Repository의 역할은 구글 공식 문서에 따르면 이와 같습니다.
- 앱의 나머지 부분에 데이터 노출
- 데이터 변경사항을 한곳에 집중
- 여러 데이터 소스 간의 충돌 해결
- 앱의 나머지 부분에서 데이터 소스 추상화
- 비즈니스 로직 포함
정리하자면 Repository는 DataSource와 ViewModel 사이의 중간다리 역할을 하고 필요한 Datasource와 business Logic을 가져와 Repository에서 사용한다 이렇게 하면 데이터를 불러오는 쪽에서는 어떤 DataSource와 Business Logic을 사용하는 모르게 추상화가 가능하다.
class QuestionRepositoryImpl @Inject constructor(
private val dataSource: QuestionDataSource,
) : QuestionRepository {
override fun getQuestionList(pageSize: Int?): Flow<Result<QuestionListVO>> = flow {
val response = dataSource.getQuestionList(pageSize)
if (response.isSuccessful) {
emit(Result.success(response.body()!!.toVO()))
} else {
emit(Result.failure(Exception("failed")))
}
}
}
Response가 성공적이면 Result.success에 그리고 VO로 변환을 Repository에서 처리를 해준다.
실패하면 Result.failure 하고 exception을 넘겨준다.
도메인 모듈
Domain 모듈은 비즈니스 로직의 캡슐화를 담당하고 있습니다.
이 모듈 같은 경우에는 순수 Kotlin/java로 이루어지게 되는데 가장 큰 이유는 플랫폼에 의존하지 않고 재사용을 하기 위해서입니다.
비즈니스 로직만 담고 있기 때문에 안드로이드가 아닌 프로젝트에서도 활용할 수 있고.
지금 Kotlin이 IOS까지 만들 수 있는 멀티 플랫폼을 추진 중인데 이 모듈을 IOS로 그대로 옮겨도 사용할 수 있게 할 수 있습니다.
구글 공식 문서에 의하면 이러한 장점들을 가질 수 있습니다.
- 코드 중복을 방지합니다.
- 도메인 레이어 클래스를 사용하는 클래스의 가독성을 개선합니다.
- 앱의 테스트 가능성을 높입니다.
- 책임을 분할하여 대형 클래스를 피할 수 있습니다.
이 모듈은 UI에 사용할 Model 그리고 비즈니스 로직을 담고 있는 usecase, 그리고 Data 모듈에 의존하지 않고 Repository를 가져오기 위해서 인터페이스를 가지고 있다.
Usecase
Domain Module에서는 다양한 비즈니스 로직을 가지고 있는데, 이를 분할하고 처리하는 역할을 Usecase가 담당하고 있습니다.
한 개의 UseCase당 한개의 작업을 하고, 이름을 지을 때는 하는 일 (동사) + 대상 + UseCase 이 규칙을 따라야 합니다.
class GetQuestionListUseCase @Inject constructor(
private val repository: QuestionRepository,
) {
fun invoke(pageSize: Int?) = repository.getQuestionList(pageSize)
}
원래라면 여러 repository에서 데이터를 가져와 UI에서 바로 쓸 수 있게 결합 및 처리도 할 수 있지만. 현재 프로젝트에서는 한 개의 repository만 있어서 데이터를 넘겨주는 역할만 하고 있습니다.
프레젠테이션 모듈
이 모듈은 화면에 애플리케이션 데이터를 표시하고 사용자 상호작용을 담당하고 있다.
기본적으로 단방향 데이터 흐름으로 상태를 관리해야 된다.
UI가 viewModel에 사용자 이벤트를 알리면 viewModel이 Domain모듈에 정보를 요청한다.
그러면 Domain 모듈로부터 UI에 바로 쓸 수 있는 정보를 받게 되는데 이걸 렌더링 정보를 담고 있는 UIState을 가지고 있는다.
UI 측에서 UIState이 바뀐 걸 관측하면 가져와서 화면을 바꾼다
ViewModel은 UI Element을 알면 안 됩니다!
Flow 아니면 LiveData을 사용해 UI Element가 viewModel을 관측할 수 있게 만들어한다.
sealed class UiState {
object Loading : UiState()
data class Success<R : Any>(val data: R) : UiState()
data class Error(val message: String) : UiState()
}
화면별로 UIState을 따로 정의해 줄 수 있지만, 기본적으로 겹치는 부분이 많아서 제너릭으로 재사용하기 쉽게 만들어놨다.
@HiltViewModel
class HomeViewModel @Inject constructor(
private val listUseCase: GetQuestionListUseCase,
private val contentUseCase: GetQuestionContentUseCase,
) : ViewModel() {
private val _result = MutableStateFlow<UiState>(UiState.Loading)
val result: StateFlow<UiState> = _result.asStateFlow()
fun getQuestionList(pageSize: Int?) {
viewModelScope.launch {
listUseCase.invoke(pageSize)
.catch {
_result.emit(UiState.Error(it.message ?: "Error"))
}
.collect { questions ->
questions.fold(
onSuccess = {
_result.emit(UiState.Success(it))
},
onFailure = {
_result.emit(UiState.Error(it.message ?: "error"))
}
)
}
}
}
}
@AndroidEntryPoint
class HomeFragment : Fragment(), SimpleListAdapter.ClickListener {
private val viewModel: HomeViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.getQuestionList(20)
// viewModel UIState을 관측한다
viewModel.result.collect {
render(it)
}
}
}
}
private fun render(uiState: UiState) {
when (uiState) {
// 로딩중 일때는 progressBar을 보여준다
UiState.Loading -> {
binding.pbHome.visibility = View.VISIBLE
binding.rvHome.visibility = View.GONE
}
// 성공적으로 데이터를 가져오면 progressBar을 지우고 데이터를 Recyclerview에 넣는다
is UiState.Success<*> -> {
binding.rvHome.visibility = View.VISIBLE
val response = uiState.data as QuestionListVO
adapter.questionList = response.questions
adapter.notifyDataSetChanged()
binding.pbHome.visibility = View.GONE
}
// 실패시 toast 메시지를 보여준다
is UiState.Error -> {
binding.pbHome.visibility = View.GONE
Toast.makeText(context, uiState.message, Toast.LENGTH_SHORT).show()
}
}
}
}
화면이 만들어지면 viewModel에 getQuestionList을 호출하고 result flow에 변화를 기다린다.
바뀌면 render 함수에 uiState을 넣어서 처리를 해준다.
Common-ui
프레젠테이션에 있는 모듈들을 기본적으로 서로 모르기 때문에 재사용성을 위해 중복되는 정보들을 가지고 있는 모듈입니다.
Navigation
전에 있던 글에 쓴 대로 Navigation에 필요한 정보들을 담고 있는 모듈입니다.
이 모듈은 없어도 문제가 전혀 안됩니다.
[마무리]
이렇게 MVVM + Clean Architecture + Mutli-Module 프로젝트를 어떻게 만들 수 있지를 한번 다루어봤습니다.
다른 분들은 App Module에 data, domain, presentation을 넣고 domain만 Kotlin/Java을 만들기 위해 모듈 두 개만 쓰는 경우도 있는데.
제가 느끼기에는 이렇게 나누는 게 Clean Architecture가 제시하는 방향을 강제로 지킬 수 있고 의존성을 해결하기에는 가장 좋은 것 같았습니다.
전체 코드는 여기서 확인 가능합니다.
https://github.com/flash159483/multi-module-navigation
참고 자료
https://developer.android.com/topic/architecture/data-layer
'android > jetpack' 카테고리의 다른 글
(android/kotlin) Firebase RemoteConfig + Hilt로 원격으로 값 변경하기 (2) | 2023.11.03 |
---|---|
(android/kotlin) 멀티모듈 프로젝트에서 Bottom Navigation 메뉴를 각각의 모듈로 나누는 방법 (0) | 2023.09.03 |