관리 메뉴

막내의 막무가내 프로그래밍 & 일상

[안드로이드] 클린 아키텍처(Clean Architecture) 정리 및 구현 본문

안드로이드/코틀린 & 아키텍처 & Recent

[안드로이드] 클린 아키텍처(Clean Architecture) 정리 및 구현

막무가내막내 2021. 1. 28. 22:45
728x90

[2021-04-28 업데이트]

 

[2022-02-01 업데이트]

Hilt 사용한 프로젝트 링크 하단에 추가

 

 

 

 

[프로젝트]

github.com/mtjin/mtjin-android-clean-architecture-movieapp

 

mtjin/mtjin-android-clean-architecture-movieapp

Clean Architecture 학습 및 구현. Contribute to mtjin/mtjin-android-clean-architecture-movieapp development by creating an account on GitHub.

github.com


 

시작하기 앞서 처음 학습한거라 미숙한 점이 많은 점 양해부탁드립니다. 공부 더 하고 나중에 프로젝트 및 내용을 수정할 예정입니다 :)

 

P.S 이 포스팅의 샘플 토이 프로젝트에서는 에러코드를 그냥 kt파일 const val 로 코드를 나누어 해결했는데 원래 같으면 result나 code 같은 값을 가진 BaseResponse 같은 data class 파일을 만들어 깔끔하게 분기를 나누거나 Exception 클래스를 상속받은 클래스들을 받아서 통신 응답 및 예외 처리를 하면 좋으나 토이프로젝트이기 때문에 간단히 상수로 해결한점 양해부탁드립니다.

 

1. 클린 아키텍처의 두둥등장

 

 

 

BOB 삼촌, 출처: https://sites.google.com/site/unclebobconsultingllc/

 

 

특정 수준 혹은 복잡도를 가진 애플리케이션을위한 고품질 코드를 작성하려면 상당한 노력과 경험이 필요합니다. 애플리케이션은 고객의 요구 사항을 충족 할뿐만 아니라 유연하고 테스트 가능하며 유지 관리가 가능해야합니다.

이러한 문제에 대한 해결책으로 Bob 삼촌으로도 알려진 Robert C. Martin은 2012 년에 Clean Architecture 개념을 제시했습니다.

 

 


 

2-1. 클린 아키텍처의 구조 및 특징

 

 

오리지널 클린아키텍처 구조

 

 

클린 아키텍처의 구조는 위와 같이 총 4가지 계층으로 되어 있습니다.

이렇게 계층을 나누는 이유는 계층을 분리하여 관심사를 분리시키기 위해서이며 이런 아키텍처가 동작하기 위해서는 의존성 규칙을 지켜야 합니다. 한마디로 각 분리된 클래스가 한가지 역할만 하고 서로 의존을 어떻게 할지 규칙이 정해져있고 지켜야한다는 말입니다.

(+ 의존성이란?  "의존성"은 예를 들어 서비스로 사용할 수 있는 객체이다. 클라이언트가 어떤 서비스를 사용할 것인지 지정하는 대신, 클라이언트에게 무슨 서비스를 사용할 것인지를 말해주는 것이다. "주입"은 의존성(서비스)을 사용하려는 객체(클라이언트)로 전달하는 것을 의미한다.)

 

여기서 의존성 규칙은 모든 소스코드 의존성은 반드시 외부에서 내부로, 고수준 정책을 향해야 합니다. (위 그림에서는 원 안쪽으로 갈수록 의존성이 낮아집니다.)  즉 안드로이드를 예로들면 비즈니스 로직을 담당하는 ViewModel과 같은 코드들이 DB 또는 Web 같이 구체적인 세부 사항에 의존하지 않아야 합니다. 이를 통해 비즈니스 로직(고수준 정책)은 세부 사항들(저수준 정책)의 변경에 영향을 받지 않도록 할 수 있습니다.

 

이렇게 나눔으로써 얻는 이점들은 다음과 같습니다. 가장 중요한건 Testable과 유지보수 및 협업이라 볼 수 있겠습니다.

  • 코트 테스트 커버리지 증대
  • 쉽게 패키지 구조 탐색 가능
  • 집중화된 클래스에 따른 프로젝트 유지 관리 증대
  • 새 기능을 빠르게 적용 가능
  • 이후의 개발에도 안정적인 구현
  • 명확한 규율로 전반적으로 따라야 할 베스트 프랙티스

 

안드로이드 개발을 위한 더 이해하기 쉬운 클린아키텍처 구조 그림도가 있지만 뒤에서 살펴보도록 하고 먼저 오리지널 클린아키텍처의 각 계층의 역할에 대해 설명하면 다음과 같습니다.

 

