[Android]요즘 핫한 Clean Architecture? 왜 쓰는거야

2

요즘 오픈 채팅을 돌아다니다 보면, Clean Architecture 패턴을 적용했다는 말을 많이 듣습니다.
"엥 패턴은 MVC, MVP, MVVM,MVI 만 있는 거 아니었어? 새로운 아키텍처 패턴인가?" 라는 생각이 드실 수도 있어서 오늘 한번 시원하게 달려봅시다!

1. 클린 아키텍처는 무엇일까?


개발자라면 한번 들어본 이 책!, Uncle Bob이라 불리는 로버트 마틴이 저술한 책인데요.

Clean Architecture는 이분이 제안했던 시스템 아키텍처로, 기존의 계층형 아키텍처가 가지던 의존성에서 벗어나게 하는 설계를 제공합니다.

Clean Architecture를 치면 가장 많이 나오는 유명한 청사진을 확인해볼까요?

이원은 시스템을 구성하는 영역을 4가지로 나누고 있습니다.
내부에서 바깥으로 차근차근 설명해 보겠습니다.

엔티티

  • 핵심 업무 규칙을 캡슐화한다.
  • 메서드를 가지는 객체, 일련의 데이터 구조와 함수의 집합
  • 가장 변하지 않고 외부로부터 영향을 받지 않는 영역이다.

유즈케이스

  • 애플리케이션의 특화된 업무 규칙을 포함한다.
  • 시스템의 모든 유즈 케이스를 캡슐화하고 구현한다.
  • 엔티티로 들어오고 나가는 데이터 흐름을 조정하고 조작한다.

인터페이스 어댑터 (게이트웨이)

  • 일련의 어댑터들로 구성한다.
  • 어댑터는 데이터를 유즈케이스와 엔티티에 가장 편리한 형식 <-> 데이터베이스나 웹 같은 외부 에이전시에 가장 편리한 형식으로 변환한다.
  • 컨트롤러, 프레젠터, 게이트웨이 등이 이곳에 속한다.

프레임워크와 드라이버

  • 시스템의 핵심 업무와는 관련 없는 세부 사항이다.
  • 프레임워크나, 데이터베이스, 웹서버 등이 여기에 해당한다.

이때 Clean ArchitectureBoundary(경계)에 대해 가장 중요하게 생각하는데요.

소프트웨어 아키텍처는 선을 긋는 기술이며, 나는 이러한 선을 경계(boundary)라고 부른다.
경계는 소프트웨어 요소를 서로 분리하고, 경계 한편에 있는 요소가 반대편에 있는 요소을 알지 못하도록 막는다. - Robert C. Martin, Clean Architecture

화살표 방향은 의존성을 뜻하는데요.
Clean Architecture의 의존성은 바깥에서 안으로 향하고 있고, 바깥쪽 원에서는 안쪽을 영향을 주지 않습니다.
바깥으로 갈수록, 덜 중요한 , 세부적인 영역이라고 표현되며, 경계의 안으로 갈수록 고수준(좀 더 추상화된 개념)이라는 표현을 하기도 합니다.

좀 더 풀이하자면, 고수준이 운동을 한다. 라면, 저수준은 좀 더 세부적으로, 집에서 팔벌려뛰기 운동을 한다는 느낌이라고 생각합니다.

잘모르겠다면 하나만 기억합시다! outer circles은 inner circles에 영향을 미치지 않는다!

2. 클린 아키텍처는 왜 필요할까?

글로 설명하려니 너무 딱딱하시죠?? 풀어서 설명해 드릴게요!

A양은 배달의 민족 개발자라고 가정해봅시다.

어느날, 배달의 민족이 요기요라는 통합되었다는 소식이 들려옵니다.
A양에게 팀장님이 지시를 합니다.

"배달의 민족 시스템이 잘되어 있으니 배달의 민족 핵심 기능을 차용하고! Ui쪽이랑 데이터베이스쪽은 바꾸게!"

혹은 이런 지시가 들어왔습니다.

"배달의 민족이 너무 잘되어서 웹으로 확장하려고 하네! 하루 빨리 만들어 보게나!"

비즈니스 로직은 비슷한데.. 뭔가 많이 바뀌어야 하고.. 그냥 새로 만들어야하나?

이런상황에 어떻게 하실껀가요?

