
Room Flow를 붙이면 DB 변경과 화면 갱신 연결이 훨씬 자연스러워집니다. 다만 이 편리함은 invalidation이 생길 때 쿼리를 다시 실행하는 구조 위에 서 있습니다. 이번 글에서는 Room DAO가 Flow를 반환할 때 무엇이 좋아지는지, 어디서 비용이 커지는지, 그리고 언제는 LiveData나 one-shot query가 더 단순한지 실무 기준으로 정리하겠습니다.
왜 같이 쓰나
Room과 Flow를 같이 쓰는 가장 큰 이유는 읽기 흐름이 자연스러워진다는 점입니다. 저장 후 다시 불러오기를 여기저기 직접 연결하지 않아도 되고, 화면은 현재 상태를 구독하는 쪽에 더 가까워집니다. 저장 코드와 화면 갱신 코드를 느슨하게 분리할 수 있다는 점이 Room Flow의 첫 번째 장점입니다.
예를 들어 할 일 목록 화면에서는 새 항목 추가, 완료 상태 변경, 삭제 뒤에 목록을 다시 보여줘야 합니다. 이때 DAO가 Flow를 반환하면 화면은 하나의 읽기 흐름만 collect하면 됩니다. 목록 갱신 흐름은 RecyclerView와 ListAdapter 차이 글과도 잘 이어집니다.
Room Flow 동작
자동 갱신을 정확히 이해하려면 Room이 무엇을 기준으로 다시 읽는지부터 봐야 합니다. 공식 문서 기준으로 Room observable query는 쿼리에서 참조한 테이블에 변경이 생기면 새 값을 emit합니다. 즉, 바뀐 row만 골라 보내는 느낌보다 변경 신호를 보고 쿼리를 다시 실행하는 구조에 가깝습니다.
@Dao
interface TodoDao {
@Query("SELECT * FROM todo ORDER BY createdAt DESC")
fun observeTodos(): Flow<List<TodoEntity>>
}UI가 이 Flow를 collect하는 동안 `todo` 테이블에 insert, update, delete가 일어나면 Room은 이 쿼리를 다시 실행합니다. 그 결과가 새 emission으로 내려오고, 화면은 그 값을 다시 그리면 됩니다. 목록, 상세, 필터 화면처럼 데이터 변경을 계속 따라가야 하는 장면에서 특히 편합니다.
코드 예시
실무에서는 DAO Flow를 UI에 바로 붙이기보다 ViewModel 상태로 한 번 감싸는 편이 더 안정적입니다. DAO는 관찰 가능한 읽기 흐름을 제공하고, Repository는 매핑과 중복 emission 정리를 맡고, UI는 최종 상태만 그리는 구조가 보통 더 읽기 쉽습니다.
@Dao
interface TodoDao {
@Query("SELECT * FROM todo WHERE isDone = 0 ORDER BY createdAt DESC")
fun observeActiveTodos(): Flow<List<TodoEntity>>
@Query("SELECT * FROM todo WHERE id = :id")
suspend fun getTodo(id: Long): TodoEntity?
}
class TodoRepository(
private val todoDao: TodoDao,
) {
fun observeActiveTodos(): Flow<List<Todo>> {
return todoDao.observeActiveTodos()
.map { entities -> entities.map { it.toModel() } }
.distinctUntilChanged()
}
suspend fun getTodo(id: Long): Todo? {
return todoDao.getTodo(id)?.toModel()
}
}
class TodoViewModel(
private val repository: TodoRepository,
) : ViewModel() {
val uiState: StateFlow<TodoListUiState> = repository.observeActiveTodos()
.map { todos -> TodoListUiState(items = todos) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TodoListUiState(isLoading = true)
)
}이 구조의 핵심은 DB 구조를 UI에 직접 새기지 않는 데 있습니다. Room의 편의는 살리되, 화면은 상태를 그리는 역할에 더 집중하게 됩니다. 상태 흐름 관점은 안드로이드 UDF는 왜 중요할까와도 닿아 있습니다.
무엇이 편해지나
- 저장 후 다시 불러오기 타이밍을 일일이 맞추지 않아도 된다
- 읽기 흐름 하나를 기준으로 상태를 설계하기 쉬워진다
- 화면 갱신과 데이터 저장 코드를 느슨하게 분리하기 좋다
- 목록 화면에서 submitList 같은 UI 갱신 흐름과 잘 맞는다
처음에는 refresh 한 줄이 더 쉬워 보여도, 조건이 늘어나면 관찰 흐름이 오히려 덜 흔들립니다. 특히 여러 군데에서 같은 데이터를 읽는 앱일수록 이 차이가 커집니다.
놓치기 쉬운 비용
많이 놓치는 지점은 여기입니다. Room observable query에는 중요한 제한이 있습니다. 참조한 테이블의 어떤 row가 바뀌든, 그 row가 현재 결과 집합에 없어도 쿼리가 다시 실행될 수 있습니다. 즉, 자동 갱신은 공짜가 아닙니다.
@Query("SELECT * FROM message WHERE roomId = :roomId ORDER BY createdAt DESC")
fun observeMessages(roomId: Long): Flow<List<MessageEntity>>이 쿼리는 `message` 테이블을 참조합니다. 그러면 같은 테이블 안에서 현재 화면과 직접 상관없는 row가 바뀌어도 invalidation이 발생하고, Room은 쿼리를 다시 실행할 수 있습니다. 그래서 너무 넓은 테이블을 한 번에 관찰하거나, 화면에서 필요 없는 큰 결과 집합을 계속 읽는 구조는 금방 비싸집니다.
- 너무 넓은 테이블을 한 번에 관찰하는 경우
- 필요 없는 컬럼까지 큰 결과 집합으로 읽는 경우
- 여러 화면이 비슷한 Flow를 동시에 수집하는 경우
- emission마다 무거운 정렬, 그룹화, 매핑을 메인 쪽에서 하는 경우
공식 문서도 실제 결과가 달라질 때만 UI가 반응하게 하려면 `distinctUntilChanged()`를 적용할 수 있다고 안내합니다. 다만 이것은 불필요한 UI 반응을 줄이는 데 도움이 되는 것이지, 이미 한 번 실행된 비싼 쿼리 자체를 없애 주는 것은 아닙니다. 근본적으로는 너무 넓게 관찰하지 않는 설계가 더 중요합니다.
수집 주의점
lifecycle
Room Flow를 UI에 붙일 때는 어디서 collect하느냐가 중요합니다. `launch` 안에서 무심코 collect를 오래 유지하면, 화면이 보이지 않는 동안에도 수집이 이어져 낭비가 생길 수 있습니다. UI 수집은 보통 `repeatOnLifecycle`로 묶는 편이 더 안전합니다.
class TodoListFragment : Fragment(R.layout.fragment_todo_list) {
private val viewModel: TodoViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collectLatest { state ->
render(state)
}
}
}
}
}공식 `repeatOnLifecycle` 문서는 lifecycle이 target state를 오갈 때 block을 cancel/restart한다고 설명합니다. Fragment에서는 `viewLifecycleOwner` 기준이라 View가 사라진 뒤 수집이 남는 문제도 줄이기 쉽습니다. 관련 배경은 repeatOnLifecycle vs launchWhenStarted 글에서 더 자세히 볼 수 있습니다.
후처리
Room이 비동기로 값을 준다고 해서 downstream 비용까지 자동으로 사라지는 것은 아닙니다. emission마다 큰 리스트를 다시 그룹화하거나, 복잡한 계산을 하거나, UI에서 바로 무거운 변환을 하면 병목은 그대로 남습니다. Room Flow가 해결하는 것은 observable query의 편의이지, 모든 후처리 비용이 아닙니다.
collectLatest
렌더링 쪽에서 최신 값만 중요하다면 `collectLatest`가 자연스러운 경우도 많습니다. Kotlin 공식 문서 기준으로는 새 값이 오면 이전 값의 action block이 취소되기 때문입니다. 다만 이것만 붙인다고 과한 재조회 문제가 사라지는 것은 아닙니다. 더 자세한 차이는 collectLatest vs collect 글과 함께 보면 이해가 쉽습니다.
언제 단순한 게 낫나
one-shot
한 번 읽고 끝나는 값이라면 `suspend` query가 더 맞습니다. 공식 문서도 one-shot query를 그 시점의 snapshot을 한 번 가져오는 작업으로 설명합니다. 상세 화면 초기값, 수정 화면 진입, 제출 직전 검증용 조회처럼 계속 관찰할 이유가 크지 않은 장면에서는 Flow보다 one-shot query가 더 읽기 쉽습니다.
LiveData
LiveData가 더 단순한 경우도 있습니다. 기존 View 기반 프로젝트에서 이미 LiveData 중심으로 화면이 짜여 있고, UI 관찰만 필요하며, 팀이 Flow 연산자를 깊게 쓰지 않는다면 LiveData도 충분히 실용적입니다. 공식 문서 기준으로 LiveData는 lifecycle-aware data holder이기 때문에, UI가 생명주기를 따라 관찰한다는 목적만 놓고 보면 여전히 단순합니다.
즉, 새 코드라고 무조건 모든 DAO를 Flow로 바꿔야 하는 것은 아닙니다. 계속 변화를 따라가야 하는 읽기인지, 아니면 한 번 읽고 끝나는 읽기인지부터 먼저 가르는 편이 낫습니다.
빠른 기준
- DB 변경을 계속 따라가야 한다 → Room Flow 검토
- 화면이 보일 때만 수집하면 된다 → repeatOnLifecycle로 묶기
- 결과가 자주 같게 나올 수 있다 → distinctUntilChanged() 검토
- 한 번 읽고 끝나면 된다 → suspend one-shot query 검토
- 프로젝트가 LiveData 중심이고 요구가 단순하다 → LiveData도 충분히 가능
Room Flow는 자동 갱신이 필요할 때 강하고, 필요 없는 곳까지 관찰하기 시작하면 오히려 비용이 커집니다.
마무리
Room과 Flow를 같이 쓰면 읽기 흐름이 단순해지고, DB 변경을 UI 갱신과 자연스럽게 연결하기 쉬워집니다. 하지만 그 편리함은 테이블 변경이 생기면 쿼리를 다시 조회한다는 전제 위에 서 있습니다. 그래서 관찰 범위가 넓어질수록 비용도 같이 커질 수 있습니다.
결국 중요한 것은 API 이름보다 질문입니다. 이 화면은 계속 변화를 따라가야 하는가, 관찰 범위가 너무 넓지 않은가, UI 수집이 lifecycle과 맞물려 있는가, 한 번 조회로 끝내는 편이 더 단순하지 않은가. 이 질문에 답할 수 있으면 Room Flow는 꽤 믿을 만한 도구가 됩니다.
더 이어서 보면 좋은 글은 repeatOnLifecycle vs launchWhenStarted, collectLatest vs collect, StateFlow와 SharedFlow 차이, RecyclerView와 ListAdapter 차이입니다. 공식 문서는 Room asynchronous and observable queries, repeatOnLifecycle, LiveData overview, collectLatest를 같이 보면 기준이 더 분명해집니다.