-
Notifications
You must be signed in to change notification settings - Fork 0
1. Domain
도메인은 어느 라이브러리에도 의존성을 가지지 않고(심지어 안드로이드 라이브러리도!) 순수 코틀린 코드로 이루어진 layer입니다.
이 앱이 어떤 일을 할 것인지를 결정하는 비즈니스 로직을 포함하고 있습니다.
이 Domain은 optional로서 구현이 필요 하지 않은 프로젝트가 있을 수 있습니다. 아래는 안드로이드에서 권장하는 아키텍쳐 가이드 입니다.
Android Architecture Guide
출처: https://developer.android.com/topic/architecture?hl=ko
위의 그림처럼 필수는 아니며 앱을 동작하는데 필요한 로직들이 많지 않는다면 구현을 안하는 것이 좋을 것 같습니다.
이번 프로젝트도 복잡한 로직이 없어 Domain Layer가 필요하지 않지만, 예시를 위해 작성하였습니다.
제가 정의한 클래스들을 구분하기 위해 접미어에 특정 키워드를 붙여 정의하였습니다.
도메인 레이어에서는 아래와 같은 규칙이 있습니다.
- Query
api에 요청을 할 때 필요한 Parameter입니다.
- Body
api에 요청한 후 응답의 body값 입니다.
- Info
api와는 별개로 프로젝트 전체를 통틀어 사용할 데이터 타입을 정의한 클래스입니다.
- UseCase
비즈니스 로직을 포함하고 있는 클래스입니다. 데이터를 받아서 무엇을 할지 결정합니다.
- Repository
데이터를 받아오기 위한 창구 역할을 하는 클래스입니다.
이렇게 컨벤션을 정한 다음 아래와 같이 패키지를 구성하였습니다.
객체지향 5가지 설계 원칙 중 의존 역전 원칙(Dependency inversion principle)을 준수하기 위해선 데이터를 가져오는 직접적인 구현을 의존하기 보단 프로그램에서 어떤 데이터를 사용할지 먼저 정의하는 것이 좋다고 생각했습니다.
package com.moonyh.domain.model.info
interface BusInfo : BaseInfo {
val id: String
val number: String
val lineType: String
val remainStation: Int
val remainTimeSec: Int
val busType: String
val endStation: String
val startStation: String
}
그래서 공공 api에서 주는 데이터를 고려하지 않고 프로그램 안에서 사용할 데이터를 Interface로 정의하였습니다.
이렇게 Interface를 먼저 작성하고 실제 데이터를 가져오는 클래스에 상속받는다면 구현을 강제하게 되어 저수준 구현 모둘이 고수준 모듈을 의존하게 됩니다.
이로써 의존 역전 원칙을 만족하게 작성하였습니다.
Domain Layer는 이러한 의존 역전 원칙을 만족하기에 너무 좋은 레이어라고 생각했습니다.
모든 Layer에서 의존성을 가지는 모듈이고, 모든 모듈이 앱의 정책과 같은 고수준 로직을 의존하기 때문입니다.
따라서 대부분의 클래스는 Interface로 작성하였습니다.
확장성을 위해 둘 이상의 클래스가 같은 의미를 가지고 있다면 반드시 공통된 Interface를 상속 받도록 하였습니다.
예를 들어 아래와 같이 Api를 호출한 후 직접적으로 사용하는 데이터인 Body는 아래와 같이 제너릭으로 타입을 정의하고
이러한 클래스를 Interface가 상속 받아 타입을 override하는 형태입니다.
package com.moonyh.domain.model.api.base
interface ApiBody<out T : Any, out A : Any> {
val metaData: T
val items: A
}
상속을 받은 BusInStationInfoBody
interface BusInStationInfoBody : ApiBody<MetaData, ArrayList<out BusInfo>> {
override val metaData: MetaData
override val items: ArrayList<out BusInfo>
}
또한 극단적으로 아무런 공통 기능과 필드가 없어도 둘 이상의 클래스가 공통된 의미를 가지고 있다면 텅 빈 Interface를 상속 받도록 만들었습니다.
interface ApiQuery {
}ApiQuery를 상속 받은 BusInStationQuery
data class BusInStationQuery(
val key:String,
val cityCode:String,
val stationId:String,
var page: Int=1,
var itemCount:Int=10
): ApiQuery
이렇게 하면 아래와 같이 타입을 제한하여 더욱 명확한 Interface를 만들 수 있었습니다.
interface ApiUseCase<T : ApiQuery, A : ApiBody<MetaData, Any>> {
suspend operator fun invoke(query: T): ApiResponse<A>
}Domain은 아무 라이브러리도 종속성을 가질 수 없습니다. 그래서 Retrofit의 Response 타입을 사용하지 못합니다.
그러나 Api의 응답을 반환하는 Repository에 반환할 타입이 필요했습니다.
그래서 커스텀 타입을 정의하는 김에 평소에 어렵고 복잡했던 api 실패 핸들링 또한 개선하고자 했습니다.
이 블로그 글에 좋은 솔루션이 있어 이 코드를 이해한 후 변형하여 프로젝트에 반영했습니다.
먼저 반환할 타입인 ApiResponse를 seald class로 정의하고 이 클래스를 상속 받는 Success, Error, Exception, NoResponse를 작성하여
클래스 타입으로 에러 및 여러 경우를 대응했습니다.
sealed class ApiResponse<out T> {
class Success<T : Any>(val data: T) : ApiResponse<T>()
class Error<T : Any>(val code: Int, val message: String?) : ApiResponse<T>()
class Exception<T : Any>(val e: Throwable) : ApiResponse<T>()
class NoResponse<T: Any>:ApiResponse<T>()
}그 다음 이 클래스의 메서드를 확장하여 콜백을 받을 수 있게 하였습니다.
suspend fun <T : Any> ApiResponse<T>.onSuccess(
executable: suspend (T) -> Unit
): ApiResponse<T> = apply {
if (this is ApiResponse.Success<T>) {
executable(data)
}
}
suspend fun <T : Any> ApiResponse<T>.onError(
executable: suspend (code: Int, message: String?) -> Unit
): ApiResponse<T> = apply {
if (this is ApiResponse.Error<T>) {
executable(code, message)
}
}
suspend fun <T : Any> ApiResponse<T>.onException(
executable: suspend (e: Throwable) -> Unit
): ApiResponse<T> = apply {
if (this is ApiResponse.Exception<T>) {
executable(e)
}
}이로써 아래와 같이 반환 타입을 Domain에서 사용할 수 있었습니다.
interface ArrivalInfoRepository : Repository {
suspend fun getStationArrivalInfo(
stationArrivalInfoQuery: StationArrivalInfoQuery
): ApiResponse<StationArrivalInfoBody>
suspend fun getBusArrivalInfoInStation(
busArrivalInfoInStationQuery: BusArrivalInfoInStationQuery
): ApiResponse<BusArrivalInStationInfoBody>
}이 이후에 Data Layer에서 위의 ApiResponse를 Retrofit에 사용하기 위해 CallAdapterFactory를 만든 후 적용하였습니다.
이 내용은 Data Layer 페이지에서 확인하실 수 있습니다.
UseCase는 기존에 ViewModel에서 처리하던 Api의 실패 로직 및 데이터 가공 로직을 따로 분리해 내어 사용하는 클래스입니다.
구성
UseCase라는 것도 둘 이상의 공통으로 사용하는 의미이므로 UseCase라는 빈 클래스를 만들었습니다.
interface UseCase {
}그 다음 Api로 데이터를 가져오는 UseCase들을 공통으로 묶기 위해 ApiUseCase라는 클래스를 만들었습니다.
interface ApiUseCase<T : ApiQuery, A : ApiBody<MetaData, Any>>:UseCase {
suspend operator fun invoke(query: T): ApiResponse<A>
}그 다음 이 인터페이스를 abstract클래스에 상속 받고, 이 abstract 클래스를 상속 받는 Impl클래스를 작성하여 UseCase를 만들었습니다.
abstract class GetBusInStationUseCase : ApiUseCase<BusInStationQuery, BusInStationInfoBody> {
abstract override suspend operator fun invoke(query: BusInStationQuery): ApiResponse<BusInStationInfoBody>
}
class GetBusInStationUseCaseImpl(private val stationRepository: StationRepository) :
GetBusInStationUseCase() {
override suspend operator fun invoke(query: BusInStationQuery): ApiResponse<BusInStationInfoBody> {
return stationRepository.getBusInStation(query)
}
}이렇게 구성한다면, 나중에 사용할 Presentation Layer에서 BaseViewModel을 작성하고, 아래와 같이 메소드를 정의한다면
코드의 양을 획기적으로 줄일 수 있었습니다.
abstract class BaseViewModel(application: Application) : AndroidViewModel(application) {
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private val _errorMessageFlow = MutableStateFlow("")
val errorMessageFlow = _errorMessageFlow.asStateFlow()
protected val context: Context by lazy {
getApplication()
}
//여기가 중요
protected fun <T : ApiQuery, A : ApiBody<MetaData, Any>> runApiUseCase(
apiUseCase: ApiUseCase<T, A>,
query: T,
loading: Boolean = true,
resultFlow: MutableStateFlow<A?>,
): Job {
if (loading)
enableLoading()
return viewModelScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val result = apiUseCase(query)
if (loading)
disableLoading()
result.onSuccess {
resultFlow.emit(it)
Log.e("test", "onSuccess")
}.onError { _, message ->
_errorMessageFlow.emit("$message")
}.onException {
_errorMessageFlow.emit("$it")
}
}
}
protected fun <T : ApiQuery, A : ApiBody<MetaData, Any>> runApiUseCase(
apiUseCase: ApiUseCase<T, A>,
query: T,
loading: Boolean = true,
onFinished: (A) -> Unit,
): Job {
if (loading)
enableLoading()
return viewModelScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
val result = apiUseCase(query)
if (loading)
disableLoading()
result.onSuccess {
onFinished(it)
}.onError { _, message ->
_errorMessageFlow.emit("$message")
}.onException {
_errorMessageFlow.emit("$it")
}
}
}
fun enableLoading() {
_isLoading.value = true
}
fun disableLoading() {
_isLoading.value = false
}
private val coroutineExceptionHandler =
CoroutineExceptionHandler { _, throwable ->
throwable.printStackTrace()
}
}자세한 내용은 Presentation layer에서 설명하겠습니다.
로직
앞서 말한 바와 같이 UseCase는 데이터를 어떻게 사용할 것인지, 어떻게 가공할 것인지 판단하는 로직을 포함하고 있습니다.
저는 버스의 도착 정보를 HashMap으로 캐싱하고 가장 가까운 버스 만을 노출시켜야하는 로직이 필요했습니다.
따라서 아래와 같이 버스의 id를 key로, 버스의 정보를 value로 가지는 HashMap 캐시를 이용하여 가장 가까운 버스만을 노출시키는 로직을 UseCase에 작성하였습니다.
class GetStationArrivalInfoUseCaseImpl(private val arrivalInfoRepository: ArrivalInfoRepository) :
GetStationArrivalInfoUseCase() {
private val cache=HashMap<String, BusInfo>()
override suspend operator fun invoke(query: StationArrivalInfoQuery): ApiResponse<StationArrivalInfoBody> {
val info=arrivalInfoRepository.getStationArrivalInfo(query)
if(info is ApiResponse.Success<StationArrivalInfoBody>){
info.data.items.forEach {
if(cache[it.id]==null){
cache[it.id]=it
return@forEach
}
}
return ApiResponse.Success(object : StationArrivalInfoBody {
override val metaData: MetaData =info.data.metaData
override val items: ArrayList<out BusInfo> =ArrayList(cache.values).apply { sortBy { it.remainTimeSec } }
})
}
return info
}
}여기까지 Domain Layer의 설명이었습니다.