App 개발 일지/Nutri Capture

Nutri Capture 백엔드 - StateFlow로 전환

interfacer_han 2025. 1. 2. 20:12

#1 개요

#1-1 State에서 StateFlow로 마이그레이션

 

[Kotlin] Coroutines Flow - StateFlow

#1 개요 StateFlowA SharedFlow that represents a read-only state with a single updatable data value that emits updates to the value to its collectors. A state flow is a hot flow because its active instance exists independently of the presence of collecto

kenel.tistory.com

위 게시글을 참조하여 프로젝트를 업데이트했다.

 

#1-2 StateFlow 업데이트

이전 게시글의 문제점을 해결하기 위해서, ViewModel의 State 변수를 StateFlow로 변경(#1-1)한다. 그리고 Repository 함수의 반환형을 Flow로 변경한 뒤,

 

// 구조를 보이기 위한 코드로, 실제 코드(#2)와는 세부적으로 다름

repository.getAllItems().collect { itemList ->
    _items.value = itemList // Flow로부터 값을 갱신받는 StateFlow 프로퍼티
}

와 같은 형태로 만든다. 이렇게 구조를 짜 놓으면 데이터베이스에 어떤 변경이 발생했을 때 그 변경 사항이, 데이터베이스 → DAO → Repository → ViewModel → View 순으로 암시적으로 적용된다 (View는 이미 ViewModel에 데이터 바인딩 되어 있으므로).

 

#1-3 한계점과 대처 방안

변경 사항이 데이터베이스에서 View까지 쭉 이어진다는 건 1차적으로는 좋아 보인다. 하지만, 본 프로젝트는 UI에 Page 개념이 적용되어 있다. 최초에 특정 갯수의 요소를 표시하고, 사용자의 스크롤을 감지할 때마다 추가로 표시할 요소를 불러들이는 방식이다 (무한 스크롤). 즉 원래의 프론트엔드 로직을 유지하려면, (#1-2의 코드처럼) 모든 아이템을 전부 불러오는 방식은 사용할 수 없다는 얘기다.

 

 

Nutri Capture 방향성 - 개발 일정표 (1차)

#1 현 개발 행태에 대한 문제점#1-1 과정의 완벽주의난 풋내기 프로그래머에 불과하다. 내가 만들 앱 또한 그저 그런 앱일 것이다. 적어도 처음 (출시할 의도로 만드는) 앱을 당연히 그럴 것이다,

kenel.tistory.com

그럼에도 (#1-2의 코드처럼) 통째로 모든 아이템을 불러오기로 한다. 그게 쉽고 빠르기 때문이다. 기껏 구현해두었던 무한 스크롤은 잠시 포기하겠다. 개발 일정표를 지켜야하기 때문이다. 위 게시글에서, 일단 동작하면 넘기기로 스스로에게 약속했다. 그래야 과정의 완벽주의가 아니라 결과의 완벽주의를 달성할 수 있으니까. 나중에 한꺼번에 리팩토링하거나, 시간이 남아돌 때 조금씩 생각해보기로 하겠다. 그 편이 시간 대비 더 좋은 코드가 나올 것이다 (결과의 완벽주의).

 

이번 변경 사항에서 제거해버릴 코드가 꽤 될 것이다. 어차피 이전 코드들은 Git(Hub)에 기록되어 있으니 문제될 건 없다.

 

#2 코드

#2-1 NutrientViewModel.kt

...

class NutrientViewModel(private val repository: MainRepository) : ViewModel() {
    // (1) 화면 표시용 State
    private val _nutrientScreenState = MutableStateFlow(
        NutrientScreenState(
            dayMeals = SnapshotStateList()
        )
    )
    val nutrientScreenState: StateFlow<NutrientScreenState>
        get() = _nutrientScreenState

    init {
        viewModelScope.launch {
            repository.getAllDayMeals().collect { dayMeals ->
                _nutrientScreenState.value.dayMeals.apply {
                    clear()
                    addAll(dayMeals)
                }
            }
        }
    }

    // (2) View에서 받아 처리할 이벤트
    ...

    // (3) View로부터 받은 이벤트 처리
    fun onEvent(event: NutrientViewModelEvent) {
        when (event) {
            is NutrientViewModelEvent.InsertMeal -> {
                viewModelScope.launch {
                    repository.insertMeal(event.meal, event.date)
                }
            }

            is NutrientViewModelEvent.DeleteMeal -> {
                viewModelScope.launch {
                    repository.deleteMeal(event.meal)
                }
            }

            is NutrientViewModelEvent.DeleteDayMeal -> {
                viewModelScope.launch {
                    repository.deleteDayMeal(event.dayMeal)
                }
            }
        }
    }
}

#1-2에서 말한 걸 init { ... } 부분에 구현했다. 또, onEvent() 부분이 대폭 간소화되었다.

 

#2-2 MainRepository.kt

// package com.example.nutri_capture_new.db

import kotlinx.coroutines.flow.Flow
import java.time.LocalDate

class MainRepository(private val dao: MainDAO) {
    ...

    fun getAllDayMeals(): Flow<List<DayMealView>> {
        return dao.getAllDayMeals()
    }

    ...
}

LiveData가 반환형일 때와 같은 이유로, 반환형이 Flow일 때도 suspend 키워드를 붙이지 않아야 한다. 따라서 suspend 키워드를 제거한다.

 

#2-3 MainDAO.kt

...

@Dao
interface MainDAO {
    ...

    @Query("""
    SELECT * FROM DayMealView
    ORDER BY day_date DESC,
             meal_time DESC,
             meal_id DESC
    """)
    fun getAllDayMeals(): Flow<List<DayMealView>>

    ...
}

마찬가지로 반환형을 바꾸고, suspend 키워드를 제거한다.

 

#2-4 NutrientViewModelEvent.kt

package com.example.nutri_capture_new.nutrient

import com.example.nutri_capture_new.db.DayMealView
import com.example.nutri_capture_new.db.Meal
import java.time.LocalDate

sealed class NutrientViewModelEvent {
    data class InsertMeal(val meal: Meal, val date: LocalDate) : NutrientViewModelEvent()
    data class DeleteMeal(val meal: Meal) : NutrientViewModelEvent()
    data class DeleteDayMeal(val dayMeal: DayMealView) : NutrientViewModelEvent()
}

State에 초깃값을 할당하기 위한 이벤트(InitializeState) 그리고 무한 스크롤 이벤트(LoadMoreItemsAfterLastDayMeal)를 제거한다.

 

#2-5 NutrientScreen.kt

...

@Composable
fun NutrientScreen(
    ...
) {
    LaunchedEffect(key1 = true) {
        // ViewModel로부터 받은 이벤트 처리
        viewModel.nutrientScreenEventFlow.collectLatest { event ->
            when (event) {
                is NutrientScreenEvent.ShowSnackbar -> {
                    scope.launch {
                        snackbarHostState.showSnackbar(
                            message = event.message,
                            duration = SnackbarDuration.Short
                        )
                    }
                }
            }
        }
    }

    LazyColumn(
        ...
    ) {
        ...
    }
}

무한 스크롤 관련 LaunchedEffect() 등을 제거한 모습이다.

 

#3 요약

ViewModel이 보유하는 State형 프로퍼티를 StateFlow형으로 변경함으로써, 데이터 변경사항(CRUD)이 DB로부터 ViewModel을 거쳐 View까지 암시적으로 전달되게 만들었다.

 

#4 완성된 앱

#4-1 스크린샷

'+' 버튼은 '전송' 버튼과 역할이 겹치지만 일부러 삭제하지 않았다. 왜냐하면 다음 게시글부터, ChatBar가 더 상세한 영양 정보를 담아 INSERT를 수행할 수 있게 만들 것이기 때문이다. 그 과정에서 INSERT가 정상적으로 작동하지 않을 수 있다. 따라서 '+' 버튼은 프로젝트가 좀 더 안정화되면 그 때 삭제한다.

 

#4-2 이 게시글 시점의 Commit

 

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com

 

#4-3 본 프로젝트의 가장 최신 Commit

 

GitHub - Kanmanemone/nutri-capture-new

Contribute to Kanmanemone/nutri-capture-new development by creating an account on GitHub.

github.com