Engineering
[Android] 요즘 핫한 Clean Architecture 왜 쓰는 거야?
2022년 10월 4일
원문에서 보기 ↗
요즘 오픈 채팅 대화방을 살펴보면 ‘클린 아키텍처(Clean Architecture)’ 패턴을 적용했다는 말을 자주 접할 수 있습니다. 이 글에서는 이 클린 아키텍처가 무엇인지, Android에서는 어떻게 적용되는지에 대해 코드 예제와 함께 설명합니다.
1. 클린 아키텍처(Clean Architecture)는 무엇일까?
클린 아키텍처는 개발자라면 한번쯤 들어 봤을 책 『클린 코드(Clean Code)』를 저술한 로버트 마틴(Robert C. Martin)이 제안한 시스템 아키텍처로, 기존의 계층형 아키텍처가 가지던 의존성에서 벗어나도록 하는 설계를 제공합니다.
클린 아키텍처를 키워드로 검색하면 가장 많이 나오는 유명한 청사진을 살펴보겠습니다. 
이 원은 시스템을 구성하는 영역을 네 부분으로 나누고 있습니다. 안쪽에서부터 차근차근 설명하겠습니다.
엔티티(Entities)
- 핵심 업무 규칙을 캡슐화한다.
- 메서드를 가지는 객체, 일련의 데이터 구조와 함수의 집합이다.
- 가장 변하지 않으며 외부로부터 영향을 받지 않는 영역이다.
유즈 케이스(Use Cases)
- 애플리케이션의 특화된 업무 규칙을 포함한다.
- 시스템의 모든 유즈 케이스를 캡슐화하고 구현한다.
- 엔티티로 들어오고 나가는 데이터 흐름을 조정하고 조작한다.
인터페이스 어댑터(Interface Adapter)
- 일련의 어댑터들로 구성한다.
- 외부 인터페이스에서 들어오는 데이터를 유즈 케이스와 엔티티에서 처리하기 편한 방식으로 변환하며, 유즈 케이스와 엔티티에서 나가는 데이터를 외부 인터페이스에서 처리하기 편한 방식으로 변환한다.
- 컨트롤러, 프레젠터, 게이트웨이 등이 여기에 속한다.
프레임워크와 드라이버(Frameworks & Drivers)
- 시스템의 핵심 업무와는 관련 없는 세부 사항이다.
- 프레임워크나, 데이터베이스, 웹 서버 등이 여기에 해당한다.
이때 클린 아키텍처는 경계를 가장 중요하게 생각합니다. 로버트 마틴은 경계에 대해 다음과 같이 설명합니다.
소프트웨어 아키텍처는 선을 긋는 기술이며, 나는 이러한 선을 경계(boundary)라고 부른다. 경계는 소프트웨어 요소를 서로 분리하고, 경계 한편에 있는 요소가 반대편에 있는 요소을 알지 못하도록 막는다. - Robert C. Martin, Clean Architecture
화살표의 방향은 의존성을 뜻합니다. 클린 아키텍처의 의존성은 밖에서 안으로 향하고, 바깥 원은 안쪽 원에 영향을 미치지 않습니다. 경계의 바깥으로 갈수록 덜 중요하고 세부적인 영역으로 표현되며, 안으로 갈수록 고수준(좀더 추상화된 개념)으로 표현됩니다.
좀더 풀어 설명하면 고수준이 ‘운동을 한다’라면, 저수준은 ‘집에서 팔벌려뛰기 운동을 한다’로 표현할 수 있습니다.
잘 이해가 되지 않는다면 하나만 기억하면 됩니다. 바깥 원은 안쪽 원에 영향을 미치지 않는다!
2. 클린 아키텍처는 왜 필요할까?
예를 들어 설명하겠습니다. 여러분이 A 배달 앱의 개발자이며, 어느 날 A 배달 앱이 B 배달 앱과 통합된다고 가정하겠습니다. 이때 여러분은 다음과 같은 요구를 받게 됩니다.
“A 배달 앱 시스템이 잘 되어 있으니 A 앱의 핵심 기능은 유지하고, UI와 DB 쪽만 바꿔 주세요.”
또는 다음과 같은 요구를 받을 수도 있습니다.
“A 배달 앱이 너무 잘되니 서비스를 웹으로 확장해 봅시다.”
비즈니스의 로직은 비슷한데, 변경해야 하는 부분도 많고, 아예 새로 만들 수도 없는 이러한 상황이라면, 여러분은 어떻게 하실 건가요?
만약 클린 아키텍처를 도입했다면, 단순하게 인터페이스 어댑터 영역과 프레임워크와 드라이브 영역만 수정하면 됩니다. 왜냐하면 ‘고객과 업체 사이에서 배달 서비스를 중계한다’는 비즈니스 로직은 변하지 않았기 때문입니다. 이와 같이 클린 아키텍처는 비즈니스 로직은 바꾸지 않으면서, 언제든 DB와 프레임워크에 구애 받지 않고 교체할 수 있는 아키텍처인 셈입니다.
클린 아키텍처는 단순한 추상화에 불과합니다. 그렇다면 Android에서는 어떻게 적용될까요? 아래에서는 Android에서의 클린 아키텍처 적용에 대해 설명하며, Android 개발자가 아니시라면 여기까지만 읽으시길 권장합니다.
3. 안드로이드에서는 어떻게 적용될까?
각 계층이 의미하는 바는 다음과 같습니다.
- 프리젠테이션 계층(Presentation Layer)
- 뷰(View): 직접적으로 플랫폼 의존적인 구현, 즉 UI 화면 표시와 사용자 입력을 담당합니다. 단순하게 프레젠터가 명령하는 일만 수행합니다.
- 프레젠터(Presenter): MVVM의 ViewModel과 같이, 사용자 입력이 왔을 때 어떤 반응을 해야 하는지에 대한 판단을 하는 영역입니다. 무엇을 그려야 할지도 알고 있는 영역입니다.
- 도메인 계층(Domain Layer)
- 유즈 케이스(Use Case): 비즈니스 로직이 들어 있는 영역입니다.
- 모델(Entity): 앱의 실질적인 데이터입니다.
- 데이터 계층(Data Layer)
- 리포지터리(Repository): 유즈 케이스가 필요로 하는 데이터의 저장 및 수정 등의 기능을 제공하는 영역으로, 데이터 소스를 인터페이스로 참조하여, 로컬 DB와 네트워크 통신을 자유롭게 할 수 있습니다.
- 데이터 소스(Data Source): 실제 데이터의 입출력이 여기서 실행됩니다.
데이터의 흐름은 다음과 같습니다.
사용자의 인터렉션이 발생하면 이벤트는 위에서 아래로, 아래서 위로 흐릅니다. 사용자가 버튼을 클릭하면 UI → 프레젠터 → 유즈 케이스 → 엔티티 → 리포지터리 → 데이터 소스로 이동하게 됩니다.
위 흐름을 보면 다음과 같은 의문이 생길 수 있습니다.
- 위 원에서 도메인 계층에 속해 있던 엔티티가 왜 데이터 계층에 있지?
- 트랜스레이터는 어디서 나왔지?
- 도메인 계층이 데이터 계층을 알고 있어야 데이터를 보낼 수 있는 게 아닌가?
매우 좋은 질문들입니다. 먼저, 데이터 계층의 엔티티는 위 원의 엔티티가 아닙니다. 원의 엔티티는 도메인 계층의 모델이며, 데이터 계층의 엔티티는 네트워크나 로컬 DB에서 받아온 DTO를 의미합니다. 따라서 계층을 횡단할 때 해당 계층에 맞게 변환해야 합니다. 도메인 계층에서 모델이 트랜스레이터를 거쳐, 데이터 계층의 엔티티로 변환되는 것입니다(이는 반대로도 가능합니다). 또한 실제로 도메인 계층은 데이터 계층을 참고하고 있지 않습니다. 그것은 바로 리포지터리에서 이루어지는 의존성 역전 법칙 때문입니다.
의존성 역전이란? 객체 지향 프로그래밍에서 의존 관계 역전 원칙은 소프트웨어 모듈들을 분리하는 특정 형식을 지칭한다. 이 원칙을 따르면, 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존 관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다.
단순하게 말하면, 인터페이스로 만들고, 도메인 계층에서 인터페이스를 참조하면 됩니다. 
4. 실제 코드로 한번 적용해 볼까?
예를 들기 위해 사이드 프로젝트인 Lead Pet에서 코드를 가져왔습니다. 
한 기능의 데이터 흐름을 코드로 보면서 설명하겠습니다. 예를 들어, 게시글 등록 기능을 구현한다고 생각해 봅시다.
프레젠테이션 계층
View
binding.include.tvRight.setOnClickListener {
//ViewModel로 버튼 클릭 감지 보내기
postViewModel.insertLifePost()
}
앞서 말했듯, View의 역할은 보여주기와 인터렉션 감지뿐입니다.
Presenter
@HiltViewModel
class LifePostViewModel @Inject constructor(
private val insertDailyPostBaseUseCase: @JvmSuppressWildcards InsertDailyPostBaseUseCase
) : ViewModel() {
private val _eventFlow = MutableEventFlow<Event>()
val eventFlow = _eventFlow.asEventFlow()
private val _postImageFlow = MutableStateFlow<List<Uri>>(emptyList())
val postImageFlow = _postImageFlow.asStateFlow()
fun setPostImage(uriList: List<Uri>) {
_postImageFlow.value = uriList
}
private fun event(event: Event) {
viewModelScope.launch {
_eventFlow.emit(event)
}
}
// 해당 함수로 들어온다.
fun insertLifePost(text: String, content: String) = viewModelScope.launch {
val repo = DailyPost(
userId = "",
title = text,
contents = content,
images = listOf(),
normalPostId = "",
likedCount = 0,
createdDate = listOf(),
commentCount = 0,
liked = false
)
insertDailyPostBaseUseCase(repo).collect { uiState ->
event(Event.UiEvent(uiState))
}
}
어떤 반응을 해야 하는지 판단하는 영역이고, 무엇을 그려야 하는지 알고 있는 영역이기 때문에, insertDailyPostBaseUseCase메서드를 호출했습니다.
도메인 계층
UseCase
class InsertDailyPostUseCase @Inject constructor(private val repo: DailyRepository) : InsertDailyPostBaseUseCase {
override suspend fun invoke(lifePost: DailyPost) = flow {
emit(UiState.Loding)
runCatching {
repo.insertDailyPost(lifePost)
}.onSuccess { result ->
emit(UiState.Success(result))
}.onFailure {
emit(UiState.Error(it))
}
}
}
비즈니스 로직이 들어 있는 영역입니다. 여기에서 중요한 것은 참조하고 있는 것이 도메인 계층 의 인터페이스로 이루어진 리포지터리 이며, 구현체는 데이터 계층에 속해 있다는 것입니다.
Repository
// 이녀석은 도메인 계층
interface DailyRepository {
suspend fun insertDailyPost(postEntity: DailyPost): DailyPost
}
// 이녀석은 데이터 계층
class DailyRepositoryImp @Inject constructor(private val dailyRemoteSource: DailyRemoteSource) : DailyRepository {
override suspend fun insertDailyPost(dailyPost: DailyPost): DailyPost =
dailyRemoteSource.insert(dailyPost.toMapper()).toDomain()
}
리포지터리이며, 네트워크와 통신하거나, 로컬 DB로 가져올지 선택할 수 있습니다.
데이터 계층
MAPPER
internal fun DailyFeedRequestResponse.toDomain() = DailyPost(
contents = contents,
images = images,
title = title,
userId = userId,
normalPostId = normalPostId,
likedCount = likedCount,
createdDate = createdDate,
liked = liked,
commentCount = commentCount
)
데이터 계층의 엔티티를 도메인 계층의 모델로 바꿔 주는 역할을 합니다(정반대로도 가능합니다).
RepositoryImp
override suspend fun insertDailyPost(dailyPost: DailyPost): DailyPost =
dailyRemoteSource.insert(dailyPost.toMapper()).toDomain()
도메인 계층 의 리포지터리 구현체 입니다.
5. 테스트 관점에서 바라볼까?
클린 아키텍처를 사용하는 것의 가장 큰 장점은 테스트가 용이하다 는 것입니다. 테스트 코드를 짜면서 가장 많이 듣는 얘기로는 ‘테스트 더블’이라는 개념이 있습니다.
테스트 더블(Test Double)이란? 『xUnit Test Patterns』의 저자인 제라드 메스자로스(Gerard Meszaros)가 만든 용어로 테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어주는 객체를 말한다.
쉽게 말하면, 테스트하려는 객체와 연관된 객체가 모호할 때, 대신 만드는 객체라고 할 수 있습니다.
이를 사용하려면 인터페이스로 정의하고, 구현체를 더미로 바꾸는 과정 이 필요한데요. 하지만 저희는 이미 인터페이스 를 만들었기 때문에, 인터페이스 분리 작업을 하지 않아도 됩니다. 안드로이드 Unit Test는 추후 다른 아티클을 통해 자세하게 다루도록 하겠습니다.
Fake
class FakeGetSpeciesListUseCase : GetSpeciesListBaseUseCase {
override suspend fun invoke(params: Unit): Flow<UiState<List<IndexBreed>>> = flow {
val speciesList = listOf<IndexBreed>(
IndexBreed("가", listOf("가브리안", "가비투엘", "가주망", "가지주")),
IndexBreed("나", listOf("나루투", "나리토", "나미엘", "나무베")),
IndexBreed("파", listOf("파파자", "파피주", "파피몬", "파라과이"))
)
emit(UiState.Success(speciesList))
}
}
//Viewmodel을 테스트할 때 ViewModel의 생성자 필요한 UseCase 인자를 Fake를 사용하여 대체하였음
@RunWith(MockitoJUnitRunner::class)
class SpeicesViewModelTest{
@get:Rule
val coroutineRule = MainCoroutinesRule()
@Test
fun `게시글 정상적으로 들어왔을 때_Event가 들어오는가?`() = runTest {
//given
val fakeGetSpeciesListUseCase = FakeGetSpeciesListUseCase()
//when
val adoptPostViewModel = AdoptPostViewModel(getSpeciesListUseCase = fakeGetSpeciesListUseCase)
//then
adoptPostViewModel.speciesStateFlow.test {
Truth.assertThat(awaitItem()).isInstanceOf(UiState.Success::class.java)
//취소하고 종료함
cancelAndIgnoreRemainingEvents()
}
}
}
Mock
@RunWith(MockitoJUnitRunner::class)
class InsertLifePostUseCaseTest {
private lateinit var repositoryImp: DailyRepository
private lateinit var insertLifePostUseCase: InsertDailyPostUseCase
@Test
fun `게시글 정상적으로 들어왔을 때_Result Success로 반환되는가?`() = runBlocking {
// given
val loginEntity = DailyPost(
contents = "",
images = listOf(),
normalPostId = "",
title = "",
userId = "",
likedCount = 0,
createdDate = listOf(),
commentCount = 0,
liked = false
)
repositoryImp = Mockito.mock(DailyRepository::class.java)
Mockito.`when`(repositoryImp.insertDailyPost(loginEntity)).thenReturn(loginEntity)
// when
insertLifePostUseCase = InsertDailyPostUseCase(repositoryImp)
// then
insertLifePostUseCase(loginEntity).test {
Truth.assertThat(awaitItem()).isInstanceOf(UiState.Loding::class.java)
Truth.assertThat(awaitItem()).isInstanceOf(UiState.Success::class.java)
}
}
@Test
@Throws(ServerFailException::class)
fun `게시글 정상적이지 않을 때_Result Error로 반환되는가?`() = runBlocking {
// given
val loginEntity = DailyPost(
contents = "",
images = listOf(),
normalPostId = "",
title = "",
userId = "",
likedCount = 0,
createdDate = listOf(),
commentCount = 0,
liked = false
)
repositoryImp = Mockito.mock(DailyRepository::class.java)
Mockito.`when`(repositoryImp.insertDailyPost(loginEntity))
.thenAnswer { ServerFailException("테스뚜") }
// when
insertLifePostUseCase = InsertDailyPostUseCase(repositoryImp)
// then
insertLifePostUseCase(loginEntity).test {
Truth.assertThat(awaitItem()).isInstanceOf(UiState.Loding::class.java)
Truth.assertThat(awaitItem()).isInstanceOf(UiState.Error::class.java)
awaitComplete()
}
}
}
6. 의존성 때문에 분리되지 않는 게 있다면 어떻게 하죠?
도메인 계층은 프레임워크에 종속되지 않게 짜야 합니다. 하지만 도메인 계층에 어쩔 수 없이 안드로이드 라이브러리를 implementaion해야 하는 경우가 있습니다. 그 중 하나가 Paging 3 라이브러리인데요.
package com.dev6.domain.repository
//domain Layer임에도 불구하고, AndroidX가 종속되어 있다.
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.dev6.domain.model.comment.Comment
abstract class CommentPagingSource() : PagingSource<Int, Comment>() {
abstract override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Comment>
abstract override fun getRefreshKey(state: PagingState<Int, Comment>): Int?
}
Google이 만든 Paging3를 사용하려면 Paging Source를 사용해야 하고, 그렇다면 도메인 계층에 안드로이드가 종속되어 버리는 딜레마에 처하게 됩니다.
여기서 해결법은 단순하게도 아래의 testImplementation을 implementaion 하는 것입니다.

// 안드로이드에 대한 종속성이 없어서 sync가 가능하다.
// alternatively - without Android dependencies for tests
testImplementation "androidx.paging:paging-common:$paging_version"
이를 통해, Android에 종속된 라이브러리도 도메인 계층에 사용할 수 있습니다.
7. 결론
이 글에서는 클린 아키텍처가 무엇이고, Android에서는 어떻게 사용해야 하는지 알아봤습니다. 소프트웨어 엔지니어링 관점에서는 장기적으로 유지·보수하기 좋기 때문에 소프트웨어의 수명 주기가 오래갈 것으로 생각된다면 도입할 만한 것 같습니다. 긴 글 읽어 주셔서 감사합니다.
참고
The Clean Architecture — Beginner’s Guide [안드로이드] Clean Architecture 를 도입하며 Clean Architecture는 모바일 개발을 어떻게 도와주는가? - (1) 경계선: 계층 나누기 클린 아키텍처 5부 - 아키텍처 MVVM 디자인 패턴에 따른 파일 디렉토리 구조 만들기 안드로이드와 클린 아키텍처