[Android] Repository에서 공통 데이터 관리하기

Thirfir·2024년 9월 3일

Android

목록 보기
1/1
post-thumbnail

앱은 여러 부분에서 사용될 수 있는 데이터가 존재한다.
예를 들어, 닉네임을 수정한다고 생각해보자.
화면이 프로필 화면, 프로필 수정 화면으로 구성된다고 했을 때, 닉네임 수정 흐름은 다음과 같이 구현할 수 있다.

  1. 프로필 화면에서 프로필 수정 화면으로 진입
  2. 이때 수정 전 이전 닉네임을 네비게이션 인자로 전달
  3. 전달받은 인자를 텍스트필드(xml은 EditText, Compose는 TextField)에 미리 입력해 둠
  4. 사용자가 닉네임 입력을 수정
  5. 저장 버튼을 누르면, 프로필 화면으로 결과 닉네임을 전달
  6. 프로필 화면에서 전달받은 닉네임을 표시

이 흐름이 구현 상으로 어려운 것은 아니나, 데이터를 주고받는 과정, 그리고 전달받은 데이터를 표시하는 과정에서 데이터의 일관성이 유지되지 않을 가능성이 있다.
특히 Activity 2개로 위와 같은 화면을 구성할 경우 ActivityResult를 통해 결과를 전달해야 하므로, 그 구현은 더욱 복잡해질 수 있다.

이 흐름을 줄여보자.
데이터를 전달하지 않고, 애초부터 같은 데이터를 바라보게 하면 어떨까?

1. Object

가장 간단한 것은 싱글톤 객체를 활용하는 것이다.
특히 코틀린은 Object 키워드를 제공하므로 싱글톤의 구현이 더욱 간편하다.

Object User {
    var nickname = ""
    var img = ""
}

하지만 object는 이러한 단점이 존재한다.

  1. 생명주기를 관리할 수 없고, 앱이 살아 있는 동안 메모리에 계속 남아있게 된다.
  2. 데이터 초기화 타이밍을 세밀하게 제어하기 어렵기 때문에, 상황에 따라 의도하지 않은 값이 남거나, 예측하지 못한 상태 공유 문제가 발생할 수 있다.
  3. 세밀한 접근 제어가 어렵다. (아무 곳에서나 쉽게 접근 및 수정이 가능하다)

안정적인 프로젝트를 유지하기 위해선 Object의 사용을 자제해야 한다.

2. 뷰모델 공유

또 다른 방식으로, 두 화면이 같은 뷰모델 객체를 사용하는 것이다.

두 화면이 프래그먼트로 구현될 경우, 액티비티 생명주기를 따르는 뷰모델을 이용하는 것으로,
그리고 두 화면이 Composable로 구현될 경우, viewModel을 전달하는 것으로 같은 뷰모델 객체를 공유할 수 있다.

이 방식은 구현은 간단하나, 액티비티 수준에선 사용하기 어렵고, 뷰모델이 비대해지거나 화면과 관계없는 책임까지 갖게 될 수 있다는 단점이 존재한다.

3. 매번 API 호출

각 뷰모델에서 데이터가 필요할 때마다 API를 호출하는 것이다.
구현 상으로는 Object 다음으로 간단하다고 할 수 있으나, 네트워킹 비용을 생각하면 바람직하진 않다.

4. Repository 패턴 활용

마지막으로, 정말 본론이라 할 수 있는 내용인, Repository 패턴을 활용하는 것이다.
Repository는 다른 레이어(UI, Domain)에서 데이터 레이어로의 진입점이 되는 클래스다. Repository는 여러 개의 데이터 소스를 가지며, 이 소스들로부터 데이터를 받아온다.

데이터 소스의 일반적인 네이밍은 RemoteLocal이며, 서버가 데이터의 원 출처일 경우 Remote를, 내부 저장소(Room, SharedPreferences 등)가 데이터의 원 출처일 경우 Local을 사용한다.

class UserRepository(
    private val userRemoteDataSource: UserRemoteDataSource,
    private val userLocalDataSource: UserLocalDataSource
) {
    ...
}

이때 중요한 것은, 다른 레이어는 Repository로부터 데이터를 받아오면서, 그 데이터의 출처가 Remote인지 Local인지 알 필요 없다는 것이다. 즉, 다른 레이어는 데이터를 사용하는 것에만 관심을 가질 뿐, 어떻게 받아오는지는 관심을 가지지 않는다.

이 개념을 활용한다면, Repository에서 공통 데이터를 관리할 수 있다는 생각까지 이어가 볼 수 있다.
(여기선 LocalDataSource를 사용하지는 않으나, 최초의 데이터는 Remote로 부터 받아오고 이후의 데이터는 Local(메모리)로 부터 받아오는 것으로 해석할 수 있다.)

class UserRepository(
    private val userRemoteDataSource: UserRemoteDataSource
) {
    private var user: User? = null
    
    suspend fun getUser(): User {
        return if (user == null) {
            userRemoteDataSource.getUser().also {
                user = it
            }
        } else
            user!!
        }
    }
}

이렇게 Repository에서 데이터를 관리하고, 뷰모델에서 가져다 쓰면 된다. 뷰모델은 Repository에서 필요한 데이터에 접근하기만 할 뿐, 그 데이터가 어디에서 비롯된 것인지는 관심이 없다.

