코드를 수정하면서 느낀점들
왜 Repository에서 단일 조회만 하더라도 Flow를 노출하는가? 에 대한 대답
왜 리액티브 프로그래밍인가
XML 기반의 메인액티비에 로직이 많은 코드를 수정하면서
코틀린에서도 단순 suspend함수가 아닌 flow를 사용하는지를 체감하게 되었다.
단, 여기서 flow는 핫스트림을 노출해야한다.
1. 기존 명령형 시스템의 한계
- UI 상태변경시 모든 변경점에서 상태값을 업데이트해야한다.
- 즉, 현재 액티비티가 아닌, 다른 프래그먼트에서 값을 수정시, 메인액티비티의 콜백을 사용해야한다.
- 이로인한 보일러 플레이트코드 과다, 동일한 코드의 복붙으로 관리포인트가 증가한다.
2. 선언형 접근의 이점
- 무엇을 보여줘야하는가(What)를 정의만 하면, 어떻게(How)는 프레임워크가 처리한다.
- 불변성의 개념과 함께 사용되므로, SideEffect에 대해서 걱정할 필요가 없어진다.
3. 명령형 방식의 치명적인 약점
- UI 상태에서 예외처리, 분기처리가 특정 임계치를 넘어서면, Controller의 로직분기는 기하급수적으로 증가한다.
- 분기처리 N개가 증가하면, 각 Case별 예외처리는 2^n만큼 복잡해진다.
앱을 사용하다 보면 이런 상황을 자주 마주친다.
- 사용자가 A 화면에서 티켓 8개를 본다.
- 특정 항목을 눌러 B 화면 (티켓 사용 페이지)으로 이동한다.
- B 화면에서 해당 티켓 3개를 사용한다.
- 뒤로 가기 버튼을 눌러 다시 A 화면으로 돌아온다.
그런데, A 화면에 보이는 데이터가 업데이트되기 전의 이전 상태(티켓 8개)인 경우가 있다.
사용자 입장에서는 분명 B 화면에서 데이터를 바꿨는데, A 화면은 그대로이니 혼란스럽다. 좋지 않은 사용자 경험이다.
왜 이런 문제가 발생할까?
단순히 A 화면이 뜰 때 데이터를 한 번만 가져와서 상태에 저장해두고, 그 후에는 외부에서 데이터가 바뀌든 말든 신경 쓰지 않기 때문이다. B 화면에서 데이터를 바꾼 사실을 A 화면의 ViewModel이나 UI가 알지 못한다.
이 문제를 우아하게 해결하는 방법을 정리한다.
핵심은 데이터의 단일 진실 공급원(Single Source of Truth)과 데이터 변경 사항의 관찰(Observability)이다.
관찰 가능한 데이터 흐름 (Observable Data Stream)
현대 Android 아키텍처(ViewModel, Repository, Flow)에서는 이 문제를 해결하기 위한 표준 패턴이 있다.
바로 데이터 레이어(Repository)에서 UI 레이어(ViewModel, UI)로 데이터 변경 사항을 계속 알려주는 방식이다.
데이터 흐름은 보통 이렇게 구성된다.
UI (Composable) ↔ ViewModel ↔ Repository ↔ 데이터 소스 (API, DB)
여기서 중요한 것은 Repository가 데이터의 단일 진실 공급원 역할을 하고, 이 데이터의 변경 사항을 Flow와 같은 관찰 가능한 스트림 형태로 노출한다는 점이다.
Repository가 이제 데이터의 변경이 생겼을 때, 이를 감지하고 Flow에 해당 변경을 전파해야한다.
데이터 동기화
class ItemRepository(
private val client: ItemApiClient,
repositoryScope: CoroutineScope,
){
private val _itemCountFlow = MutableSharedFlow<Int>(
replay = 0, // 과거 값을 다시 보내지 않음
extraBufferCapacity = 0, // 버퍼 없음
onBufferOverflow = BufferOverflow.SUSPEND // 버퍼가 꽉 차면 발행측 일시 중단 (기본값)
)
private val externalCountFlow: SharedFlow<Int> by lazy {
merge(
flow { emit(client.getItemCount()) },
_itemCountFlow
).shareIn(
scope = repositoryScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
replay = 1
)
}
fun getItemCountFlow(): Flow<Int> {
return externalCountFlow // 외부에 공개할 Flow
}
suspend fun setCount(count: Int){
client.setItemCount(count)
_itemCountFlow.emit(count) // itemCountFlow에 새로운 값 발행
}
}
1. 외부에서 구독을 시작하면, Api를 호출하도록 한다.
2. 값을 변경시키게 된다면, 내부 flow를 변경시켜, 구독이 이루어지게 한다.
data class CountUiState(
val count: Int,
){
companion object {
fun from(count: Int): CountUiState {
return CountUiState(count)
}
}
}
class ItemViewModel(
private val itemRepository: ItemRepository,
) : ViewModel() {
val itemUiState: StateFlow<UiState<CountUiState>> = itemRepository.getItemCountFlow()
.map<Int, UiState<CountUiState>> { UiState.Success(CountUiState.from(it)) }
.catch { e -> emit(UiState.Error(e)) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = UiState.Loading
)
private val _sideEffect = Channel<String>(Channel.BUFFERED)
val sideEffect = _sideEffect.receiveAsFlow()
fun setCount(count: Int) {
viewModelScope.launch {
try {
itemRepository.setCount(count)
} catch (e: Exception) {
_sideEffect.send("Error: ${e.message}")
}
}
}
}
이제 의문이 들 수 있다.
Repository에 상태가 생기지 않나?
하지만 여기서 중요한 구분은 이것이 UI 상태(UI State) 가 아니라 데이터 상태(Data State) 라는 점이다.
- 데이터 상태: 애플리케이션이 관리해야 하는 실제 데이터의 값(예: 사용자 목록, 게시글 내용, 설정 값).
UI와 직접적으로 연결되지 않으며, 여러 화면이나 앱의 다른 부분에서 공유될 수 있다. - UI 상태: 특정 화면의 UI 요소들이 어떤 상태인지에 대한 정보(예: 로딩 스피너가 보이는지, 에러 메시지가 무엇인지, 텍스트 필드에 사용자가 입력한 내용, 목록의 스크롤 위치). 해당 UI 컴포넌트나 화면에 종속적이다.
Repository의 역할 중 하나는 데이터 소스(API, DB)를 추상화하고 데이터의 Single Source of Truth (단일 진실 공급원) 를 제공하는 것이다.
이때 Repository가 로컬 캐시(메모리, DB)를 관리하는 것은 이 Single Source of Truth를 구현하는 일반적인 방법이다.
캐시는 API에서 가져온 데이터를 일시적 또는 영구적으로 저장하여, 네트워크 호출 없이 빠르게 데이터를 제공하거나 오프라인 지원을 가능하게 한다.
따라서 Repository 내부에 (혹은 Repository가 관리하는 별도의 클래스에서) 데이터의 캐시 상태를 가지는 것은 Repository 패턴의 정상적인 사용 방식이다.
사용하는 입장에서 repository는 local인지 remote인지 너무 추상화되서 불편하지않을까?
Repository는 의도적으로 데이터가 로컬에서 오는지, 원격에서 오는지 등을 사용하는 쪽(ViewModel)으로부터 완전히 추상화한다. 그리고 이것이 바로 Repository 패턴의 가장 큰 장점 중 하나이다.
- 결합도 감소 (Decoupling): ViewModel이나 UI는 데이터가 어디서 오는지, 어떻게 저장되는지에 대해 전혀 알 필요 없다.
그냥 Repository에게 "아이템 목록 줘" 또는 "새 아이템 만들어 줘" 라고 요청하고, 결과만 받아서 처리하면 된다. - 유연성 및 변경 용이성: 데이터 소스(API 엔드포인트 변경, 다른 DB 사용, 캐싱 전략 변경 등)가 변경되더라도 Repository 내부 구현만 수정하면 되고, ViewModel이나 UI 코드는 전혀 건드릴 필요가 없다.
- 테스트 용이성: Repository를 사용하는 ViewModel을 테스트할 때, 실제 API 호출 없이 Repository의 가짜(Mock) 객체를 사용하여 원하는 데이터를 제공하도록 만들 수 있어 테스트 코드를 작성하기가 훨씬 쉬워진다. 반대로 Repository를 테스트할 때는 API 서비스나 캐시의 가짜 객체를 사용하면 된다.
만약 ViewModel이 데이터가 로컬에서 왔는지, 원격에서 왔는지 일일이 판단해서 UI 로직을 다르게 처리해야 한다면, ViewModel은 너무 많은 책임을 지게 되고 데이터 접근 방식 변경 시 ViewModel 코드도 함께 수정해야 하는 유지보수의 악몽이 펼쳐진다.
물론 사용자에게 "데이터가 마지막으로 동기화된 시간" 같은 정보를 보여주고 싶을 수는 있다. 이런 정보는 데이터 자체에 대한 메타데이터이므로, Repository가 데이터와 함께 노출하거나, ViewModel에서 별도의 State로 관리하여 UI에 전달할 수 있다. 하지만 이건 데이터 출처(Local/Remote) 자체를 ViewModel이 판단하는 것과는 다르다.
결론적으로,
- Repository 내부에 데이터의 캐시 상태(데이터 상태) 가 존재하는 것은 정상적이고 필요한 패턴이다. 이것이 UI 상태와 혼동되어서는 안 된다.
- Repository가 데이터 소스를 추상화하는 것은 사용하는 측면에서 "불편함"이 아니라, "유연성, 테스트 용이성, 낮은 결합도"라는 훨씬 큰 이점을 가져다주는 핵심 원칙이다.
LocalRepository 와 RemoteRepository
- LocalRepository: 로컬 데이터 소스(Room Database, DataStore, 파일, 메모리 캐시 등)와의 통신만을 담당한다. DB 쿼리 실행, 로컬 파일 읽고 쓰기 등의 코드가 여기에 들어간다.
- RemoteRepository: 원격 데이터 소스(REST API, GraphQL 등)와의 통신만을 담당한다. Retrofit 호출, 네트워크 응답 처리 등의 코드가 여기에 들어간다.
단일 Repository와의 관계: 이때, 기존에 설명한 ItemRepository와 같은 메인 Repository 클래스는 LocalRepository와 RemoteRepository를 주입받아 사용한다. 메인 Repository는 "중재자(Mediator)" 또는 "오케스트레이터(Orchestrator)" 역할을 하며, 어떤 데이터를 어디서 가져오거나 저장할지 결정하는 로직을 수행한다. 예를 들어:
- 읽기 작업 시: 로컬 캐시(LocalRepository)에서 먼저 데이터를 시도하고, 없거나 오래되었으면 네트워크(RemoteRepository)에서 가져온 후 로컬 캐시에 저장하고 데이터를 반환한다.
- 쓰기 작업 시: 네트워크(RemoteRepository)에 먼저 업데이트 요청을 보내고, 성공하면 로컬 캐시(LocalRepository)도 업데이트한다.
왜 사용하는가: 데이터 소스가 여러 개이고 각 소스와의 상호작용 로직이 복잡해질 때, 책임을 분리하여 Repository 코드를 더 깔끔하게 만들고 각 데이터 소스별 로직 테스트를 용이하게 한다.
UseCase
- 개념: Use Case는 애플리케이션의 특정 비즈니스 로직 또는 기능을 캡슐화하는 클래스다. ViewModel과 Repository 사이에 위치하는 경우가 많다.
- 역할:
- ViewModel로부터 사용자 이벤트나 요청을 받는다.
- 하나 또는 여러 개의 Repository를 호출하여 특정 비즈니스 로직을 수행한다. 예를 들어 "사용자 로그인" Use Case는 사용자 Repository, 인증 Repository 등을 호출할 수 있다.
"아이템 구매" Use Case는 아이템 Repository와 결제 Repository를 호출할 수 있다. - Repository에서 받은 데이터를 ViewModel이 사용하기 편리한 형태로 가공하거나 조합할 수 있다.
- 비즈니스 규칙(예: "아이템 재고가 있어야 구매 가능", "사용자는 하루에 한 번만 글 작성 가능")을 적용한다.
- 결과를 ViewModel에게 돌려준다 (데이터, 성공/실패 상태 등, 종종 Flow 형태로 반환).
- 왜 사용하는가:
- ViewModel 슬림화: 복잡한 비즈니스 로직을 Use Case로 옮겨 ViewModel의 코드를 간결하게 유지한다.
ViewModel은 주로 UI 상태 관리와 Use Case 호출 역할에 집중한다. - 재사용성: 동일한 비즈니스 로직이 앱의 여러 화면에서 사용될 경우 Use Case로 만들어 두면 재사용하기 용이하다.
- 테스트 용이성: Use Case는 UI나 Android 프레임워크에 의존하지 않고 순수 Kotlin/Java 코드로 작성되므로 단위 테스트하기가 매우 용이하다.
- ViewModel 슬림화: 복잡한 비즈니스 로직을 Use Case로 옮겨 ViewModel의 코드를 간결하게 유지한다.
Repository에서 Flow를 노출하는 것이 Best Practice인 요구사항들
다른 화면에서 데이터가 업데이트되었을 때 현재 화면에 자동으로 반영되어야 하는 요구사항과 같이 데이터의 변경 사항을 UI가 실시간 또는 거의 실시간으로 관찰하고 반응해야 하는 경우.
이런 요구사항에서는 Repository가 Flow를 노출하는 것이 매우 중요하고 강력한 패턴이 된다.
- 실시간/준실시간 업데이트: 백그라운드 작업(예: 동기화, WorkManager), 다른 사용자의 데이터 변경, 로컬 DB 업데이트 등 데이터 소스의 변경이 발생했을 때, Repository가 Flow를 통해 변경된 데이터를 자동으로 흘려보내고, ViewModel과 UI가 이를 구독하여 즉시 화면을 갱신할 수 있다.
- 캐싱 전략 구현 용이: "로컬 캐시의 데이터를 먼저 보여주고, 백그라운드에서 네트워크 통신하여 캐시를 갱신하면 UI가 자동 업데이트되게" 하는 패턴(Cache-first, Network-refresh)을 Flow를 사용하면 자연스럽게 구현할 수 있다.
- ViewModel의 단순화: ViewModel은 그저 Repository의 Flow를 collect하기만 하면 되므로, 데이터 변경 감지 및 UI 상태 업데이트 로직이 간결해진다. 수동으로 데이터를 주기적으로 새로고침하거나 복잡한 콜백 체인을 관리할 필요가 없다.
- 데이터의 Single Source of Truth 보장: Repository가 캐시를 관리하고 Flow를 통해 데이터를 노출하면, 캐시가 데이터의 단일 진실 공급원이 되고 모든 관찰자가 동일한 최신 상태를 보게 된다.
'Android' 카테고리의 다른 글
[Android] suspend 함수에서 Result를 사용하지 마세요 (0) | 2025.07.10 |
---|---|
[Android] Ktor Client에서 Jwt 인증 로직 구현하기 (0) | 2025.05.05 |