Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@
- `offline_cache_enabled`가 켜지면 [운동 기록 Repository](data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt)는 캘린더·목록·상세를 서버에서 갱신한 뒤 Room 스냅샷으로 반환합니다. 꺼지면 기존 서버 조회를 유지합니다. 최초 진입·새 날짜/월 선택·화면의 **새로고침**은 서버를 조회합니다. 복귀·앱 전경 전환은 첫 시작을 제외하고 Room만 한 번 다시 읽으며 선택 날짜·월·스크롤을 보존합니다. 재연결은 화면이나 작성 중인 폼을 교체하지 않습니다.
- 오프라인에서는 저장된 결과(성공한 빈 목록 포함)를 읽고, 미조회 날짜는 네트워크 오류로 구분합니다. 온라인 갱신 실패는 저장된 화면을 유지하면서 한 번 알립니다. 계정별 로컬 ID와 동기화 대기 표시를 사용하며, 기존 생성·수정·삭제·이미지 작업은 여전히 온라인 전용입니다. 자신의 쓰기는 Room 목록·상세·날짜별 횟수를 함께 맞춥니다. 쓰기 후 캐시 우선 조회는 유효한 스냅샷이 없고 온라인일 때만 서버에서 다시 읽습니다. 서버 쓰기 성공 뒤 캐시 반영 실패는 성공 결과와 경고로 전달하며 쓰기를 재시도하지 않습니다.
- Room 2→3은 빈 조회의 계정별 메타데이터 테이블만 추가하는 마이그레이션입니다. 플래그 OFF 쓰기 성공이나 캐시 반영 실패는 계정별 무효 표시를 동기 저장합니다. 표시를 저장한 다음 ON 조회는 동기화 완료 목록·상세·횟수·빈 조회 메타데이터를 한 트랜잭션으로 버린 뒤 표시를 지웁니다. 정리 실패 시 오래된 스냅샷을 반환하지 않으며 대기 행과 타이머는 보존합니다. 무효 표시 저장 자체가 실패하면 플래그와 무관하게 서버 쓰기 성공에 `CACHE_ERROR` 경고를 붙이고 서버 쓰기를 재시도하지 않습니다. 이 저장 실패 때에는 오래된 캐시가 남을 수 있습니다. 이미지 업로드 후 상세 조회 실패도 캐시 경고를 전달하고 무효 표시 저장을 시도합니다.
- 앱 시작 시 IO 코루틴이 `ClockProvider.now().minusMonths(6)`보다 이른 운동 횟수·목록·상세와 빈 응답 조회 마커를 한 트랜잭션으로 정리합니다. 기준일은 포함해 보관하며, 목록 없는 상세는 운동 시작 날짜로 판정합니다. 서버 ID가 있는 `SYNCED` 기록만 삭제하고, 같은 회원의 로컬/서버 ID로 연결된 대기·로컬 전용·보관 기간 안의 기록과 타이머는 보존합니다. 정리는 앱 시작을 기다리게 하지 않으며 실패해도 앱 초기화를 중단하지 않습니다.
- CI 단위 테스트 명령에는 Kotlin/JVM 모듈인 `:domain:test`가 포함되어 있지 않습니다. 로컬 전체 단위 테스트 안내에는 이를 별도로 포함했습니다. Maestro도 현재 CI에서 실행하지 않습니다.
- `dev` 워크플로우에는 배포를 push로 제한하는 조건이 없어 PR에도 배포 단계가 있습니다. 배포 구성은 공개 출시 여부나 실제 배포 성공을 뜻하지 않습니다.

Expand Down
31 changes: 31 additions & 0 deletions app/src/main/java/com/project200/undabang/ApplicationClass.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.kakao.vectormap.KakaoMapSdk
import com.project200.common.di.IoDispatcher
import com.project200.domain.manager.ExerciseCacheCleaner
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject

Expand All @@ -18,6 +26,15 @@ class ApplicationClass : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory

@Inject
lateinit var exerciseCacheCleaner: ExerciseCacheCleaner

@Inject
@IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher

private val applicationScope by lazy { CoroutineScope(SupervisorJob() + ioDispatcher) }

/** WorkManager가 Hilt로 Worker를 생성하도록 팩토리를 넘깁니다 */
override val workManagerConfiguration: Configuration
get() =
Expand All @@ -40,6 +57,8 @@ class ApplicationClass : Application(), Configuration.Provider {

// Crashlytics 디버그 모드에서는 비활성화
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)

