From 136b9f3b68730091e4fa60616cade9a4ecf50222 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 21 Aug 2026 21:32:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Room=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=EC=99=80=20=EC=9A=B4=EB=8F=99=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20=EC=BA=90=EC=8B=9C=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=B6=94=EA=B0=80=20#581=20#583?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 오프라인 캐시의 저장층입니다. 아직 아무도 이 DB를 주입받지 않습니다. - 모든 테이블에 memberId 컬럼을 두고 조회에 계정 조건을 겁니다 - 목록과 상세는 테이블을 나눴습니다. 한 테이블에 담으면 목록 응답을 저장할 때 detail과 location이, 상세를 저장할 때 date와 thumbnailUrls가 서로 덮여 사라집니다 - 목록의 date는 조회할 때 서버에 넘긴 날짜입니다. startedAt에서 파생시키면 자정을 넘긴 기록이 서버가 묶어준 날과 어긋납니다 - sortOrder는 서버 응답 순서입니다. SQL이 순서를 보장하지 않습니다 - 날짜는 ISO 문자열로 담아 사전순 비교가 시간순 비교가 되게 했고, 사진 목록은 JSON 컬럼입니다 --- data/build.gradle.kts | 5 + .../1.json | 196 ++++++++++++++++++ .../project200/data/local/UndabangDatabase.kt | 36 ++++ .../data/local/UndabangTypeConverters.kt | 55 +++++ .../data/local/dao/ExerciseCountDao.kt | 36 ++++ .../data/local/dao/ExerciseRecordDao.kt | 53 +++++ .../data/local/entity/CachedPicture.kt | 17 ++ .../data/local/entity/ExerciseCountEntity.kt | 21 ++ .../local/entity/ExerciseListItemEntity.kt | 32 +++ .../entity/ExerciseRecordDetailEntity.kt | 28 +++ 10 files changed, 479 insertions(+) create mode 100644 data/schemas/com.project200.data.local.UndabangDatabase/1.json create mode 100644 data/src/main/java/com/project200/data/local/UndabangDatabase.kt create mode 100644 data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt create mode 100644 data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt create mode 100644 data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt create mode 100644 data/src/main/java/com/project200/data/local/entity/CachedPicture.kt create mode 100644 data/src/main/java/com/project200/data/local/entity/ExerciseCountEntity.kt create mode 100644 data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt create mode 100644 data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 7634034f..56f8abef 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -20,6 +20,11 @@ android { } } +// Room 스키마를 파일로 남겨 마이그레이션 검증에 씁니다 +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + dependencies { implementation(projects.domain) implementation(projects.common) diff --git a/data/schemas/com.project200.data.local.UndabangDatabase/1.json b/data/schemas/com.project200.data.local.UndabangDatabase/1.json new file mode 100644 index 00000000..3c3248cb --- /dev/null +++ b/data/schemas/com.project200.data.local.UndabangDatabase/1.json @@ -0,0 +1,196 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "953d4eadbefa6d56d23af6cd5623fb2d", + "entities": [ + { + "tableName": "exercise_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `date` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`memberId`, `date`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "date" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exercise_list_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `recordId` INTEGER NOT NULL, `date` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `title` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `thumbnailUrls` TEXT, PRIMARY KEY(`memberId`, `recordId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "personalType", + "columnName": "personalType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrls", + "columnName": "thumbnailUrls", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "recordId" + ] + }, + "indices": [ + { + "name": "index_exercise_list_item_memberId_date", + "unique": false, + "columnNames": [ + "memberId", + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_list_item_memberId_date` ON `${TABLE_NAME}` (`memberId`, `date`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exercise_record_detail", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `recordId` INTEGER NOT NULL, `title` TEXT NOT NULL, `detail` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `location` TEXT NOT NULL, `pictures` TEXT, PRIMARY KEY(`memberId`, `recordId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "detail", + "columnName": "detail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "personalType", + "columnName": "personalType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pictures", + "columnName": "pictures", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "recordId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '953d4eadbefa6d56d23af6cd5623fb2d')" + ] + } +} \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/local/UndabangDatabase.kt b/data/src/main/java/com/project200/data/local/UndabangDatabase.kt new file mode 100644 index 00000000..c701526f --- /dev/null +++ b/data/src/main/java/com/project200/data/local/UndabangDatabase.kt @@ -0,0 +1,36 @@ +package com.project200.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.project200.data.local.dao.ExerciseCountDao +import com.project200.data.local.dao.ExerciseRecordDao +import com.project200.data.local.entity.ExerciseCountEntity +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.ExerciseRecordDetailEntity + +/** + * 오프라인 캐시용 로컬 데이터베이스입니다. + * + * 모든 테이블은 memberId 컬럼을 두고 조회에도 계정 조건을 겁니다 + * 계정이 바뀌면 이전 사용자의 행이 보이면 안 되기 때문입니다 + */ +@Database( + entities = [ + ExerciseCountEntity::class, + ExerciseListItemEntity::class, + ExerciseRecordDetailEntity::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters(UndabangTypeConverters::class) +abstract class UndabangDatabase : RoomDatabase() { + abstract fun exerciseCountDao(): ExerciseCountDao + + abstract fun exerciseRecordDao(): ExerciseRecordDao + + companion object { + const val DATABASE_NAME = "undabang.db" + } +} diff --git a/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt b/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt new file mode 100644 index 00000000..d1246423 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt @@ -0,0 +1,55 @@ +package com.project200.data.local + +import androidx.room.ProvidedTypeConverter +import androidx.room.TypeConverter +import com.project200.data.local.entity.CachedPicture +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * SQLite가 아는 타입은 INTEGER, REAL, TEXT, BLOB 넷뿐이라 나머지는 여기서 문자열로 바꿉니다. + * + * 날짜는 ISO 문자열로 담습니다. 사전순 비교가 곧 시간순 비교라서 + * BETWEEN과 ORDER BY를 컬럼에 그대로 걸 수 있습니다 + * + * 사진 목록은 JSON 문자열입니다. 사진을 따로 조회할 일이 없고 항상 기록과 함께 읽히며, + * 리스트 순서가 그대로 보존됩니다 + */ +@ProvidedTypeConverter +class UndabangTypeConverters(moshi: Moshi) { + private val stringListAdapter = + moshi.adapter>( + Types.newParameterizedType(List::class.java, String::class.java), + ) + + private val pictureListAdapter = + moshi.adapter>( + Types.newParameterizedType(List::class.java, CachedPicture::class.java), + ) + + @TypeConverter + fun fromLocalDate(value: LocalDate?): String? = value?.toString() + + @TypeConverter + fun toLocalDate(value: String?): LocalDate? = value?.let(LocalDate::parse) + + @TypeConverter + fun fromLocalDateTime(value: LocalDateTime?): String? = value?.toString() + + @TypeConverter + fun toLocalDateTime(value: String?): LocalDateTime? = value?.let(LocalDateTime::parse) + + @TypeConverter + fun fromStringList(value: List?): String? = value?.let(stringListAdapter::toJson) + + @TypeConverter + fun toStringList(value: String?): List? = value?.let(stringListAdapter::fromJson) + + @TypeConverter + fun fromPictureList(value: List?): String? = value?.let(pictureListAdapter::toJson) + + @TypeConverter + fun toPictureList(value: String?): List? = value?.let(pictureListAdapter::fromJson) +} diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt new file mode 100644 index 00000000..8b051432 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseCountDao.kt @@ -0,0 +1,36 @@ +package com.project200.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.project200.data.local.entity.ExerciseCountEntity +import kotlinx.coroutines.flow.Flow +import java.time.LocalDate + +@Dao +interface ExerciseCountDao { + @Query( + "SELECT * FROM exercise_count " + + "WHERE memberId = :memberId AND date BETWEEN :startDate AND :endDate " + + "ORDER BY date", + ) + fun observeRange( + memberId: String, + startDate: LocalDate, + endDate: LocalDate, + ): Flow> + + @Upsert + suspend fun upsertAll(counts: List) + + /** 서버 응답에는 운동한 날만 들어 있어서, 0건이 된 날을 지우려면 구간을 먼저 비워야 합니다 */ + @Query( + "DELETE FROM exercise_count " + + "WHERE memberId = :memberId AND date BETWEEN :startDate AND :endDate", + ) + suspend fun deleteRange( + memberId: String, + startDate: LocalDate, + endDate: LocalDate, + ) +} diff --git a/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt new file mode 100644 index 00000000..5ed6fdf4 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/dao/ExerciseRecordDao.kt @@ -0,0 +1,53 @@ +package com.project200.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.ExerciseRecordDetailEntity +import kotlinx.coroutines.flow.Flow +import java.time.LocalDate + +@Dao +interface ExerciseRecordDao { + @Query( + "SELECT * FROM exercise_list_item " + + "WHERE memberId = :memberId AND date = :date " + + "ORDER BY sortOrder", + ) + fun observeListByDate( + memberId: String, + date: LocalDate, + ): Flow> + + @Upsert + suspend fun upsertListItems(items: List) + + /** 서버에서 지워진 기록이 남지 않도록 그날 목록을 갈아끼울 때 씁니다 */ + @Query("DELETE FROM exercise_list_item WHERE memberId = :memberId AND date = :date") + suspend fun deleteListByDate( + memberId: String, + date: LocalDate, + ) + + @Query("SELECT * FROM exercise_record_detail WHERE memberId = :memberId AND recordId = :recordId") + fun observeDetail( + memberId: String, + recordId: Long, + ): Flow + + @Upsert + suspend fun upsertDetail(detail: ExerciseRecordDetailEntity) + + @Query("DELETE FROM exercise_record_detail WHERE memberId = :memberId AND recordId = :recordId") + suspend fun deleteDetail( + memberId: String, + recordId: Long, + ) + + @Query("DELETE FROM exercise_list_item WHERE memberId = :memberId AND recordId = :recordId") + suspend fun deleteListItem( + memberId: String, + recordId: Long, + ) +} diff --git a/data/src/main/java/com/project200/data/local/entity/CachedPicture.kt b/data/src/main/java/com/project200/data/local/entity/CachedPicture.kt new file mode 100644 index 00000000..84330782 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/CachedPicture.kt @@ -0,0 +1,17 @@ +package com.project200.data.local.entity + +import com.squareup.moshi.JsonClass + +/** + * 상세 화면에 뿌릴 사진 한 장입니다. + * + * 사진 파일이 아니라 주소만 담습니다. 오프라인에서 사진이 실제로 뜨는 건 + * Coil과 Glide의 디스크 캐시가 맡습니다 + * + * id는 로컬 조회 키가 아니라 이미지 삭제 요청에 서버로 되돌려 보낼 값입니다 + */ +@JsonClass(generateAdapter = true) +data class CachedPicture( + val id: Long, + val url: String, +) diff --git a/data/src/main/java/com/project200/data/local/entity/ExerciseCountEntity.kt b/data/src/main/java/com/project200/data/local/entity/ExerciseCountEntity.kt new file mode 100644 index 00000000..646d78ad --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/ExerciseCountEntity.kt @@ -0,0 +1,21 @@ +package com.project200.data.local.entity + +import androidx.room.Entity +import java.time.LocalDate + +/** + * 캘린더에 찍히는 날짜별 운동 횟수입니다. + * + * 로컬 기록 행을 세지 않고 서버 응답을 그대로 담습니다 + * 캘린더 API는 개수만 주고 기록 자체를 주지 않아서, 세는 방식이면 + * 캘린더만 열어본 달이 오프라인에서 빈 달로 보입니다 + */ +@Entity( + tableName = "exercise_count", + primaryKeys = ["memberId", "date"], +) +data class ExerciseCountEntity( + val memberId: String, + val date: LocalDate, + val count: Int, +) diff --git a/data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt b/data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt new file mode 100644 index 00000000..6d081224 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/ExerciseListItemEntity.kt @@ -0,0 +1,32 @@ +package com.project200.data.local.entity + +import androidx.room.Entity +import androidx.room.Index +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * 하루 운동 기록 목록의 한 줄입니다. + * + * date는 목록을 받아올 때 서버에 넘긴 조회 날짜입니다. 응답에는 들어 있지 않지만 + * startedAt에서 파생시키면 자정을 넘긴 기록이 서버가 묶어준 날과 어긋나서 컬럼으로 둡니다 + * + * sortOrder는 서버가 준 응답 순서입니다. SQL은 순서를 보장하지 않아서 + * 이걸로 정렬해야 목록이 서버와 같은 차례로 보입니다 + */ +@Entity( + tableName = "exercise_list_item", + primaryKeys = ["memberId", "recordId"], + indices = [Index(value = ["memberId", "date"])], +) +data class ExerciseListItemEntity( + val memberId: String, + val recordId: Long, + val date: LocalDate, + val sortOrder: Int, + val title: String, + val personalType: String, + val startedAt: LocalDateTime, + val endedAt: LocalDateTime, + val thumbnailUrls: List?, +) diff --git a/data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt b/data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt new file mode 100644 index 00000000..be55e49d --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/ExerciseRecordDetailEntity.kt @@ -0,0 +1,28 @@ +package com.project200.data.local.entity + +import androidx.room.Entity +import java.time.LocalDateTime + +/** + * 운동 기록 상세입니다. + * + * 목록과 컬럼이 겹치지만 테이블을 나눕니다. 한 테이블에 담으면 목록 응답을 저장할 때 + * detail과 location이, 상세를 저장할 때 date와 thumbnailUrls가 서로 덮여 사라집니다 + * + * 상세 응답에는 기록 ID가 없어서 recordId는 조회할 때 넘긴 값으로 채웁니다 + */ +@Entity( + tableName = "exercise_record_detail", + primaryKeys = ["memberId", "recordId"], +) +data class ExerciseRecordDetailEntity( + val memberId: String, + val recordId: Long, + val title: String, + val detail: String, + val personalType: String, + val startedAt: LocalDateTime, + val endedAt: LocalDateTime, + val location: String, + val pictures: List?, +) From d6a7f6bc44d0b469ad8130bacd34a2c8a19d1bea Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 21 Aug 2026 21:32:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20=EB=A1=9C=EC=BB=AC=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EC=97=B0=EA=B2=B0=20#581?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계정 경계를 테이블보다 먼저 잠급니다. 로그아웃이나 계정 전환 때 이전 사용자의 데이터가 남으면 안 됩니다. - 세션을 지우는 경로가 MainViewModel.transitionToLogin()과 SettingFragment.clearLocalSession() 둘인데 모두 ClearSessionUseCase를 거치므로 삭제를 여기 한 곳에 연결합니다 - domain은 Room을 모르게 합니다. FcmTokenSyncScheduler와 같은 패턴으로 domain에 SessionDataCleaner 인터페이스를 두고 data 구현이 지웁니다 --- .../com/project200/data/di/DatabaseModule.kt | 61 +++++++++++++++++++ .../data/impl/RoomSessionDataCleaner.kt | 22 +++++++ .../domain/manager/SessionDataCleaner.kt | 10 +++ .../domain/usecase/ClearSessionUseCase.kt | 6 +- .../domain/usecase/ClearSessionUseCaseTest.kt | 22 ++++++- 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 data/src/main/java/com/project200/data/di/DatabaseModule.kt create mode 100644 data/src/main/java/com/project200/data/impl/RoomSessionDataCleaner.kt create mode 100644 domain/src/main/java/com/project200/domain/manager/SessionDataCleaner.kt diff --git a/data/src/main/java/com/project200/data/di/DatabaseModule.kt b/data/src/main/java/com/project200/data/di/DatabaseModule.kt new file mode 100644 index 00000000..2150d0fd --- /dev/null +++ b/data/src/main/java/com/project200/data/di/DatabaseModule.kt @@ -0,0 +1,61 @@ +package com.project200.data.di + +import android.content.Context +import androidx.room.Room +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.domain.manager.SessionDataCleaner +import com.squareup.moshi.Moshi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DatabaseModule { + @Binds + @Singleton + abstract fun bindSessionDataCleaner(roomSessionDataCleaner: RoomSessionDataCleaner): SessionDataCleaner + + companion object { + @Provides + @Singleton + fun provideUndabangTypeConverters(moshi: Moshi): UndabangTypeConverters { + return UndabangTypeConverters(moshi) + } + + @Provides + @Singleton + fun provideUndabangDatabase( + @ApplicationContext context: Context, + typeConverters: UndabangTypeConverters, + ): UndabangDatabase { + return Room.databaseBuilder( + context, + UndabangDatabase::class.java, + UndabangDatabase.DATABASE_NAME, + ) + .addTypeConverter(typeConverters) + .build() + } + + @Provides + @Singleton + fun provideExerciseCountDao(database: UndabangDatabase): ExerciseCountDao { + return database.exerciseCountDao() + } + + @Provides + @Singleton + fun provideExerciseRecordDao(database: UndabangDatabase): ExerciseRecordDao { + return database.exerciseRecordDao() + } + } +} diff --git a/data/src/main/java/com/project200/data/impl/RoomSessionDataCleaner.kt b/data/src/main/java/com/project200/data/impl/RoomSessionDataCleaner.kt new file mode 100644 index 00000000..a97245fb --- /dev/null +++ b/data/src/main/java/com/project200/data/impl/RoomSessionDataCleaner.kt @@ -0,0 +1,22 @@ +package com.project200.data.impl + +import com.project200.common.di.IoDispatcher +import com.project200.data.local.UndabangDatabase +import com.project200.domain.manager.SessionDataCleaner +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RoomSessionDataCleaner + @Inject + constructor( + private val database: UndabangDatabase, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + ) : SessionDataCleaner { + override suspend fun clearAll() = + withContext(ioDispatcher) { + database.clearAllTables() + } + } diff --git a/domain/src/main/java/com/project200/domain/manager/SessionDataCleaner.kt b/domain/src/main/java/com/project200/domain/manager/SessionDataCleaner.kt new file mode 100644 index 00000000..2928b522 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/manager/SessionDataCleaner.kt @@ -0,0 +1,10 @@ +package com.project200.domain.manager + +/** + * 계정 경계를 지키기 위해 로컬에 쌓인 캐시를 지웁니다. + * 로그아웃이나 계정 전환 때 이전 사용자의 데이터가 남으면 안 됩니다. + * 구현은 Room을 쓰는 data 모듈에 있습니다. + */ +interface SessionDataCleaner { + suspend fun clearAll() +} diff --git a/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt index c8a34dd1..3c629f05 100644 --- a/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt @@ -1,6 +1,7 @@ package com.project200.domain.usecase import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.manager.SessionDataCleaner import com.project200.domain.repository.AuthRepository import javax.inject.Inject @@ -9,10 +10,13 @@ class ClearSessionUseCase constructor( private val authRepository: AuthRepository, private val fcmTokenSyncScheduler: FcmTokenSyncScheduler, + private val sessionDataCleaner: SessionDataCleaner, ) { suspend operator fun invoke() { authRepository.clearSession() - // 회원ID가 지워져 실행돼도 SKIPPED로 끝나지만 예약을 남겨둘 이유가 없다 + // 회원ID가 지워져 실행돼도 SKIPPED로 끝나지만 예약을 남겨둘 이유가 없습니다 fcmTokenSyncScheduler.cancel() + // 다음 사용자가 이전 사용자의 캐시를 보면 안 됩니다 + sessionDataCleaner.clearAll() } } diff --git a/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt index 43a0e3d4..17a4043a 100644 --- a/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt @@ -1,6 +1,7 @@ package com.project200.domain.usecase import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.manager.SessionDataCleaner import com.project200.domain.repository.AuthRepository import io.mockk.Runs import io.mockk.coEvery @@ -28,11 +29,14 @@ class ClearSessionUseCaseTest { @MockK private lateinit var mockFcmTokenSyncScheduler: FcmTokenSyncScheduler + @MockK + private lateinit var mockSessionDataCleaner: SessionDataCleaner + private lateinit var useCase: ClearSessionUseCase @Before fun setUp() { - useCase = ClearSessionUseCase(mockRepository, mockFcmTokenSyncScheduler) + useCase = ClearSessionUseCase(mockRepository, mockFcmTokenSyncScheduler, mockSessionDataCleaner) } @Test @@ -40,6 +44,7 @@ class ClearSessionUseCaseTest { // Given coEvery { mockRepository.clearSession() } just Runs every { mockFcmTokenSyncScheduler.cancel() } just Runs + coEvery { mockSessionDataCleaner.clearAll() } just Runs // When useCase() @@ -53,6 +58,7 @@ class ClearSessionUseCaseTest { // Given coEvery { mockRepository.clearSession() } just Runs every { mockFcmTokenSyncScheduler.cancel() } just Runs + coEvery { mockSessionDataCleaner.clearAll() } just Runs // When useCase() @@ -60,4 +66,18 @@ class ClearSessionUseCaseTest { // Then verify(exactly = 1) { mockFcmTokenSyncScheduler.cancel() } } + + @Test + fun `invoke 호출 시 로컬 캐시를 지운다`() = runTest { + // Given + coEvery { mockRepository.clearSession() } just Runs + every { mockFcmTokenSyncScheduler.cancel() } just Runs + coEvery { mockSessionDataCleaner.clearAll() } just Runs + + // When + useCase() + + // Then + coVerify(exactly = 1) { mockSessionDataCleaner.clearAll() } + } } From 0d349ad042d1757cc4cd5f0a32221c65ff29afe5 Mon Sep 17 00:00:00 2001 From: edv-Shin Date: Fri, 21 Aug 2026 21:32:50 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EC=9A=B4=EB=8F=99=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20DataSource=20=EB=B6=84=EB=A6=AC=20#583?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 API 호출을 RemoteDataSource로 옮기고 Room 접근을 LocalDataSource로 분리합니다. apiCallBuilder 계약과 화면 동작은 그대로입니다. - LocalDataSource가 memberId를 자기 안에서만 읽습니다. 호출부가 넘길 수 없으니 계정 조건을 빠뜨린 쿼리를 쓸 자리가 없습니다 - 캘린더와 목록은 구간을 비우고 다시 채웁니다. 서버 응답에는 운동한 날과 남아 있는 기록만 오니까 upsert만 하면 0건이 된 날과 삭제된 기록이 캐시에 계속 남습니다 - LocalDataSource는 아직 호출부가 없습니다. 읽기를 Flow로 바꾸는 건 #584입니다 --- .../ExerciseRecordLocalDataSource.kt | 106 ++++++++++++++++++ .../ExerciseRecordRemoteDataSource.kt | 51 +++++++++ .../data/impl/ExerciseRecordRepositoryImpl.kt | 20 ++-- .../data/mapper/ExerciseCacheMapper.kt | 84 ++++++++++++++ 4 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt create mode 100644 data/src/main/java/com/project200/data/datasource/ExerciseRecordRemoteDataSource.kt create mode 100644 data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt diff --git a/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt b/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt new file mode 100644 index 00000000..b1a98305 --- /dev/null +++ b/data/src/main/java/com/project200/data/datasource/ExerciseRecordLocalDataSource.kt @@ -0,0 +1,106 @@ +package com.project200.data.datasource + +import androidx.room.withTransaction +import com.project200.data.local.PreferenceManager +import com.project200.data.local.UndabangDatabase +import com.project200.data.local.dao.ExerciseCountDao +import com.project200.data.local.dao.ExerciseRecordDao +import com.project200.data.mapper.toEntity +import com.project200.data.mapper.toModel +import com.project200.domain.model.ExerciseCount +import com.project200.domain.model.ExerciseListItem +import com.project200.domain.model.ExerciseRecord +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import timber.log.Timber +import java.time.LocalDate +import javax.inject.Inject + +/** + * 운동 기록 캐시를 읽고 씁니다. + * + * 계정 조건을 여기서 한 번에 겁니다. 호출부가 memberId를 넘기지 않게 해서 + * 조건을 빠뜨린 쿼리가 나올 자리를 없앱니다 + * + * 로그아웃 상태에서는 읽기가 빈 값을, 쓰기가 아무 일도 하지 않습니다 + */ +class ExerciseRecordLocalDataSource + @Inject + constructor( + private val database: UndabangDatabase, + private val exerciseCountDao: ExerciseCountDao, + private val exerciseRecordDao: ExerciseRecordDao, + private val preferenceManager: PreferenceManager, + ) { + fun observeCountsByRange( + startDate: LocalDate, + endDate: LocalDate, + ): Flow> { + val memberId = currentMemberId() ?: return flowOf(emptyList()) + return exerciseCountDao.observeRange(memberId, startDate, endDate) + .map { entities -> entities.map { it.toModel() } } + } + + /** 서버 응답에는 운동한 날만 들어 있어서 구간을 비우고 다시 채웁니다 */ + suspend fun replaceCountsByRange( + startDate: LocalDate, + endDate: LocalDate, + counts: List, + ) { + val memberId = currentMemberId() ?: return + database.withTransaction { + exerciseCountDao.deleteRange(memberId, startDate, endDate) + exerciseCountDao.upsertAll(counts.map { it.toEntity(memberId) }) + } + } + + fun observeListByDate(date: LocalDate): Flow> { + val memberId = currentMemberId() ?: return flowOf(emptyList()) + return exerciseRecordDao.observeListByDate(memberId, date) + .map { entities -> entities.map { it.toModel() } } + } + + /** 서버에서 지워진 기록이 남지 않도록 그날 목록을 통째로 갈아끼웁니다 */ + suspend fun replaceListByDate( + date: LocalDate, + items: List, + ) { + val memberId = currentMemberId() ?: return + database.withTransaction { + exerciseRecordDao.deleteListByDate(memberId, date) + exerciseRecordDao.upsertListItems( + items.mapIndexed { index, item -> item.toEntity(memberId, date, index) }, + ) + } + } + + fun observeDetail(recordId: Long): Flow { + val memberId = currentMemberId() ?: return flowOf(null) + return exerciseRecordDao.observeDetail(memberId, recordId) + .map { entity -> entity?.toModel() } + } + + suspend fun saveDetail( + recordId: Long, + record: ExerciseRecord, + ) { + val memberId = currentMemberId() ?: return + exerciseRecordDao.upsertDetail(record.toEntity(memberId, recordId)) + } + + /** 기록이 삭제되면 목록과 상세를 함께 지웁니다 */ + suspend fun deleteRecord(recordId: Long) { + val memberId = currentMemberId() ?: return + database.withTransaction { + exerciseRecordDao.deleteListItem(memberId, recordId) + exerciseRecordDao.deleteDetail(memberId, recordId) + } + } + + private fun currentMemberId(): String? { + return preferenceManager.getMemberId().also { + if (it == null) Timber.w("회원ID가 없어 운동 기록 캐시를 건너뜁니다") + } + } + } diff --git a/data/src/main/java/com/project200/data/datasource/ExerciseRecordRemoteDataSource.kt b/data/src/main/java/com/project200/data/datasource/ExerciseRecordRemoteDataSource.kt new file mode 100644 index 00000000..1a642258 --- /dev/null +++ b/data/src/main/java/com/project200/data/datasource/ExerciseRecordRemoteDataSource.kt @@ -0,0 +1,51 @@ +package com.project200.data.datasource + +import com.project200.data.api.ApiService +import com.project200.data.dto.BaseResponse +import com.project200.data.dto.ExerciseIdDto +import com.project200.data.dto.GetExerciseCountByRangeDTO +import com.project200.data.dto.GetExerciseRecordData +import com.project200.data.dto.GetExerciseRecordListDto +import com.project200.data.dto.PatchExerciseRequestDto +import com.project200.data.dto.PostExerciseRequestDto +import com.project200.data.dto.PostExerciseResponseDTO +import okhttp3.MultipartBody +import java.time.LocalDate +import javax.inject.Inject + +/** 운동 기록의 서버 호출을 모읍니다. 응답 래핑과 오류 변환은 호출부의 apiCallBuilder가 맡습니다 */ +class ExerciseRecordRemoteDataSource + @Inject + constructor( + private val apiService: ApiService, + ) { + suspend fun getExerciseCountsByRange( + startDate: LocalDate, + endDate: LocalDate, + ): BaseResponse> = apiService.getExerciseCountsByRange(startDate, endDate) + + suspend fun getExerciseRecordDetail(recordId: Long): BaseResponse = + apiService.getExerciseRecordDetail(recordId) + + suspend fun getExerciseList(date: LocalDate): BaseResponse> = apiService.getExerciseList(date) + + suspend fun postExerciseRecord(request: PostExerciseRequestDto): BaseResponse = + apiService.postExerciseRecord(request) + + suspend fun patchExerciseRecord( + recordId: Long, + request: PatchExerciseRequestDto, + ): BaseResponse = apiService.patchExerciseRecord(recordId, request) + + suspend fun postExerciseImages( + recordId: Long, + pictures: List, + ): BaseResponse = apiService.postExerciseImages(recordId, pictures) + + suspend fun deleteExerciseImages( + recordId: Long, + pictureIds: List, + ): BaseResponse = apiService.deleteExerciseImages(recordId, pictureIds) + + suspend fun deleteExerciseRecord(recordId: Long): BaseResponse = apiService.deleteExerciseRecord(recordId) + } diff --git a/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt index d1c48479..cbb06f67 100644 --- a/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/ExerciseRecordRepositoryImpl.kt @@ -3,7 +3,7 @@ package com.project200.data.impl import android.content.Context import androidx.core.net.toUri import com.project200.common.di.IoDispatcher -import com.project200.data.api.ApiService +import com.project200.data.datasource.ExerciseRecordRemoteDataSource import com.project200.data.dto.ExerciseIdDto import com.project200.data.dto.GetExerciseCountByRangeDTO import com.project200.data.dto.GetExerciseRecordData @@ -30,7 +30,7 @@ import kotlin.coroutines.cancellation.CancellationException class ExerciseRecordRepositoryImpl @Inject constructor( - private val apiService: ApiService, + private val remoteDataSource: ExerciseRecordRemoteDataSource, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @ApplicationContext private val context: Context, ) : ExerciseRecordRepository { @@ -40,7 +40,7 @@ class ExerciseRecordRepositoryImpl ): BaseResult> { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.getExerciseCountsByRange(startDate, endDate) }, + apiCall = { remoteDataSource.getExerciseCountsByRange(startDate, endDate) }, mapper = { dtoList: List? -> dtoList?.map { it.toModel() } ?: throw NoSuchElementException("구간별 운동 횟수 조회 응답 데이터가 없습니다.") }, @@ -50,7 +50,7 @@ class ExerciseRecordRepositoryImpl override suspend fun getExerciseDetail(recordId: Long): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.getExerciseRecordDetail(recordId) }, + apiCall = { remoteDataSource.getExerciseRecordDetail(recordId) }, mapper = { dto: GetExerciseRecordData? -> dto?.toModel() ?: throw NoSuchElementException("운동 상세 정보 응답 데이터가 없습니다.") }, @@ -60,7 +60,7 @@ class ExerciseRecordRepositoryImpl override suspend fun getExerciseRecordList(date: LocalDate): BaseResult> { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.getExerciseList(date) }, + apiCall = { remoteDataSource.getExerciseList(date) }, mapper = { dtoList: List? -> dtoList?.map { it.toModel() } ?: emptyList() }, @@ -70,7 +70,7 @@ class ExerciseRecordRepositoryImpl override suspend fun createExerciseRecord(record: ExerciseRecord): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.postExerciseRecord(record.toPostExerciseDTO()) }, + apiCall = { remoteDataSource.postExerciseRecord(record.toPostExerciseDTO()) }, mapper = { dto: PostExerciseResponseDTO? -> dto?.toModel() ?: throw NoSuchElementException("운동 기록 생성 응답 데이터가 없습니다.") }, @@ -83,7 +83,7 @@ class ExerciseRecordRepositoryImpl ): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.patchExerciseRecord(recordId, record.toPatchExerciseDTO()) }, + apiCall = { remoteDataSource.patchExerciseRecord(recordId, record.toPatchExerciseDTO()) }, mapper = { exerciseIdDto: ExerciseIdDto? -> exerciseIdDto?.exerciseId ?: throw NoSuchElementException("운동 기록 수정 응답 데이터가 없습니다.") }, @@ -118,7 +118,7 @@ class ExerciseRecordRepositoryImpl return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.postExerciseImages(recordId, imageParts) }, + apiCall = { remoteDataSource.postExerciseImages(recordId, imageParts) }, mapper = { exerciseIdDto: ExerciseIdDto? -> exerciseIdDto?.exerciseId ?: throw NoSuchElementException("이미지 업로드 응답 데이터가 없습니다.") }, @@ -131,7 +131,7 @@ class ExerciseRecordRepositoryImpl ): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.deleteExerciseImages(recordId, imageIds) }, + apiCall = { remoteDataSource.deleteExerciseImages(recordId, imageIds) }, mapper = { Unit }, ) } @@ -139,7 +139,7 @@ class ExerciseRecordRepositoryImpl override suspend fun deleteExerciseRecord(recordId: Long): BaseResult { return apiCallBuilder( ioDispatcher = ioDispatcher, - apiCall = { apiService.deleteExerciseRecord(recordId) }, + apiCall = { remoteDataSource.deleteExerciseRecord(recordId) }, mapper = { Unit }, ) } diff --git a/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt b/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt new file mode 100644 index 00000000..e4db292c --- /dev/null +++ b/data/src/main/java/com/project200/data/mapper/ExerciseCacheMapper.kt @@ -0,0 +1,84 @@ +package com.project200.data.mapper + +import com.project200.data.local.entity.CachedPicture +import com.project200.data.local.entity.ExerciseCountEntity +import com.project200.data.local.entity.ExerciseListItemEntity +import com.project200.data.local.entity.ExerciseRecordDetailEntity +import com.project200.domain.model.ExerciseCount +import com.project200.domain.model.ExerciseListItem +import com.project200.domain.model.ExerciseRecord +import com.project200.domain.model.ExerciseRecordPicture +import java.time.LocalDate + +fun ExerciseCount.toEntity(memberId: String): ExerciseCountEntity { + return ExerciseCountEntity( + memberId = memberId, + date = date, + count = count, + ) +} + +fun ExerciseCountEntity.toModel(): ExerciseCount { + return ExerciseCount( + date = date, + count = count, + ) +} + +fun ExerciseListItem.toEntity( + memberId: String, + date: LocalDate, + sortOrder: Int, +): ExerciseListItemEntity { + return ExerciseListItemEntity( + memberId = memberId, + recordId = recordId, + date = date, + sortOrder = sortOrder, + title = title, + personalType = type, + startedAt = startTime, + endedAt = endTime, + thumbnailUrls = imageUrl, + ) +} + +fun ExerciseListItemEntity.toModel(): ExerciseListItem { + return ExerciseListItem( + recordId = recordId, + title = title, + type = personalType, + startTime = startedAt, + endTime = endedAt, + imageUrl = thumbnailUrls, + ) +} + +fun ExerciseRecord.toEntity( + memberId: String, + recordId: Long, +): ExerciseRecordDetailEntity { + return ExerciseRecordDetailEntity( + memberId = memberId, + recordId = recordId, + title = title, + detail = detail, + personalType = personalType, + startedAt = startedAt, + endedAt = endedAt, + location = location, + pictures = pictures?.map { CachedPicture(it.id, it.url) }, + ) +} + +fun ExerciseRecordDetailEntity.toModel(): ExerciseRecord { + return ExerciseRecord( + title = title, + detail = detail, + personalType = personalType, + startedAt = startedAt, + endedAt = endedAt, + location = location, + pictures = pictures?.map { ExerciseRecordPicture(it.id, it.url) }, + ) +}