관리 메뉴

나만을 위한 블로그

Repository 패턴이란? 본문

개인 공부/디자인 패턴

Repository 패턴이란?

참깨빵위에참깨빵 2023. 7. 17. 23:15
728x90
반응형

안드로이드 디벨로퍼 공식문서 중 앱 아키텍처 가이드라는 페이지를 보면 아래와 같은 그림을 제시하고 있다.

 

https://developer.android.com/jetpack/guide?hl=ko 

 

앱 아키텍처 가이드  |  Android 개발자  |  Android Developers

앱 아키텍처 가이드 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 가이드에는 고품질의 강력한 앱을 빌드하기 위한 권장사항 및 권장 아키텍처가 포함

developer.android.com

 

 

기본 뼈대는 MVVM이지만 뷰모델과 Model, Remote Data Source 사이에 Repository(저장소)라는 레이어(이하 계층)가 존재한다.

MVVM이라면 View, ViewModel, Model 3가지 계층이 있어야 할 텐데, Repository는 왜 포함돼 있는 것인가?

 

안드로이드 코드랩 중 저장소 패턴을 다루는 코드랩에 이와 관련한 설명이 작성돼 있다.

 

https://developer.android.com/codelabs/basic-android-kotlin-training-repository-pattern?hl=ko#3 

 

저장소 패턴  |  Android Developers

저장소 패턴을 사용하여 기존 앱에서 캐싱을 구현합니다.

developer.android.com

저장소 패턴은 데이터 레이어를 앱의 나머지 부분에서 분리하는 디자인 패턴이다. 데이터 레이어는 UI와 별도로 앱의 데이터, 비즈니스 로직을 처리하는 앱 부분을 나타내며 나머지 앱에서 이 데이터에 접근할 수 있도록 일관된 API를 노출한다. UI가 유저에게 정보를 제공하는 동안 레이어에는 네트워킹 코드, Room DB, 오류 처리, 데이터를 읽거나 조작하는 코드 등이 포함된다

저장소는 데이터 소스(영구 모델, 웹 서비스, 캐시) 간의 충돌을 해결하고 이 데이터의 변경사항을 중앙 집중화할 수 있다...(중략)

< 저장소 사용의 이점 >

저장소 모듈은 데이터 작업을 처리하고 여러 백엔드 사용을 허용한다. 일반적인 실제 앱에서 저장소는 네트워크에서 데이터를 가져올지 아니면 로컬 DB에 캐시된 결과를 사용할지 결정하는 로직을 구현한다. 저장소를 사용하면 뷰모델 같은 호출 코드에 영향을 주지 않고 다른 지속성 라이브러리로의 이전 같은 구현 세부정보를 교체할 수 있다. 이는 코드를 모듈식으로 테스트 가능하게 만드는 데도 도움이 된다

저장소는 앱 데이터의 특정 부분에 관한 단일 정보 소스 역할을 해야 한다. 네트워크 리소스와 오프라인 캐시 등 여러 데이터 소스로 작업할 때 저장소는 앱 데이터가 최대한 정확하고 최신 상태로 유지되도록 하므로 앱이 오프라인 상태일 때도 최상의 환경을 제공한다...(중략)

 

정리하면 Repository 사용 시 특장점은

 

  • 네트워크 또는 로컬 DB의 데이터를 가져오고, 필요 시 조작하는 비즈니스 로직을 갖고 있다
  • Repository 사용 시 뷰모델에 영향을 주지 않기 때문에, 뷰모델을 신경쓰지 않고 구현 세부로직을 바꿀 수 있다
  • Testable한 모듈을 만들 수 있다

 

위 3가지라고 정리할 수 있겠다. 하지만 이것이 전부인가? 생각하면 뭔가 더 있을 것 같다.

다른 곳에선 뭐라고 설명하는지 확인했다.

 

https://medium.com/swlh/repository-pattern-in-android-c31d0268118c

 

Repository Pattern in Android

In-depth guide about the implementation of repository pattern in Android MVVM architecture.

medium.com

