App 개발 일지/Nutri Capture

Nutri Capture 백엔드 - Room 무결성 보완

interfacer_han 2024. 11. 15. 00:06

#1 개요

이전 게시글의 Commit은 앱 실행이 안되는 버그가 있다. 해당 버그를 발생시킨 근본적인 부분을 찾아간다. 특히, 세번째 에러는 Room의 무결성과 관련된 에러로 해결까지 꽤 시간이 소요됐으며, 동시에 Room 구현에 있어 DAO를 경솔히 작성해서는 안된다는 교훈을 얻었다.

 

#2 코드 - 첫번째 에러

#2-1 NoSuchElementException

FATAL EXCEPTION: main (Ask Gemini)
Process: com.example.nutri_capture_new, PID: 7679
java.util.NoSuchElementException: List is empty.
    at kotlin.collections.CollectionsKt___CollectionsKt.last(_Collections.kt:418)
    at com.example.nutri_capture_new.nutrient.NutrientViewModel$onEvent$2.invokeSuspend(NutrientViewModel.kt:51)
    ...

 

#2-2 발생 원인

// in NutrientViewModel.kt

is NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal -> {
    viewModelScope.launch {
        val lastDayMeal = _nutrientScreenState.value.dayMeals.last()
        _nutrientScreenState.value.dayMeals.addAll(
            repository.getNextDayMealsAfter(lastDayMeal, 10)
        )
    }
}

NutrientViewModel.onEvent() 속 When(NutrientViewModelEvent)의 한 분기문에서 발생한 에러다. List가 비어있는 경우, List.last()를 적용할 수 없어서 발생한다.

 

#2-3 대처

// in NutrientViewModel.kt

is NutrientViewModelEvent.LoadMoreItemsAfterLastDayMeal -> {
    viewModelScope.launch {
        if(_nutrientScreenState.value.dayMeals.isNotEmpty()) {
            val lastDayMeal = _nutrientScreenState.value.dayMeals.last()
            _nutrientScreenState.value.dayMeals.addAll(
                repository.getNextDayMealsAfter(lastDayMeal, 10)
            )
        }
    }
}

if문을 하나 추가한다.

 

#3 코드 - 두번째 에러

#3-1 NullPointerException

FATAL EXCEPTION: main (Ask Gemini)
Process: com.example.nutri_capture_new, PID: 7839
java.lang.NullPointerException: Attempt to invoke virtual method 'java.time.LocalDate com.example.nutri_capture_new.db.DayMealView.getDate()' on a null object reference
    at com.example.nutri_capture_new.db.MainRepository.getNextDayMealsAfter(MainRepository.kt:43)
    ...

 

#3-2 발생 원인

// in MainRepository.kt

suspend fun getNextDayMealsAfter(lastDayMeal: DayMealView, limit: Int): List<DayMealView> {
    return dao.getNextDayMealsAfter(
        lastDayMeal.date, lastDayMeal.time, lastDayMeal.mealId, limit
    )
}

에서 lastDayMeal.date에 null이 담겨 있어서 발생한다. 하지만, 내 의도대로라면 lastDayMeal에는 null값이 들어갈 수가 없다. 원인은 어이없게도,

 

// in NutrientViewModel.kt

fun log() {
    viewModelScope.launch {
        Log.i("interfacer_han, getAllDayMeals()", repository.getAllDayMeals().toString())
        Log.i("interfacer_han, getNextDayMealsAfter()",
            repository.getNextDayMealsAfter(
                repository.getDayMeal(6),
                100
            ).toString()
        )
    }
}

만들어놓고 삭제를 깜빡한 Log 출력용 함수였다.

 

#3-3 대처

먼저, ViewModel의 log()를 삭제한다. 그리고,

 

...

@Composable
fun NutrientScreen(
    ...
) {
    LaunchedEffect(key1 = true) {
        // DayMealView 작동 확인용 로그
        viewModel.log()

        ...
    }

    ...
}

