클린 아키텍처(Clean Architecture) 는 2012년 로버트 C 마틴 (aka 밥아저씨)가 탄생시킨(?) 개념이다.
이전의 시스템 아키텍쳐들(알리스테어 코번의 헥사고날 아키텍쳐, 제프리 팔레모르의 어니언 아키텍쳐, 밥아저씨의 스크리밍 아키텍쳐 등)은 세세한 부분은 다르지만 모두 같은 목적을 갖고 있는데, 바로 관심사의 분리이다.
관심사의 분리를 위해 공통적으로 추구하는 내용은 아래와 같다.
이런 내용들을 단일 개념으로 통합하여 나온 그림이 아래의 그림이다.
각 동심원은 서로 다른 영역을 표현하며 안으로 향할수록 고수준, 바깥으로 갈수록 저수준의 계층이다. 의존 규칙에 의해서 소스 코드는 안쪽을 향해서만 의존할 수 있다. 즉, 반드시 저수준에서 고수준으로 향해야 한다. 안쪽의 원은 바깥쪽의 원에 대해 알지 못하며 참조해서는 안된다.
엔티티는 대규모 프로젝트 레벨의 핵심 비즈니스 규칙을 캡슐화 한다. 메서드를 가진 객체 일 수도 있지만 데이터 구조와 함수의 집합 일 수 도 있다.
바깥쪽에서 무엇이 변경되더라도 바뀌지 않는다.
애플리케이션 고유 비지니스 규칙을 포함한다. 엔티티로 들어오고 나가는 데이터의 흐름을 조정한다.
유즈케이스에서 발생한 변경이 엔티티에 영향을 주어서는 안되며 또한 유즈케이스 바깥쪽에서 발생한 변경이 유즈케이스에 영향을 주어서는 안된다.
인터페이스 어댑터 계층은 일련의 어댑터들로 구성되는데, 어댑터는 유스케이스와 엔티티의 데이터를 외부 계층에 적용할 수 있는 형식으로 변환한다. MVC, MVP, MVVM 등의 Controller, Presenter, ViewModel 등이 이 계층에 속한다.
가장 바깥쪽의 계층은 데이터베이스나 웹 프레임워크, UI 등으로 구성되는데 일반적으로 안쪽원과 통신하기 위한 코드외에 특별히 작성할 코드가 많지 않다.
아니다! 원은 컨셉을 전하기 위한 수단일뿐, 지켜야한다는 규칙은 없다. 다만 의존 규칙은 항상 지켜야한다. (반드시 저수준 -> 고수준)
아래는 안드로이드 뿐 아니라 모바일에서 적용되는 클린 아키텍처의 개념을 설명한 그림이다.
3개의 레이어로 이루어져 있으며, 클린 아키텍쳐상의 Entity 개념은 채택하지 않는다 (Data Layer의 Entity는 클린 아키텍처의 Entity의 개념과 다름)
의존성의 방향은 Presentation(UI) -> Data -> Domain 이다.
(Repository의 위치에 따라 의존성 방향이 Presentation(UI) -> Domain -> Data 로 될 수 있다)
각각의 레이어들을 이미지 검색 앱으로 예를 들어 설명해보겠다. (코드 전문)
화면과 관련된 모든 것들이 이 레이어에 포함된다.
@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에 영향을 끼쳐서는 안된다.
class SearchImageUseCase(
private val imageRepository: ImageRepository
) {
suspend operator fun invoke(query: String): Flow<PagingData<Image>> {
return imageRepository.searchImages(query)
}
}
data class ImageModel(
val title: String,
val link: String,
val thumbnail: String,
val sizeHeight: Int,
val sizeWidth: Int,
val isFavorite: Boolean = false
)
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)
}
@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
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
)
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)
}
}
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
)