(중략)...MVVM에는 View, ViewModel, Model 3가지 주요 구성 요소가 있다...(중략)...그러나 문제는 이 아키텍처 패턴을 올바르게 구현하더라도, 특히 로컬 또는 원격 DB에서 데이터에 접근해야 하는 경우 일부 코드 중복이 여전히 나타날 수 있다는 것이다. 예를 들어 Retrofit을 써서 원격 DB에서 데이터를 가져와야 한다. 뷰모델에서 구현하려면 아래 코드가 필요하다
val book = liveData {
    val bookDAO = Retrofit.getClient().create(BookDAO::class.java)
    val book = bookDAO.getBook()
    emit(book)
}
이 책의 LiveData 변수는 나중에 View에서 관찰(observe)된다. 그러나 원격 DB에서 일부 리소스에 접근해야 하는 경우 DAO를 설정하려면 Retrofit 보일러 플레이트 코드가 필요하다. 이것은 데이터 접근의 필요성을 단순화하고 MVVM 아키텍처 패턴에서 데이터 접근을 중앙 집중화하기 위해 수정돼야 할 문제다

< 단일 진실 공급원(Single Source Of Truth, SSOT) >

데이터 접근을 중앙 집중화하려면 SSOT가 필요하다. 기본적으로 데이터 접근을 하나의 클래스로 중앙 집중화하는 게 관행이다. 책 데이터에 접근해야 하는 경우 책과 관련된 모든 데이터 접근을 단일 클래스로 중앙 집중화해야 다른 클래스(클라이언트)가 "진실 소스(source of truth)"에 요청해서 책 데이터를 쉽게 가져올 수 있다...(중략)

앱에 SSOT를 적용하려면 Repository 패턴을 구현해야 한다. Repository 패턴은 디자인 패턴 중 하나다. 이 패턴은 객체를 유지하며 해당 객체가 기본 DB에서 실제로 유지되는 방법을 알아야 한다. 즉 데이터 지속성이 어떻게 발생하는지 신경쓰지 않아도 된다. 이 지속성에 대한 지식, 즉 지속성 논리는 Repository라는 클래스 안에 캡슐화된다

" Repository 패턴은 앱에서 데이터 지속성 논리를 추상화해서 관심사 분리(separation of concerns)를 구현한다 "

본질적으로 Repository 패턴은 데이터 지속성이 실제로 발생하는 방법에 대한 지식이 없어도 앱에서 비즈니스 로직과 데이터 접근 계층의 분리를 용이하게 한다

Repository 패턴을 사용하면 데이터 저장소에서 데이터를 저장, 검색하는 방법에 대한 세부 정보를 숨길 수 있다. 이 데이터 저장소는 DB, XML 파일 등이 될 수 있다. 이 패턴을 적용해 웹 서비스 또는 ORM에 의해 노출된 데이터에 접근하는 방법을 숨길 수도 있다...(중략)...Repository는 메모리에 상주하는(reside in the memory) 도메인 객체 모음으로 정의된다

 

https://psid23.medium.com/repository-pattern-for-data-access-in-software-development-4c10aa9604da

 

Repository Pattern for Data Access in Software Development

Repository design pattern as defined by Martin Fowler isolates your domain from caring about how storage is implemented so all objects retrieved can be treated like an in-memory collection. You could…

psid23.medium.com

마틴 파울러가 정의한 Repository 패턴은 검색된 모든 객체가 메모리 안의 컬렉션처럼 처리될 수 있게 스토리지 구현법에 대한 관심에서 도메인을 분리한다. DB, XML, 텍스트 문서, 웹 서비스 등을 기반으로 하는 Repository가 있을 수 있다. 애플리케이션 코드 자체는 신경쓰지 않는다...(중략)

아래의 흐름을 생각해보라

코드를 단위 테스트하려는 경우 데이터 접근 계층에 밀접하게 연결되어 있는 걸 볼 수 있다. 지속성 저장소(persistence store)가 변경되면 데이터 접근 계층의 코드가 바뀌고 이를 참조하는 코드도 변경된다. 이 긴밀한 결합 문제를 해결하기 위해 코드와 데이터 접근 계층 사이에 Repository 계층이 도입됐다. 이제 코드는 Repository 계층과 상호작용하고 데이터 접근 계층에서 분리된다. Repository 패턴을 구현하면 아래처럼 될 것이다

Repository 패턴은 데이터 접근 계층에 파사드를 배치해서 지속성이 작동하는 방식을 알 필요가 없도록 애플리케이션 코드의 나머지 부분을 보호할 수 있다. 코드를 단위 테스트할 경우 Repository 추상화를 통해 더 쉽게 수행할 수 있다. 가짜 데이터를 반환하는 mock repository를 구현할 수 있는 것이다...(중략)

 