NutrientScreen에 있던, log()를 호출하는 코드도 삭제한다.

 

#4 코드 - 세번째 에러

#4-1 SQLiteConstraintException (code 787)

FATAL EXCEPTION: main (Ask Gemini)
Process: com.example.nutri_capture_new, PID: 8547
android.database.sqlite.SQLiteConstraintException: FOREIGN KEY constraint failed (code 787 SQLITE_CONSTRAINT_FOREIGNKEY)
    at android.database.sqlite.SQLiteConnection.nativeExecuteForLastInsertedRowId(Native Method)
    at android.database.sqlite.SQLiteConnection.executeForLastInsertedRowId(SQLiteConnection.java:974)
    at android.database.sqlite.SQLiteSession.executeForLastInsertedRowId(SQLiteSession.java:814)
    at android.database.sqlite.SQLiteStatement.executeInsert(SQLiteStatement.java:89)
    at androidx.sqlite.db.framework.FrameworkSQLiteStatement.executeInsert(FrameworkSQLiteStatement.kt:42)
    at androidx.room.EntityInsertionAdapter.insertAndReturnId(EntityInsertionAdapter.kt:101)
    at com.example.nutri_capture_new.db.MainDAO_Impl$6.call(MainDAO_Impl.java:149)
    ...

android.database.sqlite.SQLiteConstraintException: FOREIGN KEY constraint failed (code 787 SQLITE_CONSTRAINT_FOREIGNKEY) 문구가, 외래 키 제약 조건을 위배했을 때 발생했음을 보여준다. 테이블 A의 컬럼 a를 테이블 B가 외래키로서 가지고 있다고 해보자. 이때, 에러 코드 787은 주로 어떤 B.a의 값을 A.a에서 보유하지 않은 경우 발생하는 에러라고 한다. 현재 시점 본 프로젝트의 데이터베이스 스키마에서 외래 키 제약 조건이 발생할만한 관계는 Day 테이블과 Meal 테이블 사이에서다. 더 정확히는, Meal.dayId (= B.a)를 Day.dayId (= A.a)에서 보유하지 않았기에 발생하는 에러다.

 

여러 시행착오를 거쳐 이 모든 연쇄적 에러의 근본을 찾았는데 그 부분은 아래와 같다.

 

#4-2 발생 원인

// in NutrientScreen.kt

LaunchedEffect(key1 = true) {
    repeat(5) {
        viewModel.onEvent(
            NutrientViewModelEvent.InsertMeal(
                meal = Meal(
                    time = LocalTime.now(),
                    name = "test",
                    nutritionInfo = NutritionInfo()
                ),
                date = LocalDate.now()
            )
        )
    }
}

// in MainRepository.kt

suspend fun insertMeal(meal: Meal, date: LocalDate): Long {
    var dayId = dao.getDayId(date)
    if (dayId == null) {
        dayId = dao.insertDay(Day(date = date))
    }
    return dao.insertMeal(meal.copy(dayId = dayId))
}

View에서 InsertMeal 이벤트를을 5번 연달아 발생시킨다. 따라서, Repository의 insertMeal() 또한 5번 실행될 것이다.

 

// in MainRepository.kt

suspend fun insertMeal(meal: Meal, date: LocalDate): Long {
    var dayId = dao.getDayId(date)
    if (dayId == null) {
        dayId = dao.insertDay(Day(date = date))
    }

    Log.i("test", "date: $date\ndayId: $dayId")
    
    return dao.insertMeal(meal.copy(dayId = dayId))
}

insertMeal()에 Log 함수를 넣고 그 출력을 확인해보면,

 

date: 2024-11-13
dayId: 1

date: 2024-11-13
dayId: 2

date: 2024-11-13
dayId: 3

date: 2024-11-13
dayId: 4

date: 2024-11-13
dayId: 5

