Clean Architecture

KEH·2022년 6월 27일
1
post-thumbnail

오늘은 Android의 Clean Architecture에 대해서 말해보려고 합니다.
Clean Architecture에 대해서는 작년 이맘때쯤 처음 접하게 되었습니다. 대충 '각 역할에 맞게 계층을 분리하여 의존성을 낮추기 위해 사용한다.' 라고 어렴풋이 이해는 됐지만 어떻게 적용시켜야 할지, 개념이 와닿지 않았습니다.

이럴 땐 역시 무작정 따라해보고, 여러 블로그들을 돌아다니며 개념을 마구마구 눈에 익히는게 답인 것 같습니다. 이제야 어느 정도 이해가 됐다고 할까요,,,ㅎㅎ

아무튼! 지금까지 제가 이해한 내용을 최대한 이 곳에 담아보고자 합니다.
아직 알아야할 게 훨씬 많지만,, 이 글이 최대한 많은 도움이 됐음 좋겠습니다😊


Clean Architecture

[출처] https://github.com/igorwojda/Android-Showcase#architecture

[출처] https://www.charlezz.com/?p=45391

저는 이 두 사진이 가장 이해하기 쉬운 것 같습니다. 안드로이드의 클린 아키텍처는 크게 Presentation, Domain, Data 세 계층으로 나눌 수 있습니다.

Presentation

프레젠테이션 계층은 UI 와 관련된 계층 입니다. 해당 계층에는 Activity, Fragment 와 같은 UI 관련 클래스와 ViewModel 클래스가 포함됩니다. Presentation 계층은 Domain 계층과 Data 계층에 의존성을 갖습니다.
(Domain 계층에 의존성을 갖는다는 건 이해했는데 Data 계층에서 의존성을 갖는다는 건 아직 이해가 안가네요,, 알고 싶습니다.🥲)

Domain

Domain 계층은 비즈니스 로직을 처리 하는 계층입니다. Domain 계층에는 UseCase, Entity Model, Repository Interface 가 있습니다. Domain 계층은 Presentation 과 Data 계층에 어떠한 의존성도 갖지 않는 독립적인 계층 입니다.

UseCase

Domain 계층에서 UseCase 는 굉장히 중요한 역할을 합니다. 저는 UseCase 가 길잡이 역할을 한다고 생각해요. Presentation 계층에서 UseCase 를 호출하면 UseCase 는 Repository Interface를 통해 Data 계층과 연결됩니다. 모든 로직이 UseCase 를 주축으로 흐름이 이어진다는 생각이 들더라구요!

Entity(DomainModel)

Domain 계층에서 사용되는 데이터 모델을 Entity 라고 합니다. 저는 이 데이터 모델을 Data 계층에서 API 요청을 통해 받은 Response Data 를 사용자 요구사항에 맞게 가공한 데이터라고 이해했습니다.

Data

Data 계층은 DB, Server 와 상호작용을 하는 계층 입니다. 해당 계층에는 Repository 구현클래스DataSource, Data Model, Mapper 가 포함됩니다. Data 계층은 Domain 계층에 의존성 을 갖습니다.

DataSource

DataSource 는 서버나 DB 를 연결하는 역할을 합니다.

Data Model

Date 계층의 데이터 모델은 DB 를 통해 얻는 순수 데이터 구조, 서버 API 호출을 통해 받는 순수한 Response Data 구조를 말합니다. API 명세서에 정의돼 있는 Response Data 그대로 데이터 모델에 옮겨온다고 생각하면 됩니다.

Mapper

Mapper 는 API 호출 또는 DB 를 통해 얻어온 Data Model 을 Domain 계층의 데이터 모델에 맞게 가공해주는 클래스 입니다.


Clean Architecture 예제

진행하는 예제는 제가 현재 서비스하고 있는 발자국이란 어플의 이번달 목표 조회 기능입니다. 의존성 주입은 Koin 라이브러리를 활용하여 적용했습니다.
발자국 플레이스토어 (저희 앱 많이 사용해주세요😙)

Data

GoalService.kt

interface GoalService {
    @GET("{uri 적어주세욥}")
    suspend fun getThisMonthGoal(): Response<BaseResponse>
}

GoalRemoteDataSource.kt

interface GoalRemoteDataSource {
    suspend fun getThisMonthGoal(): Result<BaseResponse>
}

GoalRemoteDataSourceImpl.kt

DataSource 클래스에서 api 를 호출합니다.