Repository 패턴이 파사드(퍼사드) 패턴이라고 한다. 정확히는 파사드 패턴의 일종인 패턴이 Repository 패턴인 것이다.

왜냐면 파사드 패턴은 본질적으로 복잡성을 숨기기 위해 단순화된 인터페이스를 제공하는 디자인 패턴이기 때문이다. 특징만 보면 Repository 패턴과 비슷한데, 파사드 패턴이 Repository 패턴보다 좀 더 일반적인 특징을 갖는다.

 

그럼 안드로이드에선 어떻게 MVVM 패턴 위에 Repository 패턴을 얹어서 구현할 수 있을까?

예시로 깃허브 API를 안드로이드에서 호출하는 경우를 들어 본다.

 

interface GithubService {
    @GET("search/repositories")
    suspend fun getRepositories(
        @Query("q") query: String
    ): Response<GithubData>
}

 

보통 레트로핏으로 서버와 네트워킹하기 때문에 인터페이스에 위와 같은 함수를 작성하기 마련이다.

만약 Repository가 없다면 이 인터페이스를 곧바로 뷰모델에서 호출하는 형태가 될 것이다. 그러나 중간에 Repository가 낀다면 Repository를 아래와 같이 구성할 수 있다.

 

class GithubRepository: BaseFlowResponse() {
    private val githubClient = ApiService.client

    suspend fun getRepositories(queryString: String): Flow<ApiState<GithubData>> = flow {
        emit(flowCall { githubClient.getRepositories(queryString) })
    }.flowOn(Dispatchers.IO)

    suspend fun test(query: String): Flow<ApiState<GithubData>> = flow {
        try {
            val response = githubClient.getRepositories(query)
            if (response.isSuccessful) {
                response.body()?.let {
                    emit(ApiState.Success(it))
                }
            } else {
                try {
                    emit(ApiState.Error(response.errorBody()!!.string()))
                }   catch (e: IOException) {
                    e.printStackTrace()
                }
            }
        }   catch (e: Exception) {
            emit(ApiState.Error(e.message ?: ""))
        } as Unit
    }.flowOn(Dispatchers.IO)
}

 

ApiService는 base url을 갖고 레트로핏 객체를 만드는 싱글톤 객체를 가진 object class다.

그리고 getRepositories(), test() 2가지 함수를 볼 수 있다. 여기서 필요하다면 map, filter 같은 스트림 함수를 써서 내가 사용하고 싶은 데이터만 따로 추출 / 가공해서 뷰모델로 넘길 수 있고, 아니라면 getRepositories()처럼 서버에서 받은 값을 Flow 형태로 곧바로 리턴할 수 있다.

뷰모델은 아래와 같이 작성할 수 있다.

 

class GithubViewModel(
    private val githubRepository: GithubRepository
): ViewModel() {
    var mGithubRepositories: MutableStateFlow<ApiState<GithubData>> = MutableStateFlow(ApiState.Loading())
    var githubRepositories: StateFlow<ApiState<GithubData>> = mGithubRepositories

    fun requestGithubRepositories(query: String) = viewModelScope.launch {
        mGithubRepositories.value = ApiState.Loading()
        githubRepository.getRepositories(query)
            .catch { error ->
                mGithubRepositories.value = ApiState.Error("${error.message}")
            }
            .collect { values ->
                mGithubRepositories.value = values
            }
    }

    fun test(query: String) = viewModelScope.launch {
        mGithubRepositories.value = ApiState.Loading()
        githubRepository.test(query)
            .catch { error ->
                mGithubRepositories.value = ApiState.Error("${error.message}")
            }
            .collect { values ->
                mGithubRepositories.value = values
            }
    }
}

 

GithubRepository의 getRepositories()를 호출한 다음, catch {}로 예외가 발생한 경우의 처리(여기선 그냥 LiveData에 값을 넣기만 한다), collect {}로 Repository가 전달한 값을 MutableStateFlow에 담는다.

이 MutableStateFlow의 값은 곧장 StateFlow 참조변수 안으로 들어간다. 즉 값의 변경이 발생한다. 그러면 액티비티 또는 프래그먼트에선 이 StateFlow 참조변수의 값을 관찰하고 있다가, 받은 데이터가 ApiState sealed class의 타입 중 무엇에 속하느냐에 따라 각각 다른 처리를 한다. 로딩 바를 띄운다던가, 에러 팝업 또는 토스트를 띄운다던가, 호출에 성공해서 값들을 정상적으로 받았다면 데이터 바인딩 등을 통해 리사이클러뷰 또는 뷰에 세팅하고, 사용자는 그 뷰를 볼 수 있게 되는 식이다.

 