라는 이해하기 힘든 결과가 출력된다. dayId - date 쌍은 한 번 결정되면, 해당 레코드가 삭제될 때까지 값이 바뀌어선 안 된다. date가 저장되는 Column은 Unique하도록 설계했다. 그렇다면 dayId = dao.insertDay(Day(date = date))에서, 같은 date가 인수로 전달될 때 dayId 또한 항상 같은 같을 뱉어야 한다.

 

근본적인 원인은, DAO에서 정의한 insertDay()에 무심코 사용한 onConflict 어노테이션이었다.

 

// in MainDAO.kt

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDay(day: Day): Long

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMeal(meal: Meal): Long

나는 insertMeal()을 만들 때, onConflict = OnConflictStrategy.REPLACE로 둠으로써 updateMeal()을 만드는 수고를 덜려고 했다.

 

그리고 insertMeal()을 만들 때처럼 insertDay() 또한 onConflict = OnConflictStrategy.REPLACE로 두었는데, 여기에서 모든 비극(?)이 탄생했던 것이다. onConflict = OnConflictStrategy.REPLACE인 insertDay()는 updateDay()를 포함한 개념이 되는데, 문제는 updateDay()는 존재해서는 안 되는 함수였다는 점이다. Day 테이블의 레코드는 한번 등록된 이상, 해당 레코드가 삭제되기 전까지는 UPDATE될 일이 전혀 없기 때문이다. 전혀 UPDATE되지 않을 것이라 생각한 테이블에, UPDATE 동작을 내포한 (onConflict = OnConflictStrategy.REPLACE인) insertDay()를 막무가내로 사용했으니 일어난 혼돈인 것이다.

 

#4-3 대처

// in MainDAO.kt

@Query("SELECT day_id FROM day_table WHERE day_date = :date LIMIT 1")
suspend fun getDayId(date: LocalDate): Long

@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertDay(day: Day): Long

비극을 해결하기 위해서 insertDay()의 onConflict를 @Insert(onConflict = OnConflictStrategy.IGNORE)로 변경한다.

 

이 에러와 관련이 있는 것은 아니지만, insertDay() 바로 위에 있는 메소드 getDayId() 반환형이 Long?인게 눈에 밟힌다. 내친김에, getDayId()의 반환형을 Long?에서 Long으로 변경한다. 이러면 쿼리에 실패 시 null이 아닌 -1을 뱉는다. 대안이 있다면 굳이 null을 사용할 이유가 없다. null은 만악의 근원이다.

 

// in MainRepository.kt

suspend fun insertMeal(meal: Meal, date: LocalDate): Long {
    val dayId = dao.insertDay(Day(date = date))

    Log.i("test", "date: $date\ndayId: $dayId")
    
    return if (dayId == -1L) {
        dao.insertMeal(meal.copy(dayId = dao.getDayId(date)))
    } else {
        dao.insertMeal(meal.copy(dayId = dayId))
    }
}

레포지토리도 위와 같이 수정한다. insertDay() 시 이미 date Column에 존재하는 date 값이라면, dayId에는 -1이 담긴다 (-1L은 Long형 -1을 의미한다). 따라서, dayId가 -1인 경우와 아닌 경우를 나눠 분기문을 작성했다.

앱을 재설치 후 다시 실행시켜보면,

 

date: 2024-11-13
dayId: 1

date: 2024-11-13
dayId: -1

date: 2024-11-13
dayId: -1

date: 2024-11-13
dayId: -1

date: 2024-11-13
dayId: -1

라는 로그 메시지가 뜬다.

 

#5 Log.i( ... ) 삭제

에러 확인 및 정상 작동 확인을 위해 삽입했던 Log.i( ... )들을 제거한다.

 

#6 요약

앱이 실행되지 않는 에러를 해결했다.

 

#7 완성된 앱

#7-1 스크린샷

NutrientScreen의 LaunchedEffect()에 의해 매 실행마다 Meal 객체가 5개씩 INSERT된다.

 

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

 

GitHub - Kanmanemone/nutri-capture-new

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

github.com

 

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

 

GitHub - Kanmanemone/nutri-capture-new

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

github.com