#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
#7-3 본 프로젝트의 가장 최신 Commit
'App 개발 일지 > Nutri Capture' 카테고리의 다른 글
Nutri Capture 프론트엔드 - 채팅UI 구조 잡기 (0) | 2024.11.14 |
---|---|
Nutri Capture 백엔드 - Room 가상 테이블의 레코드를 필요한 만큼만 가져오기 (0) | 2024.11.12 |
Nutri Capture 백엔드 - Room 가상 테이블 선언 (0) | 2024.11.10 |
Nutri Capture 방향성 - 채팅 UI (1) | 2024.11.08 |
Nutri Capture 백엔드 - View에서 INSERT 트리거 (0) | 2024.10.23 |