// ProfileViewModel.kt
class ProfileViewModel(
    private val userRepository: UserRepository
) {
    var user: User? = null
    
    init {
        getUser()
    }
    
    private fun getUser() {
        viewModelScope.launch {
            user = userRepository.getUser()
        }
    }
}

// ProfileEditViewModel.kt
class ProfileEditViewModel(
    private val userRepository: UserRepository
) {
    var user: User? = null
    
    init {
        getUser()
    }
    
    private fun getUser() {
        viewModelScope.launch {
            user = userRepository.getUser()
        }
    }
}

당연하지만, ViewModel에 전달하는 userRepository는 서로 같은 객체여야 한다. 나는 주로 SingletonComponent 생명주기를 따르는 Repository를 Hilt로 주입한다.


좀 더 개선하기

구현이 간단하지만 분명히 아쉬운 점이 존재한다.

  1. var 변수 사용으로 인해 안정성이 떨어진다.
  2. 뷰모델의 init 문에서 데이터를 초기화 해야한다.
  3. 사용하는 곳(UI)에서 데이터 변경을 수동으로 반영해야 한다.

특히 3번이 핵심인데, 데이터를 공통으로 관리하더라도, 그것이 결과로 제대로 반영되지 않는다면 의미가 없다.
또한, 데이터가 상태로써 관리되는 Jetpack Compose에서 위와 같은 형태는 그대로 활용하기 어렵다.

이러한 아쉬운 점은 Coroutine Flow를 활용하여 한 번에 해결할 수 있다.

Flow로 마이그레이션

Flow는 데이터 스트림이다. Flow의 구독자는 지속적으로 데이터를 전달받는다.

다시 Repository를 보자.

class UserRepository(
    private val userRemoteDataSource: UserRemoteDataSource
) {
    private var user: User? = null
    
    suspend fun getUser(): User {
        return if (user == null) {
            userRemoteDataSource.getUser().also {
                user = it
            }
        } else
            user!!
        }
    }
}

우리가 전달받고 싶은 데이터는 user이다. user 객체는 여러 곳에서 공통적으로 사용될 수 있고, 여러 지점에서 변화할 수 있다.
따라서, user를 데이터 스트림은 Flow로 선언한다면, 이를 필요로 하는 여러 지점에선 Flow 구독하는 것으로 데이터의 일관성을 지킬 수 있다. 아래와 같이 말이다.

class UserRepository(
    private val userRemoteDataSource: UserRemoteDataSource,
    private val scope: CoroutineScope
) {
    
    private val user: StateFlow<User?> = flow {
        emit(userRemoteDataSource.getUser())
    }.stateIn(
        scope = scope,
        started = SharingStarted.Lazily,
        initialValue = null
    )
      
    fun getUser(): Flow<User?> {
        return user
    }
}

구독전략을 SharaingStarted.Lazily로 설정하여 첫 구독 이후 스트림이 시작되도록 한다.

뷰모델에선 이를 구독한다.

// ProfileViewModel.kt
class ProfileViewModel(
    private val userRepository: UserRepository
) : ViewModel() {
 
    val user: StateFlow<User?> = flow {
        emit(userRepository.getUser())
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = null
    )
}

// ProfileEditViewModel.kt
class ProfileEditViewModel(
    private val userRepository: UserRepository
) : ViewModel() {
 
    val user: StateFlow<User?> = flow {
        emit(userRepository.getUser())
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = null
    )
}

여기서 데이터를 수정하는 메서드를 추가하면 이렇게 할 수 있다.

class UserRepository(
    private val userRemoteDataSource: UserRemoteDataSource,
    private val scope: CoroutineScope
) {
    
    private val _user = MutableStateFlow<User?>(null)
    private val user: StateFlow<User?> = flow {
        _user.value = userRemoteDataSource.getUser()
        
        emitAll(_user)
    }.stateIn(
        scope = scope,
        started = SharingStarted.Lazily,
        initialValue = null
    )
      
    fun getUser(): Flow<User?> {
        return user
    }
    
    suspend fun editNickname(nickname: String) {
        userRemoteDataSource.editNickname(nickname)
        _user.value = User(nickname, user!!.value.profileImg)
    }
}

emilAll(_user)는 _user.collect { emit(it) }과 완전히 동일하다.

이렇게 적용하면 상기 언급한 3가지 문제는 이렇게 해결된다.

  1. var 변수 사용으로 인해 안정성이 떨어진다. -> 뷰모델은 Immutable한 Flow를 가지므로 뷰모델에서 직접 변경할 가능성 X
  2. 뷰모델의 init 문에서 데이터를 초기화 해야한다. -> flow 문에서 자동으로 초기화를 수행하므로 init 사용 X
  3. 사용하는 곳(UI)에서 데이터 변경을 수동으로 반영해야 한다. -> 데이터를 감지하므로 수동으로 반영할 필요 X

마무리

이러한 방식으로 데이터를 관리할 때는, 반드시 데이터의 일관성에 주의해야 한다.
가령 매 API 호출마다 반환 데이터가 다를 경우, 이렇게 관리해선 안 될 것이다.

필요에 따라 적절히 사용하는 사고를 기르자.

profile
안드로이드 개발자입니다.

0개의 댓글