만약 Clean Architecture를 도입하셨다면 단순하게 프레임워크와 드라이브 영역컨트롤러와 프레젠터 영역만 수정했다면 될일입니다.

왜냐면 배달을 한다는 비즈니스 로직을 변하지 않았으니까요.

이제 좀 이해가 가셨나요?


비즈니스 로직은 바꾸지 않으면서, 언제든지 DB와 프레임 워크에 구애받지 않고 교체할 수 있는 아키텍처인 셈이죠! (물론 플랫폼을 바꿀 경우... 그냥 바꾼다고 끝나진 않습니다...)

Clean Architecture는 단순한 추상화에 불과합니다.

그렇다면 Android에 어떻게 적용될지 확인해보면서 알아볼까요!?

안드로이드 개발자분들이 아니시면 여기까지 보시는것을 추천 드립니다~!

3. 안드로이드에서는 어떻게 적용될까

첫번째는 각각 무엇을 의미하는지 아는것부터 입니다.

  • 프리젠테이션 계층 (Presentation Layer)

    • 뷰(View): 직접적으로 플랫폼 의존적인 구현을 의미하며, 즉 UI 화면 표시와 사용자 입력을 담당합니다.
      단순하게 프레젠터가 명령하는 일만 시킬 뿐입니다.
    • 프리젠터(Presenter): MVVM의 ViewModel과 같이, 사용자 입력이 왔을 때 어떤 반응을 해야하는 지에 대한 판단을 하는 영역입니다.
      무엇을 그려줘야할지도 알고있는 영역이죠.
  • 도메인 계층 (Domain Layer)

    • 유즈케이스(UseCase): 비즈니스 로직이 들어있는 영역입니다.
    • 모델(Entity): 앱의 실질적인 데이터입니다.
  • 데이터 계층 (Data Layer)

    • 리포지토리(Repository): 유즈케이스가 필요로 하는 데이터 저장/수정 등의 기능을 제공하는 영역으로, 데이터 소스를 인터페이스로 참조하여, 로컬DB와 Network 통신을 자유롭게 가능합니다.
    • 데이터 소스(Data Source): 실제 데이터의 입출력이 여기서 실행됩니다.

두번째로 보셔야할것은 데이터 흐름입니다.

사용자의 인터렉션이 일어나면 이벤트가 위에서 아래로 흐르고, 아래에서 위로 흐르는데요.

실제로 Clean Architecture의 데이터 흐름이 어떻게 흘러가는지 확인해보겠습니다.

사용자가 버튼을 클릭하면 UI -> Presenter -> UseCase -> Entity -> Repository -> DataSource 로 이동하게 됩니다.

엥!? 이상하다 왜 Entity는 DataLayer에 가있고, TransLater는 뭐고, Domain Layer가 DataLayer를 알고 있어야 데이터를 보낼수 있잖아? 아까전에 외부에서 내부로 흐른다며!!

매우 좋은 지적입니다!

  1. Data Layer의 Entity는 위의 원의 Entity가 아닙니다.
    원의 Entity는 Domain Layer의 Model이며 Data Layer의 Entity는 네트워크나 로컬 DB에서 받아온 DTO를 의미합니다.

  2. 위의 Entity가 네트워크나, 로컬 DB에서 가져온 DTO라고 했죠? 그렇다면 Layer 를 횡단할때, 해당 Layer에 맞게 변환이 필요합니다.
    Domain Layer에서 ModelTranslater를 거쳐, Data Layer의 Entity로 변환되는것이죠. (이는 역으로도 가능합니다.)

  3. 네 그렇습니다. 실제로 Domain LayerData Layer를 참조하고 있지 않아요.
    그것은 바로 Repository에서 이루어지는 의존성 역전 법칙 때문입니다.

의존성 역전이란?

객체 지향 프로그래밍에서 의존관계 역전 원칙은 소프트웨어 모듈들을 분리하는 특정 형식을 지칭한다. 이 원칙을 따르면, 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다.

단순하게 말하면, Interface로 만들고, Domain Layer에서 Interface를 참조하면 됩니다.

실제 코드로 한번 적용해볼까?

현재 사이드 프로젝트인 Lead Pet에서 코드를 가져왔습니다.

하나의 기능의 데이터 흐름을 코드로 보면서 설명하겠습니다.
예를 들어, 게시글 등록 기능을 구현한다고 생각해봅시다.