applicationScope.launchExerciseCacheCleanup(exerciseCacheCleaner)
}

/**
Expand All @@ -58,3 +77,15 @@ class ApplicationClass : Application(), Configuration.Provider {
notificationManager.createNotificationChannel(channel)
}
}

/** 앱 초기화는 정리를 기다리지 않습니다. 일반 실패는 격리하고 취소는 작업의 취소 상태로 남깁니다 */
internal fun CoroutineScope.launchExerciseCacheCleanup(cleaner: ExerciseCacheCleaner): Job =
launch {
try {
cleaner.cleanExpiredCache()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "오래된 운동 캐시 정리 실패")
}
}
108 changes: 108 additions & 0 deletions app/src/test/java/com/project200/undabang/ExerciseCacheCleanupTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.project200.undabang

import com.google.common.truth.Truth.assertThat
import com.project200.domain.manager.ExerciseCacheCleaner
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test

@ExperimentalCoroutinesApi
class ExerciseCacheCleanupTest {
@Test
fun `시작 예약은 정리를 기다리지 않고 백그라운드에서 완료한다`() =
runTest {
val release = CompletableDeferred<Unit>()
var started = false
var completed = false
val cleaner =
object : ExerciseCacheCleaner {
override suspend fun cleanExpiredCache() {
started = true
release.await()
completed = true
}
}

val job = launchExerciseCacheCleanup(cleaner)

assertThat(started).isFalse()
assertThat(completed).isFalse()
runCurrent()
assertThat(started).isTrue()
assertThat(completed).isFalse()
assertThat(job.isActive).isTrue()
release.complete(Unit)
job.join()
assertThat(completed).isTrue()
assertThat(job.isCancelled).isFalse()
}

@Test
fun `정리 예외는 시작 호출자에게 실패나 취소로 전파하지 않는다`() =
runTest {
val cleaner =
object : ExerciseCacheCleaner {
override suspend fun cleanExpiredCache() {
throw IllegalStateException("정리 실패")
}
}

val job = launchExerciseCacheCleanup(cleaner)
job.join()

assertThat(job.isCompleted).isTrue()
assertThat(job.isCancelled).isFalse()
assertThat(coroutineContext[Job]?.isActive).isTrue()
}

@Test
fun `정리의 취소 예외를 성공으로 삼키지 않는다`() =
runTest {
val expected = CancellationException("정리 취소")
var completionCause: Throwable? = null
val cleaner =
object : ExerciseCacheCleaner {
override suspend fun cleanExpiredCache() {
throw expected
}
}

val job = launchExerciseCacheCleanup(cleaner)
job.invokeOnCompletion { completionCause = it }
job.join()

assertThat(job.isCancelled).isTrue()
assertThat(completionCause).isSameInstanceAs(expected)
assertThat(coroutineContext[Job]?.isActive).isTrue()
}

@Test
fun `정리 작업 취소는 실행 중인 정리에도 전달한다`() =
runTest {
var cancelled = false
val cleaner =
object : ExerciseCacheCleaner {
override suspend fun cleanExpiredCache() {
try {
awaitCancellation()
} finally {
cancelled = true
}
}
}

val job = launchExerciseCacheCleanup(cleaner)
runCurrent()
job.cancel()
job.join()

assertThat(cancelled).isTrue()
assertThat(job.isCancelled).isTrue()
assertThat(coroutineContext[Job]?.isActive).isTrue()
}
}
6 changes: 6 additions & 0 deletions data/src/main/java/com/project200/data/di/DatabaseModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import android.content.Context
import androidx.room.Room
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.project200.data.impl.RoomExerciseCacheCleaner
import com.project200.data.impl.RoomSessionDataCleaner
import com.project200.data.local.UndabangDatabase
import com.project200.data.local.UndabangTypeConverters
import com.project200.data.local.dao.ExerciseCountDao
import com.project200.data.local.dao.ExerciseRecordDao
import com.project200.data.local.dao.TimerDao
import com.project200.domain.manager.ExerciseCacheCleaner
import com.project200.domain.manager.SessionDataCleaner
import com.squareup.moshi.Moshi
import dagger.Binds
Expand All @@ -27,6 +29,10 @@ abstract class DatabaseModule {
@Singleton
abstract fun bindSessionDataCleaner(roomSessionDataCleaner: RoomSessionDataCleaner): SessionDataCleaner

@Binds
@Singleton
abstract fun bindExerciseCacheCleaner(roomExerciseCacheCleaner: RoomExerciseCacheCleaner): ExerciseCacheCleaner

companion object {
/** 메타데이터만 추가합니다. 기존 계정·대기 행·타이머는 재생성하지 않습니다. */
val MIGRATION_2_3 =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.project200.data.impl

import androidx.room.withTransaction
import com.project200.common.di.IoDispatcher
import com.project200.common.utils.ClockProvider
import com.project200.data.local.UndabangDatabase
import com.project200.data.local.dao.ExerciseCountDao
import com.project200.data.local.dao.ExerciseRecordDao
import com.project200.domain.manager.ExerciseCacheCleaner
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class RoomExerciseCacheCleaner
@Inject
constructor(
private val database: UndabangDatabase,
private val exerciseCountDao: ExerciseCountDao,
private val exerciseRecordDao: ExerciseRecordDao,
private val clockProvider: ClockProvider,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ExerciseCacheCleaner {
override suspend fun cleanExpiredCache() =
withContext(ioDispatcher) {
val cutoff = clockProvider.now().minusMonths(6)
val cutoffStart = cutoff.atStartOfDay()
// 모든 회원의 캐시를 정리하되, 짝을 찾는 SQL은 같은 회원 안에서만 비교합니다
database.withTransaction {
exerciseRecordDao.deleteExpiredSyncedDetails(cutoff, cutoffStart)
exerciseRecordDao.deleteExpiredSyncedListItems(cutoff, cutoffStart)
exerciseRecordDao.deleteExpiredQueryMarkers(cutoff)
exerciseCountDao.deleteBefore(cutoff)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ interface ExerciseCountDao {
startDate: LocalDate,
endDate: LocalDate,
)

/** 횟수는 서버 응답 캐시이므로 기준일 이전의 값만 정리합니다 */
@Query("DELETE FROM exercise_count WHERE date < :cutoff")
suspend fun deleteBefore(cutoff: LocalDate)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.project200.data.local.entity.ExerciseListItemEntity
import com.project200.data.local.entity.ExerciseRecordDetailEntity
import com.project200.data.local.entity.SyncState
import java.time.LocalDate
import java.time.LocalDateTime

@Dao
interface ExerciseRecordDao {
Expand Down Expand Up @@ -207,6 +208,56 @@ interface ExerciseRecordDao {
memberId: String,
localId: String,
)

/**
* 오래된 서버 캐시만 지웁니다. 같은 회원의 같은 로컬/서버 ID에 대기·로컬 전용 행이나
* 보관 기간 안의 행이 있으면 목록과 상세를 모두 보존합니다.
* 상세 정리와 같은 트랜잭션에서 호출해야 합니다
*/
@Query(
"DELETE FROM exercise_list_item " +
"WHERE date < :cutoff AND serverId IS NOT NULL AND syncState = :synced " +
"AND NOT EXISTS (" +
"SELECT 1 FROM exercise_list_item AS other " +
"WHERE other.memberId = exercise_list_item.memberId " +
"AND (other.localId = exercise_list_item.localId OR other.serverId = exercise_list_item.serverId) " +
"AND (other.syncState != :synced OR other.serverId IS NULL OR other.date >= :cutoff)) " +
"AND NOT EXISTS (" +
"SELECT 1 FROM exercise_record_detail AS detail " +
"WHERE detail.memberId = exercise_list_item.memberId " +
"AND (detail.localId = exercise_list_item.localId OR detail.serverId = exercise_list_item.serverId) " +
"AND (detail.syncState != :synced OR detail.serverId IS NULL OR detail.startedAt >= :cutoffStart))",
)
suspend fun deleteExpiredSyncedListItems(
cutoff: LocalDate,
cutoffStart: LocalDateTime,
synced: SyncState = SyncState.SYNCED,
)

/** 목록 없이 상세만 캐시한 기록도 시작 날짜로 정리합니다. 대기 중인 짝은 보존합니다 */
@Query(
"DELETE FROM exercise_record_detail " +
"WHERE startedAt < :cutoffStart AND serverId IS NOT NULL AND syncState = :synced " +
"AND NOT EXISTS (" +
"SELECT 1 FROM exercise_record_detail AS other " +
"WHERE other.memberId = exercise_record_detail.memberId " +
"AND (other.localId = exercise_record_detail.localId OR other.serverId = exercise_record_detail.serverId) " +
"AND (other.syncState != :synced OR other.serverId IS NULL OR other.startedAt >= :cutoffStart)) " +
"AND NOT EXISTS (" +
"SELECT 1 FROM exercise_list_item AS item " +
"WHERE item.memberId = exercise_record_detail.memberId " +
"AND (item.localId = exercise_record_detail.localId OR item.serverId = exercise_record_detail.serverId) " +
"AND (item.syncState != :synced OR item.serverId IS NULL OR item.date >= :cutoff))",
)
suspend fun deleteExpiredSyncedDetails(
cutoff: LocalDate,
cutoffStart: LocalDateTime,
synced: SyncState = SyncState.SYNCED,
)

/** 오래된 빈 응답이 캐시 적중으로 남지 않도록 목록·횟수 조회 마커도 함께 지웁니다 */
@Query("DELETE FROM exercise_cache_query WHERE date < :cutoff")
suspend fun deleteExpiredQueryMarkers(cutoff: LocalDate)
}

/** 서버 ID와 로컬 ID의 짝입니다 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import com.google.common.truth.Truth.assertThat
import com.project200.data.local.UndabangDatabase
import com.project200.data.local.UndabangTypeConverters
import com.project200.data.local.entity.ExerciseCountEntity
import com.project200.data.local.entity.ExerciseListItemEntity
import com.project200.data.local.entity.SimpleTimerEntity
import com.project200.data.local.entity.SyncState
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.test.runTest
Expand All @@ -17,8 +20,8 @@ import java.time.LocalDate
/**
* 출시 전 스키마 정책을 검증합니다.
*
* 파일의 version을 낮춰 구버전 DB를 만든 뒤 다시 열어, 마이그레이션 없이도
* 크래시하지 않고 재생성되는지 봅니다
* 2→3은 조회 마커만 추가하고 기존 데이터를 보존합니다.
* 미지원 파일 버전은 재생성하고, 같은 버전은 그대로 다시 엽니다
*/
@RunWith(RobolectricTestRunner::class)
class SchemaRecreatePolicyTest {
Expand Down Expand Up @@ -74,4 +77,62 @@ class SchemaRecreatePolicyTest {
assertThat(counts.single().count).isEqualTo(3)
database.close()
}

@Test
fun `2에서 3 마이그레이션은 대기 운동과 타이머를 보존하고 조회 마커만 추가한다`() =
runTest {
var database = openDatabase()
val pendingRecord =
ExerciseListItemEntity(
memberId = "member-a",
localId = "pending-exercise",
serverId = null,
syncState = SyncState.CREATE_PENDING,
date = date,
sortOrder = 0,
title = "대기 운동",
personalType = "WEIGHT",
startedAt = date.atTime(10, 0),
endedAt = date.atTime(11, 0),
thumbnailUrls = null,
)
val pendingTimer =
SimpleTimerEntity(
memberId = "member-a",
localId = "pending-timer",
serverId = null,
syncState = SyncState.CREATE_PENDING,
time = 60,
createRequestId = "timer-create",
pendingEditId = null,
editedAt = 1L,
)
database.exerciseRecordDao().upsertListItems(listOf(pendingRecord))
database.timerDao().upsertSimpleTimer(pendingTimer)
database.exerciseCountDao().upsertAll(listOf(ExerciseCountEntity("member-a", date, 2)))
database.close()

// 2→3의 유일한 스키마 차이인 조회 마커 테이블을 제거해 실제 이전 파일을 재현합니다
val path = context.getDatabasePath(UndabangDatabase.DATABASE_NAME).absolutePath
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { raw ->
raw.execSQL("DROP TABLE exercise_cache_query")
raw.version = 2
}
database = openDatabase()

assertThat(database.openHelper.readableDatabase.version).isEqualTo(3)
assertThat(database.exerciseRecordDao().getListByDate("member-a", date)).containsExactly(pendingRecord)
assertThat(database.timerDao().getSimpleTimer("member-a", "pending-timer")).isEqualTo(pendingTimer)
assertThat(database.exerciseCountDao().getRange("member-a", date, date))
.containsExactly(ExerciseCountEntity("member-a", date, 2))
database.openHelper.writableDatabase.execSQL(
"INSERT INTO exercise_cache_query(memberId, queryType, date) VALUES (?, ?, ?)",
arrayOf("member-a", "list", date.toString()),
)
database.openHelper.readableDatabase.query("SELECT queryType FROM exercise_cache_query").use { cursor ->
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getString(0)).isEqualTo("list")
}
database.close()
}
}
Loading
Loading