Skip to content
Open
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
11 changes: 10 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -164,7 +165,7 @@ class MainViewModel
}

private suspend fun transitionToLogin() {
runCatching { clearSessionUseCase() }
runCatching { clearSessionUseCase(SessionExitReason.FORCED) }
.onFailure { Timber.e(it, "세션 정리 실패 - 전이는 계속") }
_entryState.value = EntryState.Login
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ class AuthRepositoryImpl
}
}

override suspend fun clearTokens() {
withContext(ioDispatcher) {
authStateManager.clearAuthState()
}
}

companion object {
const val TAG = "AuthRepositoryImpl"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.project200.domain.model

/**
* 세션을 정리하는 이유입니다. 정리 범위가 이유에 따라 다릅니다.
*
* 호출부는 무엇을 지울지 고르지 않고 무슨 일이 있었는지만 알립니다. 지울 범위는
* `ClearSessionUseCase`가 정합니다
*/
enum class SessionExitReason {
/** 사용자가 로그아웃이나 탈퇴를 눌렀습니다. 기기에 남은 데이터를 모두 지웁니다 */
USER_INITIATED,

/**
* 토큰 만료나 갱신 실패처럼 사용자가 의도하지 않은 이탈입니다.
*
* 아직 서버에 올리지 못한 전송 대기 행을 지키기 위해 캐시와 회원ID를 남기고 토큰만
* 지웁니다. 회원ID를 남기는 이유는 재로그인 때 계정 전환 판정에 쓰이기 때문입니다.
* 회원ID가 없으면 같은 계정으로 돌아와도 이전 값과 다르다고 보고 캐시를 지웁니다
*/
FORCED,
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,15 @@ interface AuthRepository {
suspend fun signUp(gender: String, nickname: String, birth: LocalDate): BaseResult<Unit>
suspend fun checkNicknameDuplicated(nickname: String): BaseResult<Boolean>
suspend fun getMemberId(): String?

/** 토큰과 회원ID를 모두 지웁니다 */
suspend fun clearSession()

/**
* 토큰만 지우고 회원ID는 남깁니다.
*
* 남은 회원ID는 재로그인 때 계정 전환 판정에 쓰입니다. 같은 계정이면 캐시를 지우지
* 않아 전송 대기 행이 살아남습니다
*/
suspend fun clearTokens()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,80 +40,83 @@ 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()
}
}

@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() }
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,5 +16,6 @@ class SettingViewModel
) : ViewModel() {
suspend fun logout() = logoutUseCase()

suspend fun clearLocalSession() = clearSessionUseCase()
// 사용자가 로그아웃을 눌렀으므로 전송 대기 행까지 지웁니다
suspend fun clearLocalSession() = clearSessionUseCase(SessionExitReason.USER_INITIATED)
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) }
}
}
Loading