1) Entities : 엔티티는 가장 일반적인 비즈니스 규칙을 캡슐화하고 DTO(Data Transfer Object)도 포함하는 전사적 비즈니스 규칙입니다. 외부가 변경되면 이러한 규칙이 변경 될 가능성이 가장 적습니다.

 

2) Use cases : 유스케이스는 Intereactor라고도 하며 소프트웨어의 애플리케이션 별 비즈니스 규칙을 나타냅니다.이 계층은 데이터베이스, 공통 프레임 워크 및 UI에 대한 변경으로부터 격리됩니다. 

 

3) Interface Adapters (Presenters) : 인터페이스 어댑터는 데이터를 Entity 및 UseCase의 편리한 형식(Format) 에서 데이터베이스 및 웹에 적용 할 수있는 형식으로 변환합니다. 이 계층에는 MVP의 Presenter, MVVM의 ViewModel 및 게이트웨이 (= Repositories)가 포함됩니다. 즉 순수한 비즈니스 로직만을 담당하는 역할을 하게 됩니다.

 

4) Frameworks & Drivers (Web, DB) : 프레임워크와 드라이버는 웹 프레임 워크, 데이터베이스, UI, HTTP 클라이언트 등으로 구성된 가장 바깥 쪽 계층입니다.

 

 

 

 

 

2-2. 안드로이드에서의 클린 아키텍처의 구조 및 특징

 

 

뭔진 알겠는데 그래도 햇갈리네..

 

 

위에서의 그림과 설명을 보고도 실제 안드로이드에서 사용하는 아키텍처 구조와 좀 다른 용어와 레이어 구조 때문에 햇갈릴 수 도 있을 겁니다. 예를들어 MVVM, MVP 같은 아키텍처를 주로 사용하는 안드로이드에서는 대부분 Entity 레이어 나누지 않고 Controller(인터페이스 어댑터) 등 직접적으로 접하지 않는 용어들이 사용되기 때문입니다. 또한 가장 바깥계층인  Frameworks & Drivers 에  DB, Web과 함께 UI 도 포함되어 있으므로 혼란을 일으킬 수 있습니다.

 

그래서 다음과 같이 안드로이드에 맞춘 이해하기 쉽게 그린 클린아키텍처 구조 그림들이 있습니다. 

 

 

 

MVC, MVP, MVVM 과 비교해서 그림을 보면 더 효과적일 것 같아 다른 아키텍처 사진과 입맛게 맞게 수정한 사진을 첨부했습니다 :)

 

 

MVC, MVP, MVVM 아키텍처https://qiita.com/koutalou/items/07a4f9cf51a2d13e4cdc
클린 아키텍처 https://qiita.com/koutalou/items/07a4f9cf51a2d13e4cdc
안드로이드를 위해 이해하기 쉽게 나타낸 클린아키텍처 구조
https://medium.com/android-dev-hacks/detailed-guide-on-android-clean-architecture-9eab262a9011 에 나오는 이미지를 수정하여 추가해봤습니다.

 

 

네 제가 봤을때는 처음 그림보다 한층 더 이해하기 쉬워진 것 같습니다. (저만 그럴걸수도...) 

안드로이드용으로 이해하기 쉽게 만들어진 클린아키텍처 구조는 Entity 레이러를 따로 두지않고 일반적으로 Presentation, Domain, Data 총 3개의 계층으로 크게 나눠지게 됩니다. 그리고 바로 위 그림을 보면 알 수 있듯이 Presentation -> Domain 방향으로 의존성이 있습니다.

 

1) Presentation : UI(Activity, Fragment), Presenter 및 ViewModel을 포함합니다. 즉 화면과 입력에 대한 처리 등 UI와 직접적으로 관련된 부분을 담당합니다. 또한 Presentation 레이어는 Domain과 Data 레이어를 포함하고 있다는 특징이 있습니다.

 

2) Domain : 애플리케이션의 비즈니스 로직을 포함하고 비즈니스 로직에서 필요한 Model 과 UseCase를 포함하고 있습니다. 기존 MVVM을 하고 클린아키텍처를 공부한다면 UseCase를 처음보실텐데 한번만 더 짚고 넘어가자면 각 개별 기능 또는 비즈니스 논리 단위라고 보시면 됩니다. 그래서 UseCase는 보통 한 개의 행동을 담당하고 UseCase의 이름만 보고 이게 무슨 기능을 가졌을지 짐작하고 구분할 수 있어야합니다.  추가로 Domain 레이어는 Presentation, Data 레이어와 어떤 의존성도 맺지 않고 독립적이다는 특징이 있습니다. 

 

