diff --git a/AGENTS.md b/AGENTS.md index 23a07c42..b5604632 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -283,10 +283,19 @@ abstract class RepositoryModule { 로컬에 데이터를 쌓는 저장소를 추가하면 `ClearSessionUseCase`에 정리를 연결한다. `domain`의 `SessionDataCleaner` 인터페이스를 통해 `data` 구현이 지운다. +정리 범위는 이탈 이유가 정한다. `ClearSessionUseCase(reason)`에 `SessionExitReason`을 넘긴다. 호출부는 무엇을 지울지 고르지 않고 무슨 일이 있었는지만 알린다. + +|이유|캐시|회원ID|토큰| +|---|---|---|---| +|`USER_INITIATED` 로그아웃, 탈퇴|삭제|삭제|삭제| +|`FORCED` 토큰 만료, 갱신 실패, 진입 실패|보존|보존|삭제| + +- 강제 이탈에서 캐시를 남기는 이유는 아직 서버에 올리지 못한 전송 대기 행을 지키기 위해서다. 사용자가 의도하지 않은 이탈로 오프라인에서 만든 데이터를 잃게 하지 않는다 +- 강제 이탈에서 회원ID도 남긴다. 회원ID가 없으면 재로그인 때 계정 전환 판정이 "이전 값과 다름"으로 떨어져 캐시를 지운다 - 캐시 삭제를 회원ID 소거보다 먼저 한다. 계정 스코프 삭제로 좁히면 회원ID 없이는 지울 대상을 특정할 수 없다 - 캐시 삭제가 실패해도 세션 정리와 예약 작업 취소는 수행한다 - 계정이 바뀔 때도 지운다. `AuthRepositoryImpl.checkIsRegistered()`가 회원ID를 저장할 때 이전 값과 다르면 캐시를 먼저 비운다 -- 예약한 Worker는 함께 취소한다 +- 예약한 Worker는 이유와 무관하게 취소한다. 세션이 없으면 전송할 수 없다. 강제 이탈에서 남긴 대기 행은 재로그인 때 다시 예약해 올린다 ## 6. API Convention diff --git a/app/src/main/java/com/project200/undabang/main/MainViewModel.kt b/app/src/main/java/com/project200/undabang/main/MainViewModel.kt index fa74e743..a48e671f 100644 --- a/app/src/main/java/com/project200/undabang/main/MainViewModel.kt +++ b/app/src/main/java/com/project200/undabang/main/MainViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.project200.common.utils.NetworkMonitor import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.model.SessionExitReason import com.project200.domain.model.UpdateCheckResult import com.project200.domain.usecase.CheckForUpdateUseCase import com.project200.domain.usecase.ClearSessionUseCase @@ -164,7 +165,7 @@ class MainViewModel } private suspend fun transitionToLogin() { - runCatching { clearSessionUseCase() } + runCatching { clearSessionUseCase(SessionExitReason.FORCED) } .onFailure { Timber.e(it, "세션 정리 실패 - 전이는 계속") } _entryState.value = EntryState.Login } diff --git a/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt b/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt index 94936009..35e33a97 100644 --- a/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt +++ b/app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt @@ -3,6 +3,7 @@ package com.project200.undabang.main import com.google.common.truth.Truth.assertThat import com.project200.common.utils.NetworkMonitor import com.project200.domain.manager.FcmTokenSyncScheduler +import com.project200.domain.model.SessionExitReason import com.project200.domain.model.UpdateCheckResult import com.project200.domain.usecase.CheckForUpdateUseCase import com.project200.domain.usecase.ClearSessionUseCase @@ -74,7 +75,7 @@ class MainViewModelTest { every { mockNetworkMonitor.isCurrentlyConnected() } returns true every { mockAuthManager.forceLogoutFlow } returns forceLogoutFlow every { mockFcmTokenSyncScheduler.schedule() } just Runs - coEvery { mockClearSessionUseCase() } just Runs + coEvery { mockClearSessionUseCase(SessionExitReason.FORCED) } just Runs } @After diff --git a/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt index 8dc4c040..901d1e63 100644 --- a/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt @@ -120,6 +120,12 @@ class AuthRepositoryImpl } } + override suspend fun clearTokens() { + withContext(ioDispatcher) { + authStateManager.clearAuthState() + } + } + companion object { const val TAG = "AuthRepositoryImpl" } diff --git a/domain/src/main/java/com/project200/domain/model/SessionExitReason.kt b/domain/src/main/java/com/project200/domain/model/SessionExitReason.kt new file mode 100644 index 00000000..0a2431b3 --- /dev/null +++ b/domain/src/main/java/com/project200/domain/model/SessionExitReason.kt @@ -0,0 +1,21 @@ +package com.project200.domain.model + +/** + * 세션을 정리하는 이유입니다. 정리 범위가 이유에 따라 다릅니다. + * + * 호출부는 무엇을 지울지 고르지 않고 무슨 일이 있었는지만 알립니다. 지울 범위는 + * `ClearSessionUseCase`가 정합니다 + */ +enum class SessionExitReason { + /** 사용자가 로그아웃이나 탈퇴를 눌렀습니다. 기기에 남은 데이터를 모두 지웁니다 */ + USER_INITIATED, + + /** + * 토큰 만료나 갱신 실패처럼 사용자가 의도하지 않은 이탈입니다. + * + * 아직 서버에 올리지 못한 전송 대기 행을 지키기 위해 캐시와 회원ID를 남기고 토큰만 + * 지웁니다. 회원ID를 남기는 이유는 재로그인 때 계정 전환 판정에 쓰이기 때문입니다. + * 회원ID가 없으면 같은 계정으로 돌아와도 이전 값과 다르다고 보고 캐시를 지웁니다 + */ + FORCED, +} diff --git a/domain/src/main/java/com/project200/domain/repository/AuthRepository.kt b/domain/src/main/java/com/project200/domain/repository/AuthRepository.kt index 92c51405..0e09d3fb 100644 --- a/domain/src/main/java/com/project200/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/com/project200/domain/repository/AuthRepository.kt @@ -10,5 +10,15 @@ interface AuthRepository { suspend fun signUp(gender: String, nickname: String, birth: LocalDate): BaseResult suspend fun checkNicknameDuplicated(nickname: String): BaseResult suspend fun getMemberId(): String? + + /** 토큰과 회원ID를 모두 지웁니다 */ suspend fun clearSession() + + /** + * 토큰만 지우고 회원ID는 남깁니다. + * + * 남은 회원ID는 재로그인 때 계정 전환 판정에 쓰입니다. 같은 계정이면 캐시를 지우지 + * 않아 전송 대기 행이 살아남습니다 + */ + suspend fun clearTokens() } \ No newline at end of file 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 68e340fd..95b7bee0 100644 --- a/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt +++ b/domain/src/main/java/com/project200/domain/usecase/ClearSessionUseCase.kt @@ -2,6 +2,7 @@ package com.project200.domain.usecase import com.project200.domain.manager.FcmTokenSyncScheduler import com.project200.domain.manager.SessionDataCleaner +import com.project200.domain.model.SessionExitReason import com.project200.domain.repository.AuthRepository import javax.inject.Inject @@ -15,16 +16,27 @@ constructor( /** * 로컬 세션을 정리합니다. * + * 사용자가 의도한 이탈에서만 캐시를 지웁니다. 강제 이탈은 캐시와 회원ID를 남깁니다. + * 토큰 만료로 로그인 화면에 밀려난 사용자가 오프라인에서 만든 데이터를 잃지 않게 합니다 + * * 캐시를 회원ID보다 먼저 지웁니다. 계정 스코프 삭제로 좁히면 회원ID가 없는 시점에는 * 지울 대상을 특정할 수 없습니다 * 캐시 삭제가 실패해도 세션은 끊습니다. 세션이 남으면 다음 진입에서 자동 로그인됩니다 + * + * 예약 작업은 이유와 무관하게 취소합니다. 세션이 없으면 전송할 수 없습니다. + * 강제 이탈에서 남긴 대기 행은 재로그인 때 다시 예약해 올립니다 */ - suspend operator fun invoke() { + suspend operator fun invoke(reason: SessionExitReason) { try { - sessionDataCleaner.clearAll() + if (reason == SessionExitReason.USER_INITIATED) { + sessionDataCleaner.clearAll() + } } finally { fcmTokenSyncScheduler.cancel() - authRepository.clearSession() + when (reason) { + SessionExitReason.USER_INITIATED -> authRepository.clearSession() + SessionExitReason.FORCED -> authRepository.clearTokens() + } } } } 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 5a1ce6f7..15940c2a 100644 --- a/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt +++ b/domain/src/test/java/com/project200/domain/usecase/ClearSessionUseCaseTest.kt @@ -3,6 +3,7 @@ package com.project200.domain.usecase import com.google.common.truth.Truth.assertThat import com.project200.domain.manager.FcmTokenSyncScheduler import com.project200.domain.manager.SessionDataCleaner +import com.project200.domain.model.SessionExitReason import com.project200.domain.repository.AuthRepository import io.mockk.Runs import io.mockk.coEvery @@ -39,61 +40,37 @@ class ClearSessionUseCaseTest { @Before fun setUp() { useCase = ClearSessionUseCase(mockRepository, mockFcmTokenSyncScheduler, mockSessionDataCleaner) - } - - @Test - fun `invoke 호출 시 repository clearSession 호출`() = runTest { - // Given coEvery { mockRepository.clearSession() } just Runs + coEvery { mockRepository.clearTokens() } just Runs every { mockFcmTokenSyncScheduler.cancel() } just Runs coEvery { mockSessionDataCleaner.clearAll() } just Runs - - // When - useCase() - - // Then - coVerify(exactly = 1) { mockRepository.clearSession() } } @Test - fun `invoke 호출 시 예약된 FCM 토큰 등록을 취소한다`() = runTest { - // Given - coEvery { mockRepository.clearSession() } just Runs - every { mockFcmTokenSyncScheduler.cancel() } just Runs - coEvery { mockSessionDataCleaner.clearAll() } just Runs - + fun `사용자 이탈 - 토큰과 회원ID를 모두 지운다`() = runTest { // When - useCase() + useCase(SessionExitReason.USER_INITIATED) // Then - verify(exactly = 1) { mockFcmTokenSyncScheduler.cancel() } + coVerify(exactly = 1) { mockRepository.clearSession() } + coVerify(exactly = 0) { mockRepository.clearTokens() } } @Test - fun `invoke 호출 시 로컬 캐시를 지운다`() = runTest { - // Given - coEvery { mockRepository.clearSession() } just Runs - every { mockFcmTokenSyncScheduler.cancel() } just Runs - coEvery { mockSessionDataCleaner.clearAll() } just Runs - + fun `사용자 이탈 - 로컬 캐시를 지운다`() = runTest { // When - useCase() + useCase(SessionExitReason.USER_INITIATED) // Then coVerify(exactly = 1) { mockSessionDataCleaner.clearAll() } } @Test - fun `invoke 호출 시 캐시를 지운 뒤 세션을 정리한다`() = runTest { - // Given - coEvery { mockRepository.clearSession() } just Runs - every { mockFcmTokenSyncScheduler.cancel() } just Runs - coEvery { mockSessionDataCleaner.clearAll() } just Runs - + fun `사용자 이탈 - 캐시를 지운 뒤 세션을 정리한다`() = runTest { // When - useCase() + useCase(SessionExitReason.USER_INITIATED) - // Then: 회원ID가 남아 있는 동안 지워야 계정 스코프 삭제로 좁힐 수 있다 + // Then: 계정 스코프 삭제로 좁히면 회원ID가 먼저 사라지면 지울 대상을 특정할 수 없다 coVerifyOrder { mockSessionDataCleaner.clearAll() mockRepository.clearSession() @@ -101,18 +78,45 @@ class ClearSessionUseCaseTest { } @Test - fun `invoke 호출 시 캐시 삭제가 실패해도 예약과 세션을 정리한다`() = runTest { + fun `사용자 이탈 - 캐시 삭제가 실패해도 예약과 세션을 정리한다`() = runTest { // Given - coEvery { mockRepository.clearSession() } just Runs - every { mockFcmTokenSyncScheduler.cancel() } just Runs coEvery { mockSessionDataCleaner.clearAll() } throws IllegalStateException("db") // When - val thrown = runCatching { useCase() }.exceptionOrNull() + val thrown = runCatching { useCase(SessionExitReason.USER_INITIATED) }.exceptionOrNull() // Then assertThat(thrown).isInstanceOf(IllegalStateException::class.java) verify(exactly = 1) { mockFcmTokenSyncScheduler.cancel() } coVerify(exactly = 1) { mockRepository.clearSession() } } + + @Test + fun `강제 이탈 - 로컬 캐시를 지우지 않는다`() = runTest { + // When + useCase(SessionExitReason.FORCED) + + // Then: 아직 서버에 올리지 못한 전송 대기 행을 지킨다 + coVerify(exactly = 0) { mockSessionDataCleaner.clearAll() } + } + + @Test + fun `강제 이탈 - 토큰만 지우고 회원ID는 남긴다`() = runTest { + // When + useCase(SessionExitReason.FORCED) + + // Then: 회원ID가 남아야 재로그인 때 같은 계정으로 판정되어 캐시가 살아남는다 + coVerify(exactly = 1) { mockRepository.clearTokens() } + coVerify(exactly = 0) { mockRepository.clearSession() } + } + + @Test + fun `이탈 이유와 무관하게 예약된 FCM 토큰 등록을 취소한다`() = runTest { + // When + useCase(SessionExitReason.USER_INITIATED) + useCase(SessionExitReason.FORCED) + + // Then: 세션이 없으면 전송할 수 없다 + verify(exactly = 2) { mockFcmTokenSyncScheduler.cancel() } + } } diff --git a/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingViewModel.kt b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingViewModel.kt index f632cff9..c3fdaab9 100644 --- a/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingViewModel.kt +++ b/feature/profile/src/main/java/com/project200/undabang/profile/setting/SettingViewModel.kt @@ -1,6 +1,7 @@ package com.project200.undabang.profile.setting import androidx.lifecycle.ViewModel +import com.project200.domain.model.SessionExitReason import com.project200.domain.usecase.ClearSessionUseCase import com.project200.domain.usecase.LogoutUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -15,5 +16,6 @@ class SettingViewModel ) : ViewModel() { suspend fun logout() = logoutUseCase() - suspend fun clearLocalSession() = clearSessionUseCase() + // 사용자가 로그아웃을 눌렀으므로 전송 대기 행까지 지웁니다 + suspend fun clearLocalSession() = clearSessionUseCase(SessionExitReason.USER_INITIATED) } diff --git a/feature/profile/src/test/java/com/project200/undabang/profile/setting/SettingViewModelTest.kt b/feature/profile/src/test/java/com/project200/undabang/profile/setting/SettingViewModelTest.kt index 84f5107a..eeb6c31d 100644 --- a/feature/profile/src/test/java/com/project200/undabang/profile/setting/SettingViewModelTest.kt +++ b/feature/profile/src/test/java/com/project200/undabang/profile/setting/SettingViewModelTest.kt @@ -1,6 +1,7 @@ package com.project200.undabang.profile.setting import com.project200.domain.model.BaseResult +import com.project200.domain.model.SessionExitReason import com.project200.domain.usecase.ClearSessionUseCase import com.project200.domain.usecase.LogoutUseCase import io.mockk.Runs @@ -67,15 +68,16 @@ class SettingViewModelTest { } @Test - fun `clearLocalSession - clearSessionUseCase가 호출된다`() = + fun `clearLocalSession - 사용자 이탈로 세션을 정리한다`() = runTest { - coEvery { mockClearSessionUseCase() } just Runs + coEvery { mockClearSessionUseCase(SessionExitReason.USER_INITIATED) } just Runs createViewModel() viewModel.clearLocalSession() testDispatcher.scheduler.advanceUntilIdle() - coVerify(exactly = 1) { mockClearSessionUseCase() } + // Then: 사용자가 누른 로그아웃이라 전송 대기 행까지 지운다 + coVerify(exactly = 1) { mockClearSessionUseCase(SessionExitReason.USER_INITIATED) } } }