정상에서 IT를 외치다

[Android, Architecture] 안드로이드 아키텍처 - Model편 본문

안드로이드

[Android, Architecture] 안드로이드 아키텍처 - Model편

Black-Jin 2019. 8. 2. 16:49
반응형

안녕하세요. 블랙진입니다.


안드로이드 아키텍처에 관한 순차적인 포스팅을 진행하고자 합니다. 이전에 깃허브 API를 사용한 아키텍처를 포스팅 했었지만 클린아키텍처를 공부하면서 Naver API를 사용한 신규 프로젝트를 만들어 보았습니다. 기존 프로젝트가 어떻게 변화되어 가는지 과정을 함께 보면서 아키텍처를 적용하면 어떤 점이 좋아지는 지를 알아보겠습니다.



CleanArchitecture


출처 : Clean Coder Blog


유사한 관심들을 레이어로 나눠 위 그림같이 배치한게 Uncle Bob이 언급한 클린아키텍처입니다. 물론 위 글은 2012년도 글이며 현대 트렌드에 맞게 변화가 있었지만 그 생각의 본질은 동일합니다. 바로 각 레이어는 안으로만 의존성을 향하고 있다. 즉 단방향 디펜던시를 가지는 구조로 상위 레이어로만 종속성이 있어야 합니다. 또한 각각의 관심사에 특화된 독립적인 테스트를 할 수 있어 코드의 테스트 가능성이 증대되고 클래스가 집중화 되어 유지보수가 쉬워집니다.


이러한 레이어 아키텍처에 관해 궁금하신 분은 링크 클릭



의존성 규칙


출처 : Clean Architecture Guide


Model을 설계할 때 위 사진으로부터 영감을 많이 받았습니다. 클린아키텍처 레이어로 밖에서 안으로만 의존성을 가지고 있어야 합니다. 예를 들어 Entity는 원 밖에 그 무엇도 알 수 없지만 Presenter와 Repository는 Entity가 무엇인지 알 수 있어야 합니다.




Model = Domain + Data


아키텍처란 단어를 들으면 많이 떠오르는게 MVC, MVP, MVVM 일 것입니다. 여기서 공통으로 사용되는 Model에 대해 알아보겠습니다. 필자가 설명하러는 클린아키텍처에서의 모델은 Domain과 Data 레이어 부분을 의미합니다.


이제부터 각각의 레이어에 대해 언급하며 용어에 대해 정의해 보겠습니다.



PresenterLayer

화면 조작 또는 사용자의 입력을 처리하기 위한 관심사를 모아 놓은 레이어 입니다. MVP에서는 P(Presenter) MVVM에서는 VM(ViewModel)이 이에 해당합니다. 여기서는 1개 이상의 Usecase를 사용하여 데이터에 따라 화면을 조작해줍니다. 그렇기 때문에 Usecase가 있는 DomainLayer에 의존할 수 밖에 없습니다.



DomainLayer

비즈니스와 관련된 로직을 처리하는 레이어 입니다. 여기서는 Usecase를 가지고 있습니다. 


- Usecase란? 1개 이상의 Repository를 받아 비즈니스 로직을 처리하는 부분입니다. Usecase는 하나의 유저 행동에 대한 비즈니스 로직을 가지고 있는 객체라고 생각하시면 됩니다. (1개의 유저 행동에 따른 1개의 로직만을 반환합니다.)



DataLayer

도메인에 필요한 모든 데이터를 조작하기 위한 레이어 입니다. 여기서는 Repository와 DataSource를 가지고 있습니다.(이 부분을 이해하기 위해서는 리포지터리 패턴에 대한 사전 지식이 필요합니다)


- Repository? 도메인과 데이터 레이어들 사이를 중재해주는 객체 입니다. 


- DataSource? 데이터가 어디서 오는지를 관장해 줍니다. 데이터는 Local(내부 DB, 캐시..)과 Remote(서버, 소켓..)에서 가져옵니다.