3) Data : Repositoy 구현체, Cache, Room DB, Dao, Model 서버API(Retrofit2) 을 포함하고 있으며 로컬 또는 서버 API와 통신하여 데이터를 CRUD 하는 역할을 합니다. 또한 Mapper 클래스도 포함하고 있는데 DB로 부터 받아온 데이터모델과 UI에 맞는 데이터모델간의 변환을 해주는 역할을 합니다. 추가로 Domain 레이어를 포함하고있다는 특징이 있습니다.

 

이상 클린아키텍처의 각 계층이 어떻게 이루어지는지에 대해 알아봤습니다. !

 

 


 

3. 코드로 보자 (구현)

- 미흡한 부분이 많습니다. 양해부탁드립니다 :) 추후 공부후 업데이트 하겠습니다.

 

영화검색하는 토이프로젝트 앱에 공부한 내용들을 적용시켜 봤습니다.

 

1) 프로젝트 패키지 구조

 

클린아키텍처에서 말한대로 data, domain, presentation 3개의 계층으로 모듈화 했습니다.

 

 

 

 

그리고 계층간 의존성도 Presentation -> Data -> Domain 이 되기 위해 다음과 같이 app 수준의 build.gradle에 디펜던시를 추가해주었습니다.

 

[Presentation]

implementation project(':data')
implementation project(':domain')

[Data]

implementation project(':domain')

[Domain]

X

 

그리고 디펜던시에서는 각 계층에서 필요한 라이브러리만 디펜던시에 추가해주었습니다.

 

 

 

2) Presentation Layer

화면 조작 또는 사용자의 입력을 처리하기 위한 관심사를 모아 놓은 레이어 입니다. 

 

 

 

 

Koin 모듈과 Application 그리고 Activity, ViewModel 같은 뷰단의 코드로 구성되어 있으며 UI 갱신이나 사용자의 UI 입력과 관련된 로직만 처리하게 됩니다.

 

뷰와 관련된 코드인 Activity, VIewModel, Adapter 코드는 다음과 같으며 영화를 검색하는 화면입니다. 

 

[Activity]

package com.mtjin.presentation.views.search

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.lifecycle.Observer
import com.mtjin.presentation.base.BaseActivity
import com.mtjin.presentation.R
import com.mtjin.presentation.databinding.ActivityMovieSearchBinding
import org.koin.androidx.viewmodel.ext.android.viewModel


class MovieSearchActivity :
    BaseActivity<ActivityMovieSearchBinding>(R.layout.activity_movie_search) {
    private lateinit var movieAdapter: MovieAdapter
    private val viewModel: MovieSearchViewModel by viewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding.vm = viewModel
        initViewModelCallback()
        initAdapter()
    }

    private fun initAdapter() {
        movieAdapter = MovieAdapter { movie ->
            Intent(Intent.ACTION_VIEW, Uri.parse(movie.link)).takeIf {
                it.resolveActivity(packageManager) != null
            }?.run(this::startActivity)
        }
        binding.rvMovies.adapter = movieAdapter
    }

    private fun initViewModelCallback() {
        with(viewModel) {
            toastMsg.observe(this@MovieSearchActivity, Observer {
                when (toastMsg.value) {
                    MovieSearchViewModel.MessageSet.LAST_PAGE -> showToast(getString(R.string.last_page_msg))
                    MovieSearchViewModel.MessageSet.EMPTY_QUERY -> showToast(getString(R.string.search_input_query_msg))
                    MovieSearchViewModel.MessageSet.NETWORK_NOT_CONNECTED -> showToast(getString(R.string.network_error_msg))
                    MovieSearchViewModel.MessageSet.SUCCESS -> showToast(getString(R.string.load_movie_success_msg))
                    MovieSearchViewModel.MessageSet.NO_RESULT -> showToast(getString(R.string.no_movie_error_msg))
                    MovieSearchViewModel.MessageSet.ERROR -> showToast(getString(R.string.error_msg))
                    MovieSearchViewModel.MessageSet.LOCAL_SUCCESS -> showToast(getString(R.string.local_db_msg))
                }
            })
        }
    }
}

 

[Adapter]

package com.mtjin.presentation.views.search

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.mtjin.domain.model.search.Movie
import com.mtjin.presentation.R
import com.mtjin.presentation.databinding.ItemMovieBinding