Presentation Layer

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메소드를 호출했습니다.

Domain Layer

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))
        }
    }
}

비즈니스 로직이 들어가 있는 영역입니다.
그리고 여기서 중요한건, 참조하고 있는건 Domain LayerInterface로 이루어진 Repository이며, 구현체는 Data Layer에 속해있다는 것입니다.

Repository

// 이녀석은 Domain Layer 
interface DailyRepository {
    suspend fun insertDailyPost(postEntity: DailyPost): DailyPost
}

// 이녀석은 Data Layer
class DailyRepositoryImp @Inject constructor(private val dailyRemoteSource: DailyRemoteSource) : DailyRepository {
    override suspend fun insertDailyPost(dailyPost: DailyPost): DailyPost =
        dailyRemoteSource.insert(dailyPost.toMapper()).toDomain()
}

Repository이며, Network랑 통신하거나, 로컬DB로 가져올지 선택할수 있습니다.

Data Layer

MAPPER

internal fun DailyFeedRequestResponse.toDomain() = DailyPost(
    contents = contents,
    images = images,
    title = title,
    userId = userId,
    normalPostId = normalPostId,
    likedCount = likedCount,
    createdDate = createdDate,
    liked = liked,
    commentCount = commentCount
)

Data Layer의 Entity를 Domain Layer의 Model로 바꿔주는 역할을 하게 됩니다.(정반대로도 가능합니다.)

RepositoryImp

override suspend fun insertDailyPost(dailyPost: DailyPost): DailyPost =
dailyRemoteSource.insert(dailyPost.toMapper()).toDomain()

Domain LayerRepository 구현체 입니다.

테스트 관점에서 바라볼까?

Clean Architecture를 사용함으로써, 가장 큰 장점은 테스트가 용이하다는점에 있습니다.
테스트 코드를 짜면서, 가장 많이 듣는 얘기로는 테스트 더블인데요.

테스트 더블(Test Double)이란?
xUnit Test Patterns의 저자인 제라드 메스자로스(Gerard Meszaros)가 만든 용어로 테스트를 진행하기 어려운 경우 이를 대신해 테스트를 진행할 수 있도록 만들어주는 객체를 말한다.

쉽게 말하면, 테스트하려는 객체와 연관된 객체가 모호할때, 대신 만드는 객체라고 할수 있습니다.

이를 사용하려면 인터페이스로 정의하고, 구현체를 더미로 바꾸는 과정이 필요한데요.

하지만 저희는 이미 Interface를 만들었기 때문에, 인터페이스 분리작업을 안하셔도 됩니다.

안드로이드 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()
        }
    }
}

앗 의존성때문에 분리가 안되는게 있는데 어떻게 하죠?

Domain Layer는 프레임워크에 종속되지 않게 짜야합니다.

하지만, Domain Layer 에 어쩔수 없이 안드로이드 라이브러리를 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를 사용해야하고, 그렇다면 Domain Layer에 안드로이드가 종속되어버리는 딜레마에 처하게 됩니다.

여기서 해결법은 단순하게도, 아래의 testImplementationimplementaion 하는것입니다.

// 안드로이드에 대한 종속성이 없어서 sync가 가능하다.
// alternatively - without Android dependencies for tests
testImplementation "androidx.paging:paging-common:$paging_version"

이를 통해, Android에 종속된 라이브러리도 Domain Layer에 사용할 수 있습니다.

결론

Clean Architecture는 무엇이고, Android에서는 어떻게 써야 하는지 알아봤습니다.
소프트 엔지니어링 관점에서는 장기적으로 유지보수하기 좋기 때문에, 소프트웨어의 수명주기가 오래갈 것 같다고 생각된다면 도입할만할 것 같습니다.
모두모두들 긴 글 읽어주셔서 감사합니다 <3


참고

The Clean Architecture — Beginner’s Guide
[안드로이드] Clean Architecture 를 도입하며
Clean Architecture는 모바일 개발을 어떻게 도와주는가? - (1) 경계선: 계층 나누기
클린 아키텍처 5부 - 아키텍처
MVVM 디자인 패턴에 따른 파일 디렉토리 구조 만들기
안드로이드와 클린 아키텍처

profile
쉽게 가르칠수 있도록 노력하자

0개의 댓글