도식화 화면 위에 그림과 같습니다. 여기서 중요 포인트는 DomainLayer은 어플리케이션의 비즈니스 로직을 담고 있는 부분이기 때문에 그 무엇과도 의존성을 가지고 있으면 안됩니다. 여기서 비즈니스 로직을 기획이라고 이해하시면됩니다. 즉 기획이 변하지 않으면 DomainLayer 또한 변하면 안됩니다. 그렇게 Domain과 의존성을 두지 않게 함으로서 View 혹은 Data가 변화되어도 Domain은 아무 영향을 받지 않습니다.




예제


네이버 Search API 를 사용해 한 화면에 Movie와 Book 정보를 가져와 보여주는 예제입니다. API는 여기서 확인할 수 있습니다.






코드 설명



DataLayer


데이터 레이어는 DataSource와 Repository를 가지고 있습니다. 위 예제는 서버로 부터 Book과 Movie 정보를 가져오기 때문에 2개의 Repository가 필요하죠. 


DataSource


local

- 내부 DB 및 캐시 등을 구현하는 부분으로 준비 중...


remote

- NaverAPI를 사용해 서버로부터 데이터를 가져옵니다.


data class BookModel(
@SerializedName("lastBuildDate") val lastBuildDate: String,
@SerializedName("total") val total: Int,
@SerializedName("start") val start: Int,
@SerializedName("display") val display: Int,
@SerializedName("items") val items: List<BookItemModel>
)

fun BookModel.mapToDomain() =
BookEntity(lastBuildDate, total, start, display, items.mapToDomain())

fun List<BookItemModel>.mapToDomain(): List<BookItemEntity> = map { BookItemEntity(...) }


data class MovieModel(
@field:SerializedName("lastBuildDate") val lastBuildDate: String,
@field:SerializedName("total") val total: Int,
@field:SerializedName("start") val start: Int,
@field:SerializedName("display") val display: Int,
@field:SerializedName("items") val items: List<MovieItemModel>
)

fun MovieModel.mapToDomain() =
MovieEntity(lastBuildDate, total, start, display, items.mapToDomain())

fun List<MovieItemModel>.mapToDomain(): List<MovieItemEntity> = map { MovieItemEntity(...) }

Remote의 Model 부분으로 의존성은 원 안으로 향하기 때문에 DomainLayer의 Entity로 맵핑 주는 함수 mapToDomain을 사용하여 의존성을 없애줍니다.


object RemoteClient {

private const val baseUrl = "https://openapi.naver.com/v1/"

val naverService: NaverService

init {
naverService =
makeNaverMovieService(BuildConfig.DEBUG)
}

...
}

실재 서버와 통신하는 RemoteClient를 만들어 줍니다.


class NaverRemoteDataSourceImpl(
private val api: NaverService
) : NaverRemoteDataSource {

override fun getMovie(query: String): Single<MovieEntity> =
api.getMovie(query).map { it.mapToDomain() }

override fun getBook(query: String): Single<BookEntity> =
api.getBook(query).map { it.mapToDomain() }
}

서버와 통신하는 RemoteDataSource 구현체를 생성해 줍니다. 이때 우리는 DataLayer에서 DomainLayer로만 의존성을 향하기 하기 위해 데이터를 mapToDomain 함수를 사용해 맵핑해 줍니다. 


* DataLayer에서 DomainLayer로 의존성을 가진다는 것은 DomainLayer는 상위 레이어인 DataLayer에 종속되어 있다와 같은 의미입니다. 의존성이 있다라는 것은 코드로 잠깐 살펴보겠습니다.


class Person {

val money = Money()
}

class Money


   


위 그림과 코드가 Person은 Money에 의존하고 있다가 됩니다. Domain과 Data 레이어는 모델간의 의존성을 맵핑 함수를 사용해 없애주었는데 참고로 interface를 사용해 없애줄 수도 있습니다.


