Clean Architecture란?
고객들에게 제공하는 애플리케이션 같은 경우에는 수많은 기능들이 있기에 복잡도가 굉장히 높습니다. 복잡도가 높은 애플리케이션을 개발할 때 어떻게 하면 유지 보수하기 쉽고 고품질의 코드를 작성할 수 있을까요? 애플리케이션은 새로운 기능이 추가된다거나 내부 로직이 변경되어야 하는 일이 생겼을 때 유연하게 대처할 수 있도록 구조화해야 합니다.
프로젝트를 진행하며 테스트가 용이하고 유지 보수하기 쉽게 구조를 구성하고 싶어서 클린 아키텍처에 대해 공부하게 되었습니다. 클린 아키텍처의 개념은 2012년에 Robert C. Martin (Uncle Bob)님이 블로그에 기재하며 세상에 나오게 되었습니다. 클린 아키텍처의 목표는 계층을 분리하여 관심사를 분리하는 것입니다. 관심사를 분리하는 것이 무슨 의미가 있을까요?
예를 한번 들어보겠습니다. 안드로이드 로컬 DB를 기존에 Realm을 사용하고 있었습니다. Realm으로 수많은 기능을 구현하고 이미 제품까지 나와 있는 상황입니다. 그런데 갑자기 묻지도 따지지도 않고 로컬 DB를 Room으로 교체해야 한다고 합니다. 이미 프로젝트 복잡도가 높아져서 Realm에서 Room으로 바꾸기엔 쉽지 않을 것 같습니다. Realm 인스턴스를 전부 Room 인스턴스로 변경한 뒤, 그에 맞게 또 대처를 해줘야 합니다. 클린 아키텍처로 프로젝트 구조를 잡는다면 변화에 유연하게 코드를 작성할 수 있습니다. Realm에서 Room으로 교체해야 하는 예시는 아래에서 좀 더 설명하도록 하겠습니다.
클린 아키텍처는 총 4가지의 계층으로 이루어져 있습니다. 클린 아키텍처의 각 계층에 대해 설명은 다음과 같습니다.
1. Entities
엔티티는 비즈니스 규칙을 캡슐화합니다. 엔티티는 메서드를 갖는 객체일 수도 있지만 데이터 구조와 함수의 집합일 수도 있습니다. 가장 일반적이면서 고수준의 규칙을 캡슐화하게 됩니다. 외부가 변경되더라도 이러한 규칙이 변경될 가능성이 적습니다.
2. Use cases
유스케이스는 애플리케이션의 고유 규칙을 캡슐화하며 엔티티로부터의 데이터 흐름을 조합합니다. 유스케이스 계층의 변경이 엔티티에 영향을 줘서는 안 되며 데이터베이스, 공통 프레임워크 및 UI에 대한 변경으로부터 격리됩니다.
3. Interface Adapters (Presenters)
인터페이스 어댑터는 Entity 및 UseCase의 편리한 형식에서 데이터베이스 및 웹에 적용할 수 있는 형식으로 변환합니다. 이 계층에는 MVP 패턴의 Presenter, MVVM 패턴의 ViewModel가 포함됩니다. 즉, 순수한 비즈니스 로직만을 담당하는 역할을 합니다.
4. Frameworks & Drivers (Web, DB)
프레임워크와 드라이버는 상세한 정보들을 두게 됩니다. 웹 프레임워크, 데이터베이스, UI, HTTP client 등으로 구성된 가장 바깥쪽 계층입니다.
클린 아키텍처가 동작하기 위해서는 의존성 규칙을 잘 지켜줘야 합니다. 다시 말해 각각의 클래스는 한 가지 역할만 수행하고, 서로 의존 관계를 어떻게 할지 규칙이 정해져 있고 이를 지켜줘야 합니다.
의존성 규칙은 반드시 외부에서 내부로, 저수준 정책에서 고수준 정책으로 향해야 합니다. 위 그림에서는 내부로 갈수록 의존성이 낮아집니다. 예를 들면, 안드로이드에서 비즈니스 로직을 담당하는 ViewModel은 로컬 DB나 Web과 같은 세부적인 사항에 의존하지 않아야 합니다. 이를 통해 비즈니스 로직(고수준 정책)은 세부 사항(저수준 정책)의 변경에 영향받지 않도록 할 수 있습니다.
이렇게 관심사를 나누면 다음과 같은 이점을 얻을 수 있습니다.
- 새로운 기능을 빠르게 적용할 수 있습니다.
- 집중화된 클래스에 따른 프로젝트 유지 관리에 용이합니다.
- 패키지 구조 탐색이 쉬워집니다.
- 테스트 코드 작성에 용이합니다.
Clean Architecture in Android
클린 아키텍처를 안드로이드에 접목시킬 때는 일반적으로 Presentation, Domain, Data 총 3개의 계층으로 나눠지게 됩니다. Presentation -> Domain, Data -> Domain 방향으로 의존성을 갖고 있습니다. 각각의 계층에 대한 설명은 다음과 같습니다.
1. Presentation
화면과 입력에 대한 처리 등 UI와 관련된 부분을 담당합니다. Activity, Fragment, View, Presenter 및 ViewModel을 포함합니다. Presentation 계층은 Domain 계층에 대한 의존성을 가지고 있습니다.
2. Domain
애플리케이션의 비즈니스 로직에서 필요한 UseCase와 Model을 포함하고 있습니다. UseCase는 각 개별 기능 또는 비즈니스 논리 단위이며, Presentation, Data 계층에 대한 의존성을 가지지 않고 독립적으로 분리되어 있습니다. 안드로이드의 의존성을 갖지 않고 java 및 kotlin 코드로만 구성하며 다른 애플리케이션에서도 사용할 수 있습니다. Repository 인터페이스도 포함되어 있습니다.
3. Data
Domain 계층에 의존성을 가지고 있습니다. Domain 계층의 Repository 구현체를 포함하고 있으며, 데이터베이스, 서버와의 통신도 Data 계층에서 이루어집니다. 또한 mapper 클래스를 통해 Data 계층의 모델을 Domain 계층의 모델로 변환해주는 역할도 하게 됩니다.
클린 아키텍처 구조에서는 Realm에서 Room으로 데이터베이스를 변경할 때 수월하게 변경할 수 있습니다. Domain 계층에서 Repository 인터페이스를 작성하고 Data 계층에서 이를 구현합니다. 데이터베이스는 Data 계층에서만 존재하기 때문에 Realm에서 Room으로 데이터베이스를 변경한다고 하면 Data 계층의 Repository 구현체만 Room으로 변경해주면 됩니다.
Presentation 계층과 Domain 계층은 데이터베이스를 어떤 것을 사용하는지 전혀 알지 못합니다. 때문에 Data 계층의 데이터베이스 관련 로직만 변경해주면 보다 쉽게 데이터베이스를 변경할 수 있습니다.
Clean Architecture Sample
클린 아키텍처를 적용한 샘플 앱을 만들어보겠습니다. 간단하게 Github API를 사용해서 사용자 아이디를 입력하면 사용자의 Repository 목록을 불러오는 기능을 구현해보겠습니다. 프로젝트에는 의존성 주입을 위한 Hilt를 사용했습니다.
1. 프로젝트 구조
Presentation, Domain, Data 계층으로 나누기 위해 모듈을 여러 개로 나누었습니다. 멀티 모듈에 관한 내용은 이 글을 참고해주세요.
app 모듈은 Application 객체를 가지고 있으며 Presentation, Domain, Data 계층에 대한 의존성을 모두 갖습니다. Hilt를 통해 의존성 주입도 app 모듈에서 하게 됩니다. Presentation 계층에서 Application 객체를 가지고 있는 프로젝트도 봤었지만, 장선옥님의 발표를 듣고 'Application 모듈이 여러 개라면 따로 분리하는 것이 좋겠구나'라고 생각이 들어서 분리하게 되었습니다.
프로젝트 구성이 완료되면 의존성 규칙에 맞게 의존성을 입력해줍니다.
// build.gradle (app 모듈)
dependencies {
implementation project(":data")
implementation project(":domain")
implementation project(":presentation")
}
// build.gradle (presentation 모듈)
dependencies {
implementation project(":domain")
}
// build.gradle (data 모듈)
dependencies {
implementation project(":domain")
}
2. Domain 계층
GithubRepository
interface GithubRepository {
suspend fun getRepos(owner: String): List<GithubRepo>
}
Github의 Repository 목록을 가져오기 위한 Repository의 인터페이스를 만들어줍니다. GithubRepository의 구현체는 Data 계층에 위치하게 됩니다.
GetGithubReposUseCase
class GetGithubReposUseCase(private val githubRepository: GithubRepository) {
operator fun invoke(
owner: String,
scope: CoroutineScope,
onResult: (List<GithubRepo>) -> Unit = {}
) {
scope.launch(Dispatchers.Main) {
val deferred = async(Dispatchers.IO) {
githubRepository.getRepos(owner)
}
onResult(deferred.await())
}
}
}
Github에서 Repository 목록을 가져오는 기능을 제공하는 유스케이스입니다. GithubRepository를 생성자로 주입받아 데이터를 가져오는 역할을 하게 됩니다.
GithubRepo
interface GithubRepo {
val name: String
val url: String
}
Domain 계층의 Model입니다. Github Repository의 정보를 가지고 있으며 안드로이드의 의존성을 갖지 않도록 작성해줍니다. Data 클래스로 만들어도 되지만 Data 계층에서 mapper를 통해 변환해주면 그만큼 별도의 연산이 필요하기 때문에 Data 계층의 Model이 GithubRepo를 구현하는 방식으로 설계했습니다.
3. Data 계층
GithubRepos
data class GithubRepoRes(
@SerializedName("name")
private val _name: String,
@SerializedName("id")
private val _id: String,
@SerializedName("created_at")
private val _date: String,
@SerializedName("html_url")
private val _url: String
) : GithubRepo {
override val name: String
get() = _name
override val url: String
get() = _url
}
Github API를 통해 데이터를 가져올 Model입니다. Domain 계층의 GithubRepo를 구현해줍니다.
GithubService
interface GithubService {
@GET("users/{owner}/repos")
suspend fun getRepos(@Path("owner") owner: String) : List<GithubRepoRes>
}
GithubRemoteSource
interface GithubRemoteSource {
suspend fun getRepos(owner: String): List<GithubRepoRes>
}
class GithubRemoteSourceImpl @Inject constructor(
private val githubService: GithubService
) : GithubRemoteSource {
override suspend fun getRepos(owner: String): List<GithubRepoRes> {
return githubService.getRepos(owner)
}
}
GithubRemoteSource 또한 인터페이스로 구현한 뒤 구현체를 별도로 갖는 방식으로 구현했습니다. 만약 다른 방식으로 GithubRemoteSource를 구현하고 싶다면 또 다른 구현체를 만들어서 추가해주면 됩니다.
GithubRepositoryImpl
class GithubRepositoryImpl @Inject constructor(
private val githubRemoteSource: GithubRemoteSource
) : GithubRepository {
override suspend fun getRepos(owner: String): List<GithubRepo> {
return githubRemoteSource.getRepos(owner)
}
}
Domain 계층의 GithubRepository 인터페이스를 구현해줍니다. GithubRemoteSource를 생성자로 주입받아 데이터를 가져오게 됩니다. 만약 API를 가져온 데이터를 데이터베이스에 저장한 뒤 로드하는 기능을 넣고 싶다면 생성자로 GithubLocalSource를 주입받아 유사하게 구현해주면 됩니다.
GithubRemoteSource의 getRepos 함수는 List<GithubRepoRes>를 반환하지만 GithubRepoRes는 Domain 계층의 GithubRepo를 구현하고 있기 때문에 별도의 변환 과정 없이 반환이 가능합니다.
4. Presentation 계층
MainViewModel
@HiltViewModel
class MainViewModel @Inject constructor(
private val getGithubReposUseCase: GetGithubReposUseCase
): BaseViewModel() {
private val _githubRepositories = MutableLiveData<List<GithubRepo>>()
val githubRepositories: LiveData<List<GithubRepo>> = _githubRepositories
fun getGithubRepositories(owner: String) {
getGithubReposUseCase(owner, viewModelScope) {
_githubRepositories.value = it
}
}
}
ViewModel에서는 Domain 계층의 유스케이스를 주입받아 데이터를 가져오게 됩니다. Presentation 계층에서는 Data 계층의 의존성이 없기 때문에 Github API를 가져오는 구현체에 직접적으로 접근은 불가능합니다. 만약 유스케이스의 구현이 간단하다면 유스케이스는 생략하고 Repository 인터페이스를 가져와도 괜찮습니다.
MainActivity
@AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding, MainViewModel>(R.layout.activity_main) {
override val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.recyclerView.adapter = GithubAdapter()
binding.submitBtn.setOnClickListener {
val owner = binding.ownerEditText.text.toString()
viewModel.getGithubRepositories(owner)
}
subscribeToLiveData()
}
private fun subscribeToLiveData() {
viewModel.githubRepositories.observe(this) {
(binding.recyclerView.adapter as GithubAdapter).setItems(it)
}
}
}
ViewModel에서 가져온 데이터를 화면에 표시해주는 역할을 합니다.
5. App 모듈
위에서 언급했듯이 App 모듈에서는 의존성 주입을 위한 Hilt 설정이 필요합니다. Application 모듈이 여러 개라면 각각의 모듈에서 의존성을 넣어주는 게 좋은 방식이라고 생각합니다.
ApiModule
@Module
@InstallIn(SingletonComponent::class)
internal object ApiModule {
private const val CONNECT_TIMEOUT = 15L
private const val WRITE_TIMEOUT = 15L
private const val READ_TIMEOUT = 15L
private const val BASE_URL = "https://api.github.com/"
@Provides
@Singleton
fun provideCache(application: Application): Cache {
return Cache(application.cacheDir, 10L * 1024 * 1024)
}
@Provides
@Singleton
fun provideOkHttpClient(cache: Cache): OkHttpClient {
return OkHttpClient.Builder().apply {
cache(cache)
connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
retryOnConnectionFailure(true)
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}.build()
}
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(client)
.build()
}
@Provides
@Singleton
fun provideDeliveryService(retrofit: Retrofit): GithubService {
return retrofit.create(GithubService::class.java)
}
}
DataSourceModule
@Module
@InstallIn(SingletonComponent::class)
abstract class DataSourceModule {
@Singleton
@Binds
abstract fun bindsGithubRemoteSource(source: GithubRemoteSourceImpl): GithubRemoteSource
}
RepositoryModule
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Singleton
@Binds
abstract fun bindsGithubRepository(repository: GithubRepositoryImpl): GithubRepository
}
UseCaseModule
@Module
@InstallIn(ViewModelComponent::class)
object UseCaseModule {
@Provides
fun providesGetGithubReposUseCase(repository: GithubRepository): GetGithubReposUseCase {
return GetGithubReposUseCase(repository)
}
}
Domain 계층은 안드로이드에 대한 의존성이 없기 때문에 Hilt의 @Inject 어노테이션을 통한 의존성 주입이 불가능합니다. 따라서 app 모듈에서 별도의 의존성 주입을 위한 설정을 했습니다.
마치며
예전부터 이름만 들어봤던 클린 아키텍처에 대해 정리해보았습니다. 클린 아키텍처는 프로젝트가 비대해짐에 따라 큰 효과를 얻을 수 있는 구조라고 생각합니다. 추가적으로 하나의 모듈 안에서 패키지를 나눠서 분리하기보단 여러 개의 멀티 모듈을 사용하여 확실하게 관심사를 분리해준다면 개발자의 실수도 줄여줄 수 있을 것입니다. 아직까지 개념이 확실하게 잡히진 않아서 위의 설명에 실수가 있을 수 있습니다. 틀린 설명이 있다면 댓글로 지적해주시면 감사하겠습니다. 클린 아키텍처를 도입하시려는 분께 도움이 됐으면 좋겠습니다. 감사합니다. :D
예제 소스
https://github.com/tkdgusl94/blog-source/tree/master/clean-architecture-sample
참고
https://youngest-programming.tistory.com/484
'Android' 카테고리의 다른 글
[Android] Paging 3.0 Library 알아보기 - 2 (4) | 2021.07.12 |
---|---|
[Android] buildSrc를 통한 Dependency 관리 (0) | 2021.07.11 |
[Android] 오픈소스 라이선스 목록 보여주기 - OssLicensesMenuActivity (4) | 2021.07.03 |
[Android] Tree 구조를 RecyclerView로 만들어보자 - TreeAdapter (6) | 2021.07.03 |
[Android] Paging 3.0 Library 알아보기 - 1 (0) | 2021.06.27 |