class MovieAdapter(private val itemClick: (Movie) -> Unit) :
    ListAdapter<Movie, MovieAdapter.ViewHolder>(
        diffUtil
    ) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val binding: ItemMovieBinding = DataBindingUtil.inflate(
            LayoutInflater.from(parent.context),
            R.layout.item_movie,
            parent,
            false
        )
        return ViewHolder(binding).apply {
            binding.root.setOnClickListener { view ->
                val position = adapterPosition.takeIf { it != RecyclerView.NO_POSITION }
                    ?: return@setOnClickListener
                itemClick(getItem(position))
            }
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

    class ViewHolder(private val binding: ItemMovieBinding) :
        RecyclerView.ViewHolder(binding.root) {

        fun bind(movie: Movie) {
            binding.movie = movie
            binding.executePendingBindings()
        }
    }

    companion object {
        val diffUtil = object : DiffUtil.ItemCallback<Movie>() {
            override fun areContentsTheSame(oldItem: Movie, newItem: Movie) =
                oldItem == newItem

            override fun areItemsTheSame(oldItem: Movie, newItem: Movie) =
                oldItem.title == newItem.title
        }
    }
}

 

[ViewModel]

뷰모델에서는 뒤에서 살펴볼 Domain 계층의 UseCase를 사용합니다. UI와 사용자의 입력에 따른 비즈니스 로직 하나당 UseCase가 존재한다고 보시면 됩니다.

ViewModel 에서 UseCase 참조해야하기 때문에  Presentation Layer는 Domain Layer 에 의존성이 생기게 됩니다.

package com.mtjin.presentation.views.search

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.mtjin.domain.model.search.Movie
import com.mtjin.domain.usecase.GetLocalMoviesUseCase
import com.mtjin.domain.usecase.GetMoviesUseCase
import com.mtjin.domain.usecase.GetPagingMoviesUseCase
import com.mtjin.presentation.base.BaseViewModel
import com.mtjin.data.utils.LAST_PAGE
import com.mtjin.presentation.utils.NetworkManager
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers

open class MovieSearchViewModel(
    private val getMoviesUseCase: GetMoviesUseCase,
    private val getPagingMoviesUseCase: GetPagingMoviesUseCase,
    private val getLocalMoviesUseCase: GetLocalMoviesUseCase,
    private val networkManager: NetworkManager
) : BaseViewModel() {

    private var currentQuery: String = "" // 현재 검색어
    val query = MutableLiveData<String>() // 검색어(EditText two-way binding)
    private val _movieList = MutableLiveData<MutableList<Movie>>() // 영화리스트
    private val _toastMsg = MutableLiveData<MessageSet>() //검색결과 토스트 메시지

    val movieList: LiveData<MutableList<Movie>> get() = _movieList
    val toastMsg: LiveData<MessageSet> get() = _toastMsg


    // 영화검색 (15개)
    fun requestMovie() {
        currentQuery = query.value.toString().trim()
        if (currentQuery.isEmpty()) {
            _toastMsg.value = MessageSet.EMPTY_QUERY
            return
        }
        if (!checkNetworkState()) return //네트워크연결 유무
        compositeDisposable.add(
            getMoviesUseCase.execute(currentQuery)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnSubscribe { showProgress() }
                .doAfterTerminate { hideProgress() }
                .subscribe({ movies ->
                    if (movies.isEmpty()) {
                        _toastMsg.value = MessageSet.NO_RESULT
                    } else {
                        _movieList.value = movies as ArrayList<Movie>
                        _toastMsg.value = MessageSet.SUCCESS
                    }
                }, {
                    _toastMsg.value = MessageSet.ERROR
                })
        )
    }

    // 검색한 영화 더 불러오기(페이징, 무한스크롤)
    fun requestPagingMovie(offset: Int) {
        if (!checkNetworkState()) return //네트워크연결 유무
        compositeDisposable.add(
            getPagingMoviesUseCase.execute(currentQuery, offset)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnSubscribe { showProgress() }
                .doAfterTerminate { hideProgress() }
                .subscribe({ movies ->
                    val pagingMovieList = _movieList.value
                    pagingMovieList?.addAll(movies)
                    _movieList.value = pagingMovieList
                    _toastMsg.value = MessageSet.SUCCESS
                }, {
                    when (it.message) {
                        LAST_PAGE -> _toastMsg.value = MessageSet.LAST_PAGE
                        else -> _toastMsg.value = MessageSet.ERROR
                    }
                })
        )
    }

    private fun checkNetworkState(): Boolean {
        return if (networkManager.checkNetworkState()) {
            true
        } else {
            requestLocalMovies()
            false
        }
    }

    private fun requestLocalMovies() {
        compositeDisposable.add(
            getLocalMoviesUseCase.execute(currentQuery)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnSubscribe { showProgress() }
                .doAfterTerminate { hideProgress() }
                .subscribe({ movies ->
                    if (movies.isEmpty()) {
                        _toastMsg.value = MessageSet.NETWORK_NOT_CONNECTED
                    } else {
                        _movieList.value = movies as ArrayList<Movie>
                        _toastMsg.value = MessageSet.LOCAL_SUCCESS
                    }
                }, {
                    _toastMsg.value = MessageSet.NETWORK_NOT_CONNECTED
                })
        )
    }

    enum class MessageSet {
        LAST_PAGE,
        EMPTY_QUERY,
        NETWORK_NOT_CONNECTED,
        ERROR,
        SUCCESS,
        NO_RESULT,
        LOCAL_SUCCESS
    }

}

 

 

 

 