만약 프로젝트에 hilt를 적용했다면 아래와 같이 Repository를 구성할 수 있다.

 

import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class NewsRepository @Inject constructor(
    private val commonService: NewsService
) : BaseDataSource() {
    suspend fun getTopHeadlines(page: Int, pageSize: Int) = getResult {
        commonService.getTopHeadlines(page, pageSize)
    }

    suspend fun getNewsList(page: Int, pageSize: Int) = getResult {
        commonService.getNewsList(page, pageSize)
    }

}

 

@Singleton의 영향으로 NewsRepository의 인스턴스는 앱 내에서 하나만 존재하게 된다. 또한 뷰모델의 인자로 넘김으로써 여러 뷰모델에서 하나의 NewsRepository 인스턴스를 사용해 이 안의 함수들을 사용할 수 있게 된다.

뷰모델은 아래와 같이 만들 수 있다.

 

const val PAGE_SIZE = 10

@HiltViewModel
class MainViewModel @Inject constructor(private val commonDao: NewsRepository) : ViewModel() {

    private var newsResponse: ArticleResponse? = null
    private val _newsList: MutableLiveData<ApiResponse<List<Article>>> = MutableLiveData()
    val newsList: LiveData<ApiResponse<List<Article>>> = _newsList

    private val _headline: MutableLiveData<ApiResponse<Article>> = MutableLiveData()
    val headline: LiveData<ApiResponse<Article>> = _headline

    fun getNewsList(newsPage: Int) {
        viewModelScope.launch {
            _newsList.value = ApiResponse.loading()
            _newsList.value = handleNewsListResponse(commonDao.getNewsList(newsPage, PAGE_SIZE))
        }
    }

    private fun handleNewsListResponse(response: ApiResponse<ArticleResponse>): ApiResponse<List<Article>> {
        if (response.status == Status.SUCCESS) {
            response.data?.let {
                if (newsResponse == null) {
                    newsResponse = it
                } else {
                    val oldNews = newsResponse?.articles
                    val newNews = it.articles
                    oldNews?.addAll(newNews.orEmpty())
                }
                return ApiResponse.success(it.articles.orEmpty())
            }
        }
        return ApiResponse.error(response.throwable!!)
    }

    fun getHeadlines() {
        viewModelScope.launch {
            _headline.value = ApiResponse.loading()
            _headline.value = handleHeadlineResponse(commonDao.getTopHeadlines(1, PAGE_SIZE))
        }
    }

    private fun handleHeadlineResponse(response: ApiResponse<ArticleResponse>): ApiResponse<Article> {
        if (response.status == Status.SUCCESS) {
            response.data?.let { it ->
                if (!it.articles.isNullOrEmpty()) {
                    val sortedList = it.articles?.sortedByDescending { article -> article._publishedAt }
                    return ApiResponse.success(sortedList?.get(0)!!)
                }
            }
        }
        return ApiResponse.error(response.throwable!!)
    }
}

 

뷰모델이기 때문에 @HiltViewModel을 붙이고, constructor() 안에 NewsRepository를 넣어서 뷰모델이 초기화될 때 NewsRepository의 인스턴스를 같이 얻어온다.

그리고 getNewsList() 안에서 commonDao라는 NewsRepository 참조변수를 통해 NewsRepository의 getNewsList()를 호출하고, 여기서 반환되는 값을 handleNewsListResponse()의 매개변수로 넘겨 반환값에 딸느 적절한 상태를 리턴하게 된다. 뷰에 적용하는 것은 위의 StateFlow와 비교해서 사용하는 함수만 다를 뿐이고 그 외에는 특별할 게 없다.

위의 코드들은 대충 작성한 예제 수준의 코드기 때문에, 실제로 사용할 때는 API의 리턴값과 상태에 따라 적절하게 예외처리하고 뷰에 바인딩시켜야 한다.

 

 

참고한 사이트)

 

https://refactoring.guru/ko/design-patterns/facade

 

퍼사드 패턴

/ 디자인 패턴들 / 구조 패턴 퍼사드 패턴 다음 이름으로도 불립니다: Facade 의도 퍼사드 패턴은 라이브러리에 대한, 프레임워크에 대한 또는 다른 클래스들의 복잡한 집합에 대한 단순화된 인터

refactoring.guru

 

반응형
Comments