안드로이드에 클린아키텍처 적용하기

IM·2022년 6월 15일
0
post-thumbnail
post-custom-banner

클린 아키텍처란?

클린 아키텍처(Clean Architecture) 는 2012년 로버트 C 마틴 (aka 밥아저씨)가 탄생시킨(?) 개념이다.

이전의 시스템 아키텍쳐들(알리스테어 코번의 헥사고날 아키텍쳐, 제프리 팔레모르의 어니언 아키텍쳐, 밥아저씨의 스크리밍 아키텍쳐 등)은 세세한 부분은 다르지만 모두 같은 목적을 갖고 있는데, 바로 관심사의 분리이다.

관심사의 분리를 위해 공통적으로 추구하는 내용은 아래와 같다.

  1. UI, 데이터베이스, 프레임워크, 외부기능 등의 독립성
  2. 테스트의 용이성
  3. 기능 변경 및 확장의 용이성

이런 내용들을 단일 개념으로 통합하여 나온 그림이 아래의 그림이다.

의존 규칙

각 동심원은 서로 다른 영역을 표현하며 안으로 향할수록 고수준, 바깥으로 갈수록 저수준의 계층이다. 의존 규칙에 의해서 소스 코드는 안쪽을 향해서만 의존할 수 있다. 즉, 반드시 저수준에서 고수준으로 향해야 한다. 안쪽의 원은 바깥쪽의 원에 대해 알지 못하며 참조해서는 안된다.

엔티티 (Entity)

엔티티는 대규모 프로젝트 레벨의 핵심 비즈니스 규칙을 캡슐화 한다. 메서드를 가진 객체 일 수도 있지만 데이터 구조와 함수의 집합 일 수 도 있다.
바깥쪽에서 무엇이 변경되더라도 바뀌지 않는다.

유즈케이스 (UseCase)

애플리케이션 고유 비지니스 규칙을 포함한다. 엔티티로 들어오고 나가는 데이터의 흐름을 조정한다.
유즈케이스에서 발생한 변경이 엔티티에 영향을 주어서는 안되며 또한 유즈케이스 바깥쪽에서 발생한 변경이 유즈케이스에 영향을 주어서는 안된다.

인터페이스 어댑터 (Interface Adapter)

인터페이스 어댑터 계층은 일련의 어댑터들로 구성되는데, 어댑터는 유스케이스와 엔티티의 데이터를 외부 계층에 적용할 수 있는 형식으로 변환한다. MVC, MVP, MVVM 등의 Controller, Presenter, ViewModel 등이 이 계층에 속한다.

프레임워크과 드라이버

가장 바깥쪽의 계층은 데이터베이스나 웹 프레임워크, UI 등으로 구성되는데 일반적으로 안쪽원과 통신하기 위한 코드외에 특별히 작성할 코드가 많지 않다.

원은 네개여야만 할까?

아니다! 원은 컨셉을 전하기 위한 수단일뿐, 지켜야한다는 규칙은 없다. 다만 의존 규칙은 항상 지켜야한다. (반드시 저수준 -> 고수준)

안드로이드에 적용하기

아래는 안드로이드 뿐 아니라 모바일에서 적용되는 클린 아키텍처의 개념을 설명한 그림이다.

3개의 레이어로 이루어져 있으며, 클린 아키텍쳐상의 Entity 개념은 채택하지 않는다 (Data Layer의 Entity는 클린 아키텍처의 Entity의 개념과 다름)

의존성의 방향은 Presentation(UI) -> Data -> Domain 이다.
(Repository의 위치에 따라 의존성 방향이 Presentation(UI) -> Domain -> Data 로 될 수 있다)

각각의 레이어들을 이미지 검색 앱으로 예를 들어 설명해보겠다. (코드 전문)

Presentation Layer (UI Layer)

화면과 관련된 모든 것들이 이 레이어에 포함된다.

  • UI : UI를 표시하는 부분 (HostActivity, SearchImageFragment 등)
  • Presenter : UI 업데이트와 관련된 로직을 구현 (SearchImageViewModel 등)
@HiltViewModel
class SearchImageViewModel @Inject constructor(
    private val useCases: UseCases
): BaseViewModel() {
    private val _state = MutableLiveData<SearchImageState>()
    val state : LiveData<SearchImageState> = _state

    private val queryFlow = MutableSharedFlow<String>()

    override fun fetchData() = viewModelScope.launch{
        queryFlow
            .flatMapLatest {
                searchImage(it)
            }
            .cachedIn(viewModelScope)
            .collectLatest {
                setState(SearchImageState.Success(it))
            }
    }

    private suspend fun searchImage(query: String): Flow<PagingData<ImageModel>> = useCases.searchImageUseCase(query)

    fun handleQuery(query: String) = viewModelScope.launch(errorHandler) {
        queryFlow.emit(query)
    }

    fun favoriteImage(imageModel: ImageModel) = viewModelScope.launch(errorHandler){
        useCases.insertFavoriteImageUseCase(imageModel)
    }

    private fun setState(state: SearchImageState) {
        _state.value = state
    }
}