3) Data Layer

Domain에서 요청하거나 원하는 데이터를 서버 API, Local DB 와 통신하여 처리해주고 알맞게 반환해주는 역할을 합니다. 레포지토리 패턴으로 구현되어 있습니다. 

 

 

 

 

서버 API, 로컬 DB와의 통신을 담당하는 역할을 합니다. 그래서 Retrofit 관련 코드인 api 패키지Repository 패키지를 가지고 있고 Local DB(Room) 관련된 db 패키지도 가지고 있습니다.  또한 통신에 사용될 Entity Model 패키지를 가지고 있으며 Data <-> Domain 간의 Model 데이터 변환을 담당하는 Mapper 도 가지고 있습니다.

Data Layer는 Domain의 Model을 알아야할 Mapper와 Domain의 Repository 인터페이스 구현을 위해 Domain Layer에 의존성이 생기게 됩니다.

 

Mapper 코드 부터 살펴보면 다음과 같습니다. Data 모델과 Domain, Presentation에 맞는 모델간의 변환을 담당합니다. 

package com.mtjin.data.mapper

import com.mtjin.data.model.search.MovieEntity
import com.mtjin.domain.model.search.Movie

fun mapperToMovie(movies: List<MovieEntity>): List<Movie> {
    return movies.toList().map {
        Movie(
            it.actor,
            it.director,
            it.image,
            it.link,
            it.pubDate,
            it.subtitle,
            it.title,
            it.userRating
        )
    }
}

// 이 프로젝트에서는 Domain -> Data 레이어로 모델클래스를 매개변수로 전송하는 일이 없어서 사용은안한다
fun mapperToMovieEntity(movies: List<Movie>): List<MovieEntity> {
    return movies.toList().map {
        MovieEntity(
            it.actor,
            it.director,
            it.image,
            it.link,
            it.pubDate,
            it.subtitle,
            it.title,
            it.userRating
        )
    }
}

 

이제 Repository 부분을 살펴보겠습니다.

저는 Repositoy 쪽을  Local(Room) , Remote(Sever API) 로 하였는데 Cache 까지 있기도 합니다. 처음에 다음과 같이 Cache도 사용하도록 구현했는데 로직만 더 어려워지고 이 프로젝트에서는 득보다 실이 더 많다고 생각하여 제거하게 되었습니다.

// 이 프로젝트에서는 득보다 실이 더많은 것 같아 결국 제거했다
class MovieCacheDataSourceImpl : MovieCacheDataSource {
    private var movieList = ArrayList<Movie>()

    override fun getMoviesFromCache(): List<Movie> {
        return movieList
    }

    override fun saveMoviesToCache(movies: List<Movie>) {
        movieList.clear()
        movieList = ArrayList(movies)
    }
}

 

Repository 쪽 코드는 다음과 같습니다. 

 

[Repository]

local 과 remote를 분리 및 조합시켜 알맞게 로직을 처리합니다. Local 과 Remote 로 부터 concat으로 순서대로 데이터를 가져오게 됩니다.

package com.mtjin.data.repository.search

import com.mtjin.data.mapper.mapperToMovie
import com.mtjin.data.repository.search.local.MovieLocalDataSource
import com.mtjin.data.repository.search.remote.MovieRemoteDataSource
import com.mtjin.data.utils.LAST_PAGE
import com.mtjin.data.utils.NO_DATA_FROM_LOCAL_DB
import com.mtjin.domain.model.search.Movie
import com.mtjin.domain.repository.MovieRepository
import io.reactivex.Flowable
import io.reactivex.Single