interface EntityMapper<in M, out E> {

fun mapFromRemote(model: M): E

}

이런식로 인터페이스를 감싸 줘서 해결해줘도 됩니다. 이렇게 맵핑 함수 혹은 인터페이스를 사용해 api의 model이 변경되어도 domain은 영향을 받지 않습니다. 다른 직관적인 예로 Prensent와 Domain을 보겠습니다. View에서는 아래와 같은 코드가 있습니다. View에서 Usecase를 생성하고 있죠? 이말은 'View는 Usecase에 의존하고있다. -> 레이어 관계로 보면 Prensent는 Domain에 의존하고 있다'입니다.


GetContentsUsecase(
BookRepositoryImpl(
NaverRemoteDataSourceImpl(RemoteClient.naverService)
),
MovieRepositoryImpl(
NaverRemoteDataSourceImpl(RemoteClient.naverService)
),
AppSchedulerProvider
)



Repository


class BookRepositoryImpl(
private val remoteDataSource: NaverRemoteDataSource
) : BookRepository {

override fun get(query: String): Single<BookEntity> {
return remoteDataSource.getBook(query)

}
}


class MovieRepositoryImpl(
private val remoteDataSource: NaverRemoteDataSource
) : MovieRepository {

override fun get(query: String): Single<MovieEntity> {
return remoteDataSource.getMovie(query)

}
}

Book과 Movie 두 개의 리포지터리 구현체 입니다. 리포지터리에서는 localDataSource과 remoteDataSource에서 어디서 데이터를 가져올 지 정하는 부분으로 현재 예제에서는 remoteDataSource만 사용했습니다.




DomainLayer


리포지터리를 받는 유즈케이스가 있는 레이어 입니다. 비즈니스 로직을 담당합니다. 다시 언급하지만 도메인 레이어는 클린아키텍처에서 가장 안쪽에 있기 때문에 바깥 원과 독립적이어야 합니다.


Model


data class BookEntity (
val lastBuildDate: String,
val total: Int,
val start: Int,
val display: Int,
val items: List<BookItemEntity>
)


data class MovieEntity (
val lastBuildDate: String,
val total: Int,
val start: Int,
val display: Int,
val items: List<MovieItemEntity>
)

도메인 레이어의 모델로 이는 클린아키텍처에서 Entity를 의미합니다. DataLayer에서 맵핑한 데이터를 DomainLayer로  전달하기 때문에 DataLayer와 의존성이 없습니다. 다시 해석하면 DataLayer의 모델을 바꿔도 맵핑해주는 로직만 수정해주면 되기 때문에 DomainLayer의 Model은 DataLayer가 변한다 해도 수정해줄 필요가 없습니다.



Usecase


위에서도 언급했지만 1개 이상의 Repository를 받아 비즈니스 로직을 처리하는 부분입니다. 


class GetContentsUsecase(
private val bookRepository: BookRepository,
private val movieRepository: MovieRepository,
schedulersProvider: SchedulersProvider
) : SingleUseCase<Pair<List<BookItemEntity>, List<MovieItemEntity>>, String>(
schedulersProvider
) {
override fun buildUseCaseSingle(params: String): Single<Pair<List<BookItemEntity>, List<MovieItemEntity>>> {
return Single.zip(
bookRepository.get(params),
movieRepository.get(params),
BiFunction<BookEntity, MovieEntity, Pair<List<BookItemEntity>, List<MovieItemEntity>>> { book, movie ->
Pair(book.items, movie.items)
}
)
}
}

book과 movie의 리포지터리를 받아 하나의 Pair로 만들어 주었습니다. 위 예제에서 필요한 데이터는 book, movie의 리스트가 전부입니다. 이 두개의 Contents를 받는 유즈케이스를 만들어 줍니다.




PresenterLayer


사용자 행동을 받고 이를 보여주는 레이어 입니다.



