앱을 잘 만들기 위해서는 어떤 요소들이 필요할까? 잘 설계된 구조, 그리고 좋은 사용자 경험을 기반으로 한 UI, 그 UI에 보여질 데이터 등의 요소들이 필요하다. 그 중 오늘은 데이터 관점에서 살펴보려고 한다. UI에 보여줄 데이터들은 서버에서 가져오기도 하고, 내부 저장소에서 불러오기도 한다.
초기에는 간단하게 네트워크 요청만으로도 데이터를 처리하더라도 문제가 없어 보인다. 하지만 기능이 늘어나면서 데이터 관리 또한 복잡해진다. 네트워크 연결이 안되는 상황에서도 사용하려면, 캐싱이나 로컬 저장소의 필요성이 생기고 점점 복잡해진다. 이때 ViewModel이나 UI(Activity,Fragment)에서 직접 여러 데이터에 접근하게 되면 코드의 재사용성은 떨어지고 동시에 테스트 및 유지보수도 쉽지 않다.
이러한 문제점을 해결하기 위해 바로 Repository 패턴이 등장했다. Repositroy 패턴을 사용하면 여러 데이터 소스를 추상화 할 수 있고, 데이터의 출처와 무관하게 일관된 방식으로 데이터를 사용할 수 있다.
이 글에서는 Repository 패턴이 Android 앱 개발에서 왜 중요하고, 어떤 구조로 설계되는지 알아보려 한다.
Repository 패턴을 이해하기 전에, 데이터 소스가 무엇인지 알아야 한다. 이미 Data Source에 대해서 알고 있는 독자는 해당 부분을 건너뛰어도 좋다.
데이터 소스는 간단하게 정의해서 데이터를 저장하거나 가져오는 출처를 의미한다. 예를 들어 블로그를 작성할 때, 참고했던 출처를 남기는 것과 비슷하다.
일반적으로 앱에서는 두 가지 형태의 데이터 소스를 사용한다.
원격 데이터 소스는 네트워크를 통해 서버에서 데이터를 요청하는 방식이다.
조금 더 쉽게 이해하기 위해 위에서 언급했던 블로그를 떠올려보자.
예를 들어 블로그 글을 작성할 때 다른 사람의 블로그를 참고했다면, 인터넷을 통해 정보를 가져온 것이다. 이처럼 인터넷에서 받아온 데이터가 곧 원격 데이터 소스에서 얻은 정보라고 할 수 있다.
이러한 방식의 장점은 최신의 데이터를 다량으로 얻을 수 있다는 점이다. 인터넷에는 계속해서 새로운 정보가 올라오고, 필요한 정보를 실시간으로 받아 올 수 있다.
하지만 곧, 인터넷을 사용해야한다는 것이 단점이다.
그렇다면 어떻게 원격 데이터 소스를 구현할까?
아래는 코틀린을 사용하여 원격 데이터 소스를 구현한 예제이다.
// data model
data class User(
val id: Long,
val name: String,
val email: String
)
// api service
interface UserApiService {
@GET("users/{id}"}
suspend fun user(@Path("id") id:Long): Response<User>
}
// remote data source
// 예외처리 로직은 제외..
class RemoteUserDataSource(private val api: UserApiService){
suspend fun user(id: Long): Response<User> {
return api.user(id)
}
}
원격 데이터 소스는 ApiService를 프로퍼티로 갖고 내부 메서드를 사용하여 api를 호출하는 구조이다.
로컬 데이터 소스는 디바이스 내부 저장소에서 데이터를 읽거나 저장하는 방식이다.
주로 Room, SharedPreferences, DataStore 를 사용하여 데이터를 저장한다.
예를 들어 블로그 글을 작성할 때 메모장 적힌 내용을 참고했다면, 이것은 내부에 저장된 메모리에서 가져온 것이라고 볼 수 있다.
로컬 데이터 소스의 가장 큰 장점은 네트워크가 없어도 사용할 수 있다는 점이다. 하지만 저장 공간이 한정적이며 저장된 데이터가 오래되어 최신 데이터가 아닐수도 있다.
그렇다면 로컬 데이터 소스는 어떻게 구현할까?
아래는 Room을 사용한 로컬 데이터 소스의 예제이다.
// data model
@Entity(tableName = "users")
data class User(
@PrimaryKey val id: Long,
val name: String,
val email: String
)
// DAO
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE id = :id")
suspend fun userById(id: Long): User?
}
// Local Data Source
class LocalUsersDataSource(private val dao: UserDao){
suspend fun user(id: Long) : User? = dao.userById(id)
}
로컬 데이터 소스는 Dao를 프로퍼티로 갖고 내부 메서드를 사용하여 유저정보를 가져온다.
데이터 소스를 통해서 데이터를 가져오거나 저장하는 것을 알게되었다. 네트워크를 통해 가져오는 데이터는 원격 데이터 소스를, 로컬 저장소에서 가져오는 데이터는 로컬 데이터 소스를 통해 사용할 수 있다. 하지만 앱의 규모가 커지고 기능이 늘어날수록, 데이터를 관리하는 것이 점점 복잡해진다.
이러한 복잡성을 해결하기 위해 등장한 것이 바로 Repository 패턴이다.
Repository는 데이터와 UI 사이에서 소통하는 중간 관리자 역할을 한다.
UI는 Repository에 데이터를 요청하고, Repository는 상황에 따라 Remote Source나 Local Source에서 데이터를 가져오거나 저장한다. 덕분에 UI는 데이터가 어디서 오는지 신경 쓰지 않고, 항상 일관된 방식으로 데이터에 접근할 수 있다.
정리하면,
Repository 패턴은 여러 데이터 소스를 추상화하여 하나의 일관된 인터페이스를 제공하는 아키텍처 패턴이다.
사용자의 프로필 정보를 가져오는 기능을 생각해보자.
// Repository 인터페이스
interface UserRepository {
suspend fun user(id: Long): User
}
// Repository 구현체
class DefaultUserRepository(
private val remote: RemoteUserDataSource,
private val local: LocalUserDataSource
) : UserRepository {
override suspend fun user(id: Long): User = remote.user(id).body()
}
이 구조를 보면,
상위 계층인 ViewModel이나 UseCase는 인터페이스에만 의존한다. 따라서 인터페이스를 통해 사용자의 정보를 요청만 하면 된다. 그 정보가 서버에서 데이터를 가져오는지, 로컬 저장소에서 가져오는지는 알 필요가 없어진다.
관심사의 분리
재사용성 증가
유지보수 향상
테스트 용이
이번 글에서는 DataSource가 무엇인지 그리고 여러 데이터 소스를 추상화하여 관리하는 방법인 Repository 패턴에 대해 살펴보았다.
Repository 패턴을 적용하면 데이터의 출처와 관계없이 일관된 인터페이스를 통해 데이터를 다룰 수 있고, 이를 통해 관심사의 분리, 유지보수성 향상, 테스트 용이성 같은 다양한 장점을 얻을 수 있다.
이러한 구조의 필요성을 못 느낄수도 있다. 필자도 처음 Repository 패턴에 대해 접했을 때 구조만 복잡해지고 굳이 사용할 필요가 있을까? 라는 생각을 했었지만, 프로젝트의 규모가 커져감에 따라 필요성과 효과를 체감할 수 있었다.
좋은글 감사합니다. 🙇🏻♂️