class GoalRemoteDataSourceImpl(private val api: GoalService): BaseRepository(), GoalRemoteDataSource {
    override suspend fun getThisMonthGoal(): Result<BaseResponse> {
        return safeApiCall2() { api.getThisMonthGoal() }
    }
}

BaseResponse.kt

저희는 데이터를 암호화하여 저장하고 있어 BaseResponse 라는 공통된 Response Data 구조를 갖고 있습니다. result 변수에 암호화된 응답 데이터(여기서는 이번달 목표 데이터)가 들어가 있습니다.

data class BaseResponse(
    val isSuccess: Boolean,
    val code: Int,
    val message: String,
    val result: String?
)

BaseRepository.kt

BaseRepository 클래스의 safeApiCall2 메서드를 통해 Retrofit 통신 과정에서의 에러 핸들링을 진행하고 있습니다. 이 부분은 좀 더 이해가 필요한 부분이라 더 공부한 후 글을 작성하겠습니다.

지금까지 이해한 정도로는 api 통신 메서드를 호출한 후 성공했으면 Result.Success 객체에 응답 데이터(이번달 목표 데이터)를 담아서 리턴하고, 그 외의 경우는 각 상황에 맞는 에러 데이터를 Result 객체에 담아 리턴합니다.
(에러 핸들링 너무 어렵습니다,, 잘 아시는 분 꼭꼭 댓글 달아주세욥,,,)

abstract class BaseRepository {
    suspend fun <T> safeApiCall2(apiCall: suspend () -> Response<T>): Result<T> {
        return try {
            val myResp = apiCall.invoke()

            if (myResp.isSuccessful)
                Result.Success(myResp.body()!!)
            else
                Result.GenericError(myResp.code(), myResp.message() ?: "Something goes wrong")  
        } catch (e: IOException) {
            Result.NetworkError
        } catch (e: Exception) {
            Result.GenericError(RETROFIT_ERROR_CODE, e.message?: "Retrofit Error")
        }
    }
}

GoalRepositoryImpl.kt

GoalRepositoryImpl 클래스는 GoalRepository 인터페이스를 상속 받습니다. 이 부분이 Data 계층이 Domain 계층을 의존하고 있는 부분 입니다. 왜냐하면 GoalRepository 인터페이스는 Domain 계층에 존재하기 때문이죠!

또 getThisMonthGoal 은 GoalEntity 를 리턴합니다. GoalEntity는 Domain 계층의 데이터 모델입니다. 해당 메서드 내부에서는 GoalRemoteDataSource 의 getThisMonthGoal 메서드를 호출하여 전달 받은 GoalModel 데이터 클래스를 GoalMapper의 mapperToGoalEntity 메서드를 호출하여 GoalEntity 데이터 클래스로 가공합니다.

추가적으로! 저희는 데이터를 암호화하여 저장한다고 했습니다. 따라서 NetworkUtils.decrypt 메서드를 통해 복호화를 진행하였습니다.

class GoalRepositoryImpl(private val dataSource: GoalRemoteDataSource): GoalRepository {
    override suspend fun getThisMonthGoal(): Result<GoalEntity> {

        return when (val response = dataSource.getThisMonthGoal()) {
            is Result.Success -> {
                if (response.value.isSuccess)
                    Result.Success(GoalMapper.mapperToGoalEntity(NetworkUtils.decrypt(response.value.result, GoalModel::class.java)))
                else
                    Result.GenericError(response.value.code, response.value.message)    //여기서는 서버에서 보내주는 code, message
            }
            is Result.NetworkError -> response
            is Result.GenericError -> response
        }
    }

GoalModel.kt

서버의 API 호출을 하여 전달 받은 순수한 ResponseData 입니다.

data class GoalModel(
    var month: String? = "",
    val dayIdx: ArrayList<Int> = arrayListOf(),
    val userGoalTime: UserGoalTime = UserGoalTime(),
    var goalNextModified: Boolean? = true
): Serializable

data class UserGoalTime(
    var walkGoalTime: Int? = null,
    var walkTimeSlot: Int? = null
): Serializable

GoalMapper.kt

object GoalMapper {
    fun mapperToGoalEntity(goalModel: GoalModel): GoalEntity = goalModel.run {
        GoalEntity(
            month,
            dayIdx,
            userGoalTime.run { UserGoalTime(walkGoalTime, walkTimeSlot) },
            goalNextModified
        )
    }
}

Domain

GoalEntity.kt

사실 해당 기능에서는 GoalEntity 와 GoalModel 의 데이터 구조가 같습니다. 같더라도 꼭 Data 계층에서의 Model 과 Domain 계층에서의 Model 데이터를 분리해 주세요!!

data class GoalEntity(
    var month: String? = "",
    val dayIdx: ArrayList<Int> = arrayListOf(),
    val userGoalTime: UserGoalTime = UserGoalTime(),
    var goalNextModified: Boolean? = true
)

data class UserGoalTime(
    var walkGoalTime: Int? = null,
    var walkTimeSlot: Int? = null
)

GoalRepository.kt

interface GoalRepository {
    suspend fun getThisMonthGoal(): Result<GoalEntity>
}

GetThisMonthGoalUseCase.kt

class GetThisMonthGoalUseCase(private val repository: GoalRepository) {
    suspend fun invoke(): Result<GoalEntity> = repository.getThisMonthGoal()
}

Presentation

GoalThisMonthFragment.kt

프래그먼트 화면이 실행되면 이번달 목표 데이터를 받아오기 위해 GoalViewModel 의 getThisMonthGoal 메서드를 호출합니다.

class GoalThisMonthFragment: BaseFragment<FragmentGoalThisMonthBinding>(FragmentGoalThisMonthBinding::inflate) {
	private val goalVm: GoalViewModel by viewModel()