Model


data class BookItem(
val title: String,
val image: String
)

fun BookItemEntity.mapToPresenter(): BookItem = BookItem(
title,
image
)

fun List<BookItemEntity>.mapToPresenter(): List<BookItem> = map { it.mapToPresenter() }


data class MovieItem(
val title: String,
val image: String,
val director: String,
val actor: String,
val rating: String
)

fun MovieItemEntity.mapToPresenter(): MovieItem = MovieItem(
title,
image,
director.replace("|", ", ").dropLast(1),
actor.replace("|", ", ").dropLast(1),
rating.toString()
)

fun List<MovieItemEntity>.mapToPresenter(): List<MovieItem> = map { it.mapToPresenter() }

DomainLayer에서 받아온 데이터를 맵핑하여 PrensnterLayer에서 사용하기 위해 가공해줍니다. 만약 같은 Model을 사용했다면 Domain과 Prenenter는 서로에게 종속되겠죠? 이를 mapToPreneter() 함수를 사용해 Prenenter  -> Domain 으로만 의존성이 향하게 처리해 줄 수 있습니다. 



View


private fun loadData(query: String) {

GetContentsUsecase(
BookRepositoryImpl(
NaverRemoteDataSourceImpl(RemoteClient.naverService)
),
MovieRepositoryImpl(
NaverRemoteDataSourceImpl(RemoteClient.naverService)
),
AppSchedulerProvider
).get(query).doOnSubscribe {
showProgress()
}.doOnSuccess {
hideProgress()
}.subscribe({
emptySearchText()
bookAdapter.setItems(it.first.mapToPresenter())
movieAdapter.setItems(it.second.mapToPresenter())
}) {
Dlog.e(it.message)
toast(it.message.toString())
hideProgress()
}.also {
compositeDisposable.add(it)
}
}

다른 부분을 생략하고 MainActivity에서 데이터를 받아오는 부분입니다. GetContentsUseCase를 생성하고 그 안에 Repository 2개를 넣고 또 그안에 naverService 까지 주입하고 있네요. MainAcitivty는 클린아키텍처 레이어의 가장 바깥쪽으로 의존성을 가질 수 밖에 없습니다. 이러한 의존성은 추후 MVP, MVVM으로 변경하고 DI를 적용하여 없애주겠습니다.



정리


이렇게 클린 아키텍처를 크게 Presenter, Domain, Data 레이어로 분리한 후 Model에 해당하는 Domain과 Data 레이어를 코드와 함께 살펴봤습니다. 각 레이어 별로 데이터들간의 의존성을 단방향으로 향하기 위해 맵핑 해주었으며 각 객체들은 인터페이스로 장식해 주었습니다. 정말 힘든 여정이네요. 후우~ 그래도 위와 같이 작업하므로 Presenter와 Data레이어를 변경해도 그 연결 부분을 인터페이스와 데이터 맵핑 함수로 관리해 주기 때문에 Domain 코드는 수정할 필요가 없습니다! 이게 바로 클린아키텍처이죠!! 


클린아키텍처 원의 가장 안쪽에 있는 Domain은 그 외부가 아무리 변경된다 해도 결코 영향을 받아서는 안됩니다!!


☞ 여러 글을 읽고 필자의 생각을 정리한 포스팅입니다. 분명 틀린 점이나 다른 점이 있을 수 있습니다. 그 부분은 댓글로 알려주시면 내용을 수정하거나 제 의견을 남기겠습니다.


코드


위 사용한 코드는 깃허브에서 확인할 수 있습니다.



개선해야 할 점


1. MVP, MVVM 적용하기

2. DI 적용하기

3. 테스트 코드 적용하기


<참고자료>

네이버 테크 콘서트 - 예제에서 알려주지 않는 Model 이야기 (김범준)

Google Blueprint Sample

Clean Architecture Guide

Clean Coder Blog

반응형
Comments