Domain Layer

독립적인 계층으로, 다른 계층의 변경이 Domain Layer에 영향을 끼쳐서는 안된다.

  • UseCase : 행동들의 최소 단위
    - 이름만 보고도 무슨 기능을 수행하는지 알 수 있어야한다 (마치 요구사항 명세서 같다..!)
class SearchImageUseCase(
    private val imageRepository: ImageRepository
) {
    suspend operator fun invoke(query: String): Flow<PagingData<Image>> {
        return imageRepository.searchImages(query)
    }
}
  • Model : 필요한 데이터
data class ImageModel(
    val title: String,
    val link: String,
    val thumbnail: String,
    val sizeHeight: Int,
    val sizeWidth: Int,
    val isFavorite: Boolean = false
)
  • Repository (Interface) : 관련된 행동들을 정의
    - Domain Layer 를 독립적으로 만들기 위해 Repository를 Interface와 구현체로 분리해야함
interface ImageRepository {
    fun getFavoriteImages(): Flow<List<ImageEntity>>

    suspend fun searchImages(query: String): Flow<PagingData<ImageModel>>

    suspend fun insertFavoriteImage(imageEntity: ImageEntity)

    suspend fun deleteFavoriteImage(imageEntity: ImageEntity)
}

Data Layer

  • DataStore : 로컬 DB 또는 REST API 통신과 관련된 내용
@Database(
    entities = [ImageEntity::class],
    version = 1
)
abstract class ImageDatabase: RoomDatabase() {
    abstract val imageDao: ImageDao
}
@Dao
interface ImageDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertImage(imageEntity: ImageEntity)

    @Query("SELECT * FROM imageentity")
    fun getImages(): Flow<List<ImageEntity>>

    @Delete
    suspend fun deleteImage(imageEntity: ImageEntity)
}
  • Entity : 로컬 DB의 테이블을 만들기 위한 Entity와 서버 통신을 위한 Dto가 포함된다.
@Entity
data class ImageEntity(
    @PrimaryKey val title: String,
    val link: String,
    val thumbnail: String,
    val sizeHeight: Int,
    val sizeWidth: Int
)
data class ImageDto(
    val title: String,
    val link: String,
    val thumbnail: String,
    @SerializedName("sizeheight") val sizeHeight: Int,
    @SerializedName("sizewidth") val sizeWidth: Int
)
  • Repository (구현체) : Domain Layer의 Repository Interface의 실제 구현을 담당한다.
class ImageRepositoryImpl(
    private val ioDispatcher: CoroutineDispatcher,
    private val searchApi: SearchApi,
    private val imageDao: ImageDao
): ImageRepository {
    override fun getFavoriteImages(): Flow<List<ImageEntity>> =
        imageDao.getImages().flowOn(ioDispatcher)

    override suspend fun searchImages(query: String): Flow<PagingData<ImageModel>> {
        return Pager(
            config = PagingConfig(
                pageSize = SearchImagesDataSource.defaultDisplay,
                enablePlaceholders = false
            ),
            pagingSourceFactory = {
                SearchImagesDataSource(query, searchApi)
            }
        ).flow
    }

    override suspend fun insertFavoriteImage(imageEntity: ImageEntity) {
        imageDao.insertImage(imageEntity)
    }

    override suspend fun deleteFavoriteImage(imageEntity: ImageEntity) {
        imageDao.deleteImage(imageEntity)
    }
}
  • Mapper : Entity -> Model, Dto -> Entity, Dto -> Model 등과 같이 데이터들의 형식을 변환한다.
fun ImageEntity.toImageModel() : ImageModel =
    ImageModel(
        title = title,
        link = link,
        thumbnail = thumbnail,
        sizeHeight = sizeHeight,
        sizeWidth = sizeWidth
    )

fun List<ImageEntity>.toImageModels(): List<ImageModel> =
    map {
        it.toImageModel()
    }

fun ImageDto.toImageModel(): ImageModel =
    ImageModel(
        title = title,
        link = link,
        thumbnail = thumbnail,
        sizeHeight = sizeHeight,
        sizeWidth = sizeWidth
    )

fun ImageModel.toImageEntity(): ImageEntity =
    ImageEntity(
        title = title,
        link = link,
        thumbnail = thumbnail,
        sizeHeight = sizeHeight,
        sizeWidth = sizeWidth
    )

* 참고

profile
Android Developer
post-custom-banner

0개의 댓글