    override fun initAfterBinding() {
        goalVm.getThisMonthGoal()	//이번달 목표 데이터를 받아오기 위한 뷰모델 함수 호출
        observe()
    }
}

GoalViewModel.kt

GoalViewModel 클래스의 매개변수로 UseCase를 받습니다. 이 부분에서 Presentation 계층과 Domain 계층에 의존성이 생기게 됩니다.

class GoalViewModel(private val getThisMonthGoalUseCase: GetThisMonthGoalUseCase): BaseViewModel() {
    private val _thisMonthGoal: MutableLiveData<GoalEntity> = MutableLiveData()
    val thisMonthGoal: LiveData<GoalEntity> get() = _thisMonthGoal

    fun getThisMonthGoal() {
        viewModelScope.launch {
            when (val response = getThisMonthGoalUseCase.invoke()) {
                is Result.Success -> _thisMonthGoal.value = response.value
                is Result.NetworkError -> mutableErrorType.postValue(ErrorType.NETWORK)
                is Result.GenericError -> {
                    if (response.code==RETROFIT_ERROR_CODE)
                        mutableErrorType.postValue(ErrorType.UNKNOWN)
                    else   
                        mutableErrorType.postValue(ErrorType.DB_SERVER)
                }
            }
        }
    }
 }

Domain 계층은 독립적이다

처음 Clean Architecture 를 접했을 때 Domain 계층에서 UseCase 가 Repository를 호출하는 데 왜 독립적인건지 이해가 안 갔습니다. Repository는 Data 계층이라고 생각했기 때문이죠.

[출처] https://www.charlezz.com/?p=45391

위 사진은 구글에서 추천하는 Repository 패턴입니다. 저는 이 사진을 보고 무릎을 탁 쳤습니다! UseCase 클래스의 매개변수는 Repository Interface 인 것이고, Data 계층의 Repository 구현체 클래스에서 Repository Interface 를 상속하고 있습니다. 당연히 Domain 계층은 독립적이고, Data 계층은 Domain 계층에 의존하고 있는 것입니다!
이걸 왜 이해 못했을까요,, 혹시 저처럼 이해가 어려웠던 분들이 있을수도 있으니,, 제 경험을 공유합니다 ㅎㅎ,,


Clean Architecture 는 정말 어렵습니다! 사실 작성한 예제도 맞는 예제라고 장담 못합니다! 개발을 하다 보면 또다시 깨달음을 얻고 이 글이 모두 수정될 수도 있겠지요! 안드로이드 능력자 분들 살벌한 피드백 부탁 드립니다!😛





[Android, Architecture] 안드로이드 아키텍처 - Model편
Android: Error handling in Clean Architecture
안드로이드 Clean Architecture 구현하기
igorwojda/android-showcase
안드로이드와 클린 아키텍처
Clean Architecture 란 ?
[안드로이드] 클린 아키텍처(Clean Architecture) 정리 및 구현

profile
개발을 즐기고 잘하고 싶은 안드로이드 개발자입니다 :P

1개의 댓글

comment-user-thumbnail
2023년 10월 16일

좋은 글 잘봤습니다 !

답글 달기