안녕하세요!
최근 바쁜 일이 좀 있어서 엄청 오랜만에 글을 올리는 것 같습니다,,😓
앞으로는 시간이 되는대로 틈틈이 안드로이드 개발에 도움이 되는 것들을 정리해서 올리도록 노력하겠습니다 🙃
오늘 알아볼 내용으로는 안드로이드에서의 '클린 아키텍처(Clean Architecture)'가 되겠습니다!
클린아키텍처란?
Clean Architecture
특정 수준 혹은 복잡도를 가진 애플리케이션을 위한 고품질 코드를 작성하기 위해서는 상당한 노력과 경험이 필요합니다. 애플리케이션은 고객의 요구사항을 충족 할 뿐만 아니라 유연하고 테스트 가능하며, 유지 관리가 가능해야합니다. 이런 문제에 대한 해결책을 Bob 삼촌으로도 잘 알려진 Robert C. Martin이 2012년에 제시한 개념입니다.
아래는 안드로이드에서의 클린 아키텍처를 설명 하고있는 이미지입니다.
이미지를 보시면 안드로이드의 클린 아키텍처는 총 3개의 계층로 구성되어있습니다.
클린 아키텍처의 핵심으로 의존성 규칙을 잘 지켜줘야 한다는 원칙이 있는데, 반드시 외부에서 내부 즉, 저수준에서 고수준 정책으로 향해야 합니다. 이를 토대로 안드로이드에서는 Presentation > Domain / Data > Domain 방향으로 각각 의존성을 갖고 있습니다.
Presentation
Presentation 계층에서는 UI와 관련된 부분을 담당하고 있습니다. 안드로이드의 Activity, Fragment, View등이 이에 해당하며, MVVM 패턴에서는 View와 ViewModel이 이 계층에 포함됩니다.
위에서도 말씀드렸다시피 Presentation > Domain 방향으로 의존성을 갖고 있기 때문에 네트워킹과 직접적인 연관이 있는 Data 계층에 Presentation 계층은 접근할 수 없습니다. 따라서 의존성관계가 있는 Domain 계층에 접근하여 원하는 데이터를 가져와 UI에 적용시키는 형태로 동작합니다.
Domain
Domain 계층은 애플리케이션의 비즈니스 로직에서 필요한 각 개별 기능 또는 비즈니스 논리를 뜻하는 UseCase와 Model을 포함하고 있습니다. Domain 계층은 의존성 규칙에 의해 Presentation과 Data 계층에 의존성을 가지지 않고 독립적으로 분리되어 있다는게 중요 포인트라고 할 수 있겠습니다. 이는 곧 안드로이드의 의존성을 갖지 않고 Java나 Kotlin 코드로만 구성이 가능하며 다른 애플리케이션에서도 사용할 수 있다는 것을 의미합니다.
Repository를 인터페이스화 한 경우에 Repository 인터페이스도 이 계층에 포함되어 있습니다.
Data
Data 계층은 DB, 서버와의 통신이 직접적으로 이뤄지는 계층입니다. 그렇기 때문에 Model을 포함하고 있으며,
Domain 계층에 의존성을 지니고 있기 때문에 Domain 계층의 Repository 구현체를 포함하고 있습니다.
Data 계층에서는 해당 계층의 Model을 Domain 계층의 Model로 변환해주는 역할도 지니고 있습니다. 보통 Model 클래스 내부적으로 함수를 구현하는 경우도 있고, 따로 Mapper 클래스를 구현하여 변환하기도 합니다.
지금까지 안드로이드의 클린 아키텍처에 대해 매우 간단한 개념만을 살펴보았습니다.
사실 저도 막 공부하면서 익히고 있는 단계이기 때문에..👶
완전하게 안드로이드 클린 아키텍처에 대해 이해하고 있다고 할 수 없습니다만, 계속해서 공부하고 개발하면서 저의 것으로 만들고자 노력하고 있습니다. 혹시라도 잘못된 점이나, 추가적으로 알면 유용한 정보가 있는 경우에 알려주시면 정말 감사하겠습니다! 😚
여기서 끝내기는 살짝 아쉬우니, 안드로이드 클린 아키텍처를 적용한 예시 코드를 살짝만 맛보고 마무리 하도록 하겠습니다!
Domain
1) Model
data class Champion(
val id: String,
val name: String
)
Data 계층에서 매핑된 Model이 되겠습니다. 이는 순수 data class이며 실제 필요한 데이터만을 가지고 있습니다.
2) Repository(Interface)
interface ChampionRepository {
fun getChampion(): Flow<UiState<List<Champion>>>
}
Data 계층에서 구현될 Repository 인터페이스 입니다. Domain 계층은 어떤 계층에도 의존성을 가지고 있지 않고 독립적이기 때문에 Ropository는 인터페이스와 같은 형태로 존재합니다.
3) UseCase
class GetChampionUseCase @Inject constructor(private val championRepository: ChampionRepository) {
fun execute(
scope: CoroutineScope,
started: SharingStarted,
initialValue: UiState<List<Champion>>
): StateFlow<UiState<List<Champion>>> =
championRepository.getChampion().stateIn(scope, started, initialValue)
}
마지막으로 Domain 계층의 핵심인 UseCase입니다. 보통 UseCase를 작성할 때는 클래스 당 1개의 비즈니스 로직을 처리하는 경우가 많기 때문에 네이밍을 잘 정해 놓아야 추후 유지보수 때 편할 것 같다는 생각이 듭니다..ㅎ
위의 코드를 보시면 저는 Repository에서 가져온 Flow를 StateFlow로 바꿔주고 있는데, 해당 처리를 ViewModel에서 진행하셔도 전혀 상관이 없습니다!
Data
1) Api(Service)
interface ChampionApi {
@GET("data/ko_KR/champion.json")
suspend fun getChampion(): Response<ChampionResponse<Champion>>
2) Model
@JsonClass(generateAdapter = true)
data class ChampionResponse<T>(
@field:Json(name = "data") val data: Map<String, T>
) {
fun getList(): List<T> = data.values.toList()
}
API를 통해 가져온 Model 입니다. 상황에 맞게 Domain 계층의 Model로 매핑해주시면 되겠습니다.
3) DataSource
interface ChampionDataSource {
suspend fun getChampion(): ChampionResponse<Champion>
}
class ChampionDataSourceImpl @Inject constructor(
private val championApi: ChampionApi
) : ChampionDataSource {
override suspend fun getChampion(): ChampionResponse<Champion> {
val response = championApi.getChampion()
if (response.isSuccessful) {
return response.body()
?: throw EmptyBodyException("[${response.code()}] : ${response.raw()}")
} else throw NetworkErrorException("[${response.code()}] : ${response.raw()}")
}
}
DataSource 역시 인터페이스화해서 구현하였습니다. 위 코드는 네트워킹과 관련된 DataSource이므로,
정확히는 RemoteDataSource가 되겠네요!
4) Repository Impl
class ChampionRepositoryImpl @Inject constructor(
private val championDataSource: ChampionDataSource
) : ChampionRepository {
override fun getChampion(): Flow<UiState<List<Champion>>> =
flow<UiState<List<Champion>>> {
val list = championDataSource.getChampion().getList()
emit(UiState.Success(list))
}.buffer().catch {
emit(UiState.Error(it))
}
}
Domain 계층에서 작성한 Repository의 구현체입니다. 보통은 생성자로 DataSource를 주입받아 데이터를 가져오게 됩니다.
Presentation
1) ViewModel
@HiltViewModel
class MainViewModel @Inject constructor(
getChampionUseCase: GetChampionUseCase
) : ViewModel() {
val uiState: StateFlow<UiState<List<Champion>>> = getChampionUseCase.execute(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000L),
initialValue = UiState.Loading
)
}
ViewModel에서는 Domain 계층의 UseCase를 주입받아 데이터를 가져옵니다. 위에서 말씀드렸다시피, Presentation 계층은 Data 계층과의 의존성이 없기 때문에, Domain의 UseCase를 통해서만 데이터를 가져올 수 있습니다.
2) Activity
@AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding>(R.layout.activity_main) {
private val viewModel: MainViewModel by viewModels()
...
}
최종적으로 ViewModel에서 가져온 데이터를 UI로 표시 해주게 됩니다.
지금까지 안드로이드의 클린 아키텍처에 대해 간단히 남아 정리해보았습니다.
저도 프로젝트마다 클린 아키텍처를 적용해보고 있지만, 늘 새롭고 어려운 점이 많은 것 같습니다..
요즘에는 멀티 모듈을 통해 모듈 자체도 클린 아키텍처에 맞게 분리해보고 있습니다만 이 역시 막 간단하지는 않네요😢
비록 부족한 글이지만 클린 아키텍처를 안드로이드에 처음 도입하시려는 분들께 미력하게라도 도움이 되셨으면 좋겠습니다!
감사합니다 :)
🔗 예제 소스
https://github.com/sjunh812/lol-champions-project
GitHub - sjunh812/lol-champions-project: LOL champions book project
LOL champions book project. Contribute to sjunh812/lol-champions-project development by creating an account on GitHub.
github.com
'Android' 카테고리의 다른 글
[안드로이드 / Kotlin] ViewPager2 setCurrentItem duration (0) | 2022.10.28 |
---|---|
[안드로이드 / Kotlin] Moshi (2) | 2022.09.30 |
[안드로이드 / Kotlin] 소스코드 수정 없이 debug/release 앱 분리하기 (0) | 2022.05.29 |
[안드로이드 / Kotlin] Status bar 투명하게 (with DrawerLayout) (0) | 2022.05.18 |
[안드로이드 / Kotlin] Dagger Hilt (0) | 2022.05.17 |