class MovieRepositoryImpl(
    private val movieRemoteDataSource: MovieRemoteDataSource,
    private val movieLocalDataSource: MovieLocalDataSource
) : MovieRepository {

    //첫 영화검색
    override fun getSearchMovies(query: String): Flowable<List<Movie>> {
        return movieLocalDataSource.getSearchMovies(query)
            .onErrorReturn { listOf() }
            .flatMapPublisher { localMovies ->
                if (localMovies.isEmpty()) {
                    getRemoteSearchMovies(query)
                        .toFlowable()
                        .onErrorReturn { listOf() }
                } else {
                    val local = Single.just(mapperToMovie(localMovies)) // 로컬 DB
                    val remote = getRemoteSearchMovies(query) // 서버 API
                        .onErrorResumeNext { local }
                    Single.concat(local, remote) // 순서대로 불러옴
                }
            }
    }

    //인터넷이 끊킨 경우 로컬디비에서 검색
    override fun getLocalSearchMovies(query: String): Flowable<List<Movie>> {
        return movieLocalDataSource.getSearchMovies(query)
            .onErrorReturn { listOf() }
            .flatMapPublisher { cachedMovies ->
                if (cachedMovies.isEmpty()) {
                    Flowable.error(IllegalStateException(NO_DATA_FROM_LOCAL_DB))
                } else {
                    Flowable.just(mapperToMovie(cachedMovies))
                }
            }
    }

    // 서버 DB 영화검색 요청
    override fun getRemoteSearchMovies(
        query: String
    ): Single<List<Movie>> {
        return movieRemoteDataSource.getSearchMovies(query)
            .flatMap {
                movieLocalDataSource.insertMovies(it.movies)
                    .andThen(Single.just(mapperToMovie(it.movies)))
            }
    }

    //영화 검색 후 스크롤 내리면 영화 더 불러오기
    override fun getPagingMovies(
        query: String,
        offset: Int
    ): Single<List<Movie>> {
        return movieRemoteDataSource.getSearchMovies(query, offset).flatMap {
            if (it.movies.isEmpty()) {
                Single.error(IllegalStateException(LAST_PAGE))
            } else {
                if (offset != it.start) {
                    Single.error(IllegalStateException(LAST_PAGE))
                } else {
                    movieLocalDataSource.insertMovies(it.movies)
                        .andThen(Single.just(mapperToMovie(it.movies)))
                }
            }
        }
    }

}

 

[Local]

Room DB와의 로직 담당

package com.mtjin.data.repository.search.local

import com.mtjin.data.db.MovieDao
import com.mtjin.data.model.search.MovieEntity
import io.reactivex.Completable
import io.reactivex.Single

class MovieLocalDataSourceImpl(private val movieDao: MovieDao) : MovieLocalDataSource {
    override fun insertMovies(movies: List<MovieEntity>): Completable =
        movieDao.insertMovies(movies)

    override fun getAllMovies(): Single<List<MovieEntity>> = movieDao.getAllMovies()

    override fun getSearchMovies(title: String): Single<List<MovieEntity>> =
        movieDao.getMoviesByTitle(title)

    override fun deleteAllMovies(): Completable = movieDao.deleteAllMovies()

}

 

 

[Remote]

서버 API와의 통신담당

package com.mtjin.data.repository.search.remote

import com.mtjin.data.api.ApiInterface
import com.mtjin.data.model.search.MovieResponse
import io.reactivex.Single


class MovieRemoteDataSourceImpl(private val apiInterface: ApiInterface) :
    MovieRemoteDataSource {
    override fun getSearchMovies(query: String, start: Int): Single<MovieResponse> {
        return apiInterface.getSearchMovie(query, start)
    }
}

 

 

[Model]

마지막으로 Model 은 다음과 같으며 API, Room 과 엮여있습니다

@Entity(tableName = "movie")
data class MovieEntity(
    @SerializedName("actor")
    val actor: String,
    @SerializedName("director")
    val director: String,
    @SerializedName("image")
    val image: String,
    @SerializedName("link")
    val link: String,
    @SerializedName("pubDate")
    val pubDate: String,
    @SerializedName("subtitle")
    val subtitle: String,
    @PrimaryKey(autoGenerate = false)
    @SerializedName("title")
    val title: String,
    @SerializedName("userRating")
    val userRating: String
)
package com.mtjin.data.model.search


import com.google.gson.annotations.SerializedName

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

 

 

 

 

3) Domain Layer

마지막으로 누구와도 의존성을 이루지 않는 독립적인 Domain Layer 입니다.  비즈니스와 관련된 로직을 담당합니다.

 

 

 

 

앱에서 사용할 Model과 각각의 비즈니스 로직 단위를 나타내는 UseCase,  UseCase의 실질적인 구현을 담당하게 할 Repository 인터페이스로 구성이 되어있습니다. 

 

코드와 함께 살펴보겠습니다.

 

[UseCase]

Usecase란 1개 이상의 Repository를 받아 비즈니스 로직을 처리하며 하나의 유저 행동에 대한 비즈니스 로직을 가지고 있는 객체입니다. 보통 하나의 UseCase 는 하나의 로직을 담당하게끔 한다고 합니다. 그래서 UseCase의 이름만 보고도 무엇을 할지 알게끔 네이밍을 잘 지어야하며 이는 코드를 이해하는데 큰 도움이 됩니다. 하지만 이로 인해 많은 UseCase 클래스가 생긴다는 단점도 있긴 합니다.

공부하면서 이 하나의 로직당 UseCase를 지어야하는지 아니면 좀 비슷한건 묶어야되는지에 대한것과 네이밍을 어떻게할지에 대해 혼란스러운 점이 많았던 것 같습니다. 일단 하나의 로직만 갖게끔 구현했습니다.

package com.mtjin.domain.usecase

import com.mtjin.domain.model.search.Movie
import com.mtjin.domain.repository.MovieRepository
import io.reactivex.Flowable

//로컬DB에서 영화검색목록 가져오기
class GetLocalMoviesUseCase(private val repository: MovieRepository) {
    fun execute(
        query: String
    ): Flowable<List<Movie>> = repository.getSearchMovies(query)

}
package com.mtjin.domain.usecase

import com.mtjin.domain.model.search.Movie
import com.mtjin.domain.repository.MovieRepository
import io.reactivex.Flowable

//검색영화목록 가져오기 (Local + Remote)
class GetMoviesUseCase(private val repository: MovieRepository) {
    fun execute(
        query: String
    ): Flowable<List<Movie>> = repository.getSearchMovies(query)

}
package com.mtjin.domain.usecase

import com.mtjin.domain.model.search.Movie
import com.mtjin.domain.repository.MovieRepository
import io.reactivex.Single

// 검색영화목록 더 가져오기 (페이징)
class GetPagingMoviesUseCase(private val repository: MovieRepository) {
    fun execute(
        query: String,
        offset: Int
    ): Single<List<Movie>> = repository.getPagingMovies(query, offset)
}

 

 

 

[Model]

Data Layer와 Model과의 차이점은 Room, Retrofit 등과 연관되지 않는 순수 data class 이며 실제 필요한 데이터만 가지고 있습니다.

package com.mtjin.domain.model.search


data class Movie(
    val actor: String,
    val director: String,
    val image: String,
    val link: String,
    val pubDate: String,
    val subtitle: String,
    val title: String,
    val userRating: String
)

 

 

 

[Repository 인터페이스]

UseCase에서 사용할 레포지토리를 가지고 있습니다. 실제 구현체는 Data Layer에 있으며 Domain Layer는 모릅니다.

package com.mtjin.domain.repository

import com.mtjin.domain.model.search.Movie
import io.reactivex.Flowable
import io.reactivex.Single

interface MovieRepository {
    fun getSearchMovies(
        query: String
    ): Flowable<List<Movie>>

    fun getLocalSearchMovies(
        query: String
    ): Flowable<List<Movie>>

    fun getRemoteSearchMovies(
        query: String
    ): Single<List<Movie>>

    fun getPagingMovies(
        query: String,
        offset: Int
    ): Single<List<Movie>>
}

 

 

 

이상 안드로이드 클린 아키텍처에 대해 간략히 정리해봤고 3일 동안 많은 블로그들을 참고하면서 공부하고 구현해봤는데 여전히 어렵네요. 구현도 해보긴 했지만 부족하고 틀린부분이 많다고 생각합니다. 양해부탁드립니다.ㅇㅅㅇ   

글에서 부족한 부분들은 참고 및 출처나 구글링에서 자세하게 알 수 있으니 참고해주시면 감사하겠습니다. :)

당분간은 Udemy 강의를 들으면서 기본기를 A부터 Z까지 쌓을 예정이고 이 강의에서도 나중에 클린아키텍처 내용이 들어있기 때문에 이해와 구현하는데 큰 도움이 될 것 같습니다. 이후에 실력 더 쌓고 내용과 코드를 수정해보도록 하겠습니다. 

 

P.S 이 토이 프로젝트에서는 에러코드를 그냥 kt파일 const val 로 코드를 나누어 해결했는데 원래 같으면 result나 code 같은 값을 가진 BaseResponse 같은 data class 파일을 만들어 깔끔하게 분기를 나누거나 Exception 클래스를 상속받은 클래스들을 받아서 통신 응답 및 예외 처리를 하면 좋으나 토이프로젝트이기 때문에 간단히 상수로 해결한점 양해부탁드립니다.

 

 

댓글과 공감은 큰 힘이 됩니다. 감사합니다. !!

 

 

 

[시연영상]

 

 

 


[2022-02-01]

Hilt를 추가한 MVVM + Hilt + RxJava2 + Multi Module 클린아키텍처 프로젝트 예시를 추가로 만들었습니다. 예전에 한 프로젝트를 구조만 바꾼거라 부족한 면이 많은 점 양해부탁드립니다. ㅎㅎ

(Main , clean-mvvm-multi-module-rxjava-hilt  브랜치 참고) 

https://github.com/mtjin/mtjin-android-clean-architecture-movieapp/tree/clean-mvvm-multi-module-rxjava-hilt

 

GitHub - mtjin/mtjin-android-clean-architecture-movieapp: Clean Architecture 학습 및 구현(MVVM, RxJava2, Hilt, Koin, Dagger

Clean Architecture 학습 및 구현(MVVM, RxJava2, Hilt, Koin, Dagger2, Jetpack Lib) - GitHub - mtjin/mtjin-android-clean-architecture-movieapp: Clean Architecture 학습 및 구현(MVVM, RxJava2, Hilt, Koin, Dagger2...

github.com

 

 

 

 


참고 및 출처:

 

dunkey2615.tistory.com/176

 

[Android] Clean Architecture

변화 외에 영원한 것은 없다, 피할 수 없으면 즐겨라 좋은 아키텍쳐란 가능한 정보를 최대한 많이 가지고 있으면서도 중요한 결정을 미루는게 가능한 구조 좋은 코드의 구조가 좋은 제품으로

dunkey2615.tistory.com

namget.tistory.com/entry/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-Clean-Architecture

 

[안드로이드] Clean Architecture

소개 안녕하세요 YTS 입니다. 오늘은 많이 부족하지만 Clean Architecture라는 주제를 가지고 글을 한번 적으려고 합니다. Clean Code! 우선 Clean Code란 무엇일까? 결국 원작자의 의도가 무엇이며 코드를

namget.tistory.com

academy.realm.io/kr/posts/converting-an-app-to-use-clean-architecture/

 

클린 아키텍처를 안드로이드에 도입하는 방법

클린 아키텍처가 무엇인지, 왜 도입해야 하는지, 어떻게 후회하지 않을지 설명합니다.

academy.realm.io

 

rubygarage.org/blog/clean-android-architecture

 

Clean Architecture of Android Apps with Practical Examples

In this article, we review the concept of Clean Architecture for Android applications. In addition to code examples, we also give our verdict on the vital question: Is Clean Architecture the silver bullet for all development challenges?

rubygarage.org

juyeop.tistory.com/25

 

Clean Architecture 아키텍처 패턴

안녕하세요, 오늘은 이전보다 조금 더 어려운 내용인 소프트웨어 아키텍처 패턴을 알아보려고합니다. 수많은 종류의 소프트웨어 아키텍처 패턴 중 오늘은 Clean Architecture 패턴에 대해서 꼼꼼히

juyeop.tistory.com

medium.com/@justfaceit/clean-architecture%EB%8A%94-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EA%B0%9C%EB%B0%9C%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%EB%8F%84%EC%99%80%EC%A3%BC%EB%8A%94%EA%B0%80-1-%EA%B2%BD%EA%B3%84%EC%84%A0-%EA%B3%84%EC%B8%B5%EC%9D%84-%EC%A0%95%EC%9D%98%ED%95%B4%EC%A4%80%EB%8B%A4-b77496744616

 

Clean Architecture는 모바일 개발을 어떻게 도와주는가? - (1) 경계선: 계층 나누기

How Clean Architecture Assists Mobile Development - Part 1. Boudaries: Defining Layers

medium.com

woowabros.github.io/tools/2019/10/02/clean-architecture-experience.html

 

주니어 개발자의 클린 아키텍처 맛보기 - 우아한형제들 기술 블로그

안녕하세요 딜리버리플랫폼팀 송인태입니다.

woowabros.github.io

woowabros.github.io/experience/2019/01/17/baeminapp-clean-architecture.html

 

클린 아키텍처와 함께하는 배민앱 (Android) - 우아한형제들 기술 블로그

배민마켓 개발하기

woowabros.github.io

medium.com/android-dev-hacks/detailed-guide-on-android-clean-architecture-9eab262a9011

 

Detailed Guide on Android Clean Architecture

Best way to write Android Apps

medium.com

black-jin0427.tistory.com/225

 

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

안녕하세요. 블랙진입니다. 안드로이드 아키텍처에 관한 순차적인 포스팅을 진행하고자 합니다. 이전에 깃허브 API를 사용한 아키텍처를 포스팅 했었지만 클린아키텍처를 공부하면서 Naver API를

black-jin0427.tistory.com

https://juyeop.tistory.com/29

 

Android Studio 레이어 분리 방법에 대해서

안녕하세요, 오늘은 이전에 배운 클린 아키텍처를 바탕으로 실제로 안드로이드 스튜디오에서 앱 개발을 할 수 있도록 개발 환경을 구성해보겠습니다. 오늘 배운 내용은 앞으로도 많이 사용되고

juyeop.tistory.com

등 많이 참고하였습니다. 감사합니다

 

 

728x90
Comments