Skip to content

Commit 99f1e98

Browse files
authored
Merge pull request #567 from projects200/fix/fcm-token-sync-565
[Fix] FCM 토큰이 바뀌어도 서버에 반영하지 않는 문제 수정
2 parents ac768c0 + 30a7f98 commit 99f1e98

14 files changed

Lines changed: 397 additions & 25 deletions

File tree

app/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ dependencies {
109109
implementation(libs.androidx.navigation.fragment.ktx)
110110
implementation(libs.androidx.navigation.ui.ktx)
111111

112+
// WorkManager (FCM 토큰 전송 예약)
113+
implementation(libs.androidx.work.runtime.ktx)
114+
implementation(libs.androidx.hilt.work)
115+
"ksp"(libs.androidx.hilt.compiler)
116+
112117
implementation(libs.appauth)
113118

114119
// Kakao Map

app/src/main/AndroidManifest.xml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?xml version="1.0" encoding="utf-8"?>
2-
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+
xmlns:tools="http://schemas.android.com/tools">
34

45
<!-- 인터넷 권한 -->
56
<uses-permission android:name="android.permission.INTERNET" />
@@ -44,6 +45,18 @@
4445
android:name="com.kakao.sdk.AppKey"
4546
android:value="${KAKAO_NATIVE_APP_KEY}" />
4647

48+
<!-- WorkManager 기본 초기화를 끄고 Configuration.Provider로 직접 초기화합니다 -->
49+
<provider
50+
android:name="androidx.startup.InitializationProvider"
51+
android:authorities="${applicationId}.androidx-startup"
52+
android:exported="false"
53+
tools:node="merge">
54+
<meta-data
55+
android:name="androidx.work.WorkManagerInitializer"
56+
android:value="androidx.startup"
57+
tools:node="remove" />
58+
</provider>
59+
4760
<provider
4861
android:name="androidx.core.content.FileProvider"
4962
android:authorities="${applicationId}.fileprovider"

app/src/main/java/com/project200/undabang/ApplicationClass.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,26 @@ import android.app.NotificationChannel
55
import android.app.NotificationManager
66
import android.content.Context
77
import androidx.appcompat.app.AppCompatDelegate
8+
import androidx.hilt.work.HiltWorkerFactory
9+
import androidx.work.Configuration
810
import com.google.firebase.crashlytics.FirebaseCrashlytics
911
import com.kakao.vectormap.KakaoMapSdk
1012
import dagger.hilt.android.HiltAndroidApp
1113
import timber.log.Timber
14+
import javax.inject.Inject
1215

1316
@HiltAndroidApp
14-
class ApplicationClass : Application() {
17+
class ApplicationClass : Application(), Configuration.Provider {
18+
@Inject
19+
lateinit var workerFactory: HiltWorkerFactory
20+
21+
/** WorkManager가 Hilt로 Worker를 생성하도록 팩토리를 넘깁니다 */
22+
override val workManagerConfiguration: Configuration
23+
get() =
24+
Configuration.Builder()
25+
.setWorkerFactory(workerFactory)
26+
.build()
27+
1528
override fun onCreate() {
1629
super.onCreate()
1730

app/src/main/java/com/project200/undabang/fcm/FcmService.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,15 @@ class FcmService : FirebaseMessagingService() {
3030

3131
/**
3232
* 새로운 FCM 토큰이 발급되거나 갱신될 때 호출됩니다.
33-
* 이 토큰은 백엔드 서버로 전송되어 특정 기기에 알림을 보내는 데 사용됩니다.
33+
* 기기에 저장한 뒤 서버 반영은 워커에 맡깁니다.
34+
* 이 콜백은 백그라운드에서도 불려 네트워크와 로그인 상태를 확신할 수 없습니다.
3435
*/
3536
override fun onNewToken(token: String) {
3637
super.onNewToken(token)
3738
Timber.tag(TAG).d("Refreshed FCM token: $token")
3839

3940
saveFcmToken(token)
41+
FcmTokenSyncWorker.enqueue(applicationContext)
4042
}
4143

4244
/**
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.project200.undabang.fcm
2+
3+
/** FcmTokenSyncWorker의 재시도 판단. 안드로이드 의존성이 없어 단위 테스트로 검증합니다 */
4+
object FcmTokenSyncPolicy {
5+
/** 전송 실패 시 총 시도 횟수 상한 */
6+
const val MAX_ATTEMPT_COUNT = 5
7+
8+
/** runAttemptCount는 WorkManager가 주는 값으로 첫 실행이 0입니다 */
9+
fun shouldRetry(runAttemptCount: Int): Boolean = runAttemptCount + 1 < MAX_ATTEMPT_COUNT
10+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.project200.undabang.fcm
2+
3+
import android.content.Context
4+
import androidx.hilt.work.HiltWorker
5+
import androidx.work.BackoffPolicy
6+
import androidx.work.Constraints
7+
import androidx.work.CoroutineWorker
8+
import androidx.work.ExistingWorkPolicy
9+
import androidx.work.NetworkType
10+
import androidx.work.OneTimeWorkRequestBuilder
11+
import androidx.work.WorkManager
12+
import androidx.work.WorkerParameters
13+
import com.project200.domain.model.FcmTokenSyncResult
14+
import com.project200.domain.usecase.SyncFcmTokenUseCase
15+
import com.project200.undabang.oauth.AuthManager
16+
import com.project200.undabang.oauth.AuthStateManager
17+
import com.project200.undabang.oauth.TokenRefreshResult
18+
import dagger.assisted.Assisted
19+
import dagger.assisted.AssistedInject
20+
import net.openid.appauth.AuthorizationException
21+
import timber.log.Timber
22+
import java.util.concurrent.TimeUnit
23+
24+
/**
25+
* 갱신된 FCM 토큰을 서버에 반영하는 워커입니다
26+
* onNewToken()은 백그라운드나 프로세스 기동 직후에도 불려 네트워크와 로그인 상태를 확신할 수 없습니다
27+
* 전송을 워커로 미뤄 연결이 잡힌 뒤에 보내고 실패하면 다시 시도합니다
28+
*/
29+
@HiltWorker
30+
class FcmTokenSyncWorker
31+
@AssistedInject
32+
constructor(
33+
@Assisted appContext: Context,
34+
@Assisted params: WorkerParameters,
35+
private val syncFcmTokenUseCase: SyncFcmTokenUseCase,
36+
private val authStateManager: AuthStateManager,
37+
private val authManager: AuthManager,
38+
) : CoroutineWorker(appContext, params) {
39+
override suspend fun doWork(): Result {
40+
// POST /login은 @AccessTokenWithFcmApi라 401 자동 갱신이 없음 - 만료면 먼저 갱신
41+
if (authStateManager.getCurrent().needsTokenRefresh) {
42+
when (val refresh = authManager.refreshAccessToken()) {
43+
is TokenRefreshResult.Success -> Unit
44+
is TokenRefreshResult.Error -> {
45+
val ex = refresh.exception
46+
val invalidGrant =
47+
ex?.type == AuthorizationException.TYPE_OAUTH_TOKEN_ERROR &&
48+
ex.error == "invalid_grant"
49+
// invalid_grant는 재시도해도 같음. 세션 정리는 refreshAccessToken() 내부가 함
50+
return if (invalidGrant) {
51+
Result.failure()
52+
} else if (FcmTokenSyncPolicy.shouldRetry(runAttemptCount)) {
53+
Result.retry()
54+
} else {
55+
Result.failure()
56+
}
57+
}
58+
is TokenRefreshResult.NoRefreshToken,
59+
is TokenRefreshResult.ConfigError,
60+
-> return Result.failure()
61+
}
62+
}
63+
64+
val syncResult = syncFcmTokenUseCase()
65+
Timber.tag(TAG).d("FCM 토큰 전송 결과: $syncResult (시도 ${runAttemptCount + 1}회)")
66+
67+
return when (syncResult) {
68+
FcmTokenSyncResult.SUCCESS -> Result.success()
69+
// 로그인 전이거나 보낼 토큰이 없는 상태 - 재시도해도 같으므로 성공 처리
70+
FcmTokenSyncResult.SKIPPED -> Result.success()
71+
FcmTokenSyncResult.FAILURE ->
72+
if (FcmTokenSyncPolicy.shouldRetry(runAttemptCount)) Result.retry() else Result.failure()
73+
}
74+
}
75+
76+
companion object {
77+
private const val TAG = "FcmTokenSyncWorker"
78+
const val WORK_NAME = "fcm_token_sync"
79+
private const val BACKOFF_DELAY_SECONDS = 30L
80+
81+
/**
82+
* 토큰 등록을 예약합니다
83+
* 대기 중에 토큰이 다시 갱신되면 새 요청으로 바꿔 백오프를 처음부터 다시 셉니다
84+
* 워커는 실행 시점에 저장소에서 토큰을 읽으므로 마지막 값이 올라갑니다
85+
*/
86+
fun enqueue(context: Context) {
87+
val constraints =
88+
Constraints.Builder()
89+
.setRequiredNetworkType(NetworkType.CONNECTED)
90+
.build()
91+
92+
val request =
93+
OneTimeWorkRequestBuilder<FcmTokenSyncWorker>()
94+
.setConstraints(constraints)
95+
.setBackoffCriteria(
96+
BackoffPolicy.EXPONENTIAL,
97+
BACKOFF_DELAY_SECONDS,
98+
TimeUnit.SECONDS,
99+
)
100+
.build()
101+
102+
WorkManager.getInstance(context)
103+
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
104+
}
105+
}
106+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.project200.undabang.fcm
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import org.junit.Test
5+
6+
class FcmTokenSyncPolicyTest {
7+
@Test
8+
fun `첫 실행에서 실패하면 재시도한다`() {
9+
assertThat(FcmTokenSyncPolicy.shouldRetry(0)).isTrue()
10+
}
11+
12+
@Test
13+
fun `상한 직전까지는 재시도한다`() {
14+
val lastRetryableAttempt = FcmTokenSyncPolicy.MAX_ATTEMPT_COUNT - 2
15+
16+
assertThat(FcmTokenSyncPolicy.shouldRetry(lastRetryableAttempt)).isTrue()
17+
}
18+
19+
@Test
20+
fun `상한에 닿으면 재시도하지 않는다`() {
21+
val lastAttempt = FcmTokenSyncPolicy.MAX_ATTEMPT_COUNT - 1
22+
23+
assertThat(FcmTokenSyncPolicy.shouldRetry(lastAttempt)).isFalse()
24+
}
25+
26+
@Test
27+
fun `상한을 넘긴 뒤에도 재시도하지 않는다`() {
28+
assertThat(FcmTokenSyncPolicy.shouldRetry(FcmTokenSyncPolicy.MAX_ATTEMPT_COUNT)).isFalse()
29+
}
30+
}

app/src/test/java/com/project200/undabang/main/MainViewModelTest.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,27 @@ class MainViewModelTest {
495495
coVerify(exactly = 1) { mockLoginUseCase() }
496496
}
497497

498+
@Test
499+
fun `FCM 등록 - 토큰이 없어 실패하면 대기가 닫히지 않고 재연결 때 재시도한다`() =
500+
runTest {
501+
// Given: FCM 토큰이 없어 서버 로그인이 막힌다
502+
stubNoUpdate()
503+
stubAuthState(isAuthorized = true)
504+
coEvery { mockLoginUseCase() } returns
505+
BaseResult.Error("NO_FCM_TOKEN", "FCM 토큰이 없어 서버 로그인을 보내지 않았습니다.") andThen
506+
BaseResult.Success(Unit)
507+
viewModel = createViewModel()
508+
testDispatcher.scheduler.advanceUntilIdle()
509+
510+
// When: offline → online 재연결
511+
networkStateFlow.emit(false)
512+
networkStateFlow.emit(true)
513+
testDispatcher.scheduler.advanceUntilIdle()
514+
515+
// Then: 성공으로 닫히지 않았으므로 재시도가 한 번 더 나간다
516+
coVerify(exactly = 2) { mockLoginUseCase() }
517+
}
518+
498519
@Test
499520
fun `FCM 등록 - 요청이 진행 중이면 재연결이 겹쳐도 중복 발행하지 않는다`() =
500521
runTest {

data/src/main/java/com/project200/data/impl/AuthRepositoryImpl.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.project200.data.dto.GetIsNicknameDuplicated
66
import com.project200.data.dto.PostLoginRequest
77
import com.project200.data.dto.PostSignUpRequest
88
import com.project200.data.local.PreferenceManager
9+
import com.project200.data.utils.FcmTokenProvider
910
import com.project200.data.utils.apiCallBuilder
1011
import com.project200.domain.model.BaseResult
1112
import com.project200.domain.model.RegistrationStatus
@@ -23,6 +24,7 @@ class AuthRepositoryImpl
2324
private val apiService: ApiService,
2425
private val spManager: PreferenceManager,
2526
private val authStateManager: AuthStateManager,
27+
private val fcmTokenProvider: FcmTokenProvider,
2628
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
2729
) : AuthRepository {
2830
override suspend fun checkIsRegistered(): RegistrationStatus {
@@ -56,7 +58,21 @@ class AuthRepositoryImpl
5658
}
5759
}
5860

61+
/**
62+
* POST /login은 X-Fcm-Token 헤더로 기기 토큰을 등록합니다.
63+
* 토큰이 없으면 서버는 헤더 없는 요청을 LOGIN_SUCCESS로 돌려주고 등록은 건너뜁니다.
64+
* 그 성공을 그대로 올리면 재시도 경로가 닫히므로 보내기 전에 막습니다.
65+
*/
5966
override suspend fun login(): BaseResult<Unit> {
67+
val fcmToken = withContext(ioDispatcher) { fcmTokenProvider.getFcmToken() }
68+
if (fcmToken.isNullOrBlank()) {
69+
Timber.tag(TAG).w("FCM 토큰이 없어 서버 로그인을 보내지 않습니다.")
70+
return BaseResult.Error(
71+
errorCode = NO_FCM_TOKEN_ERROR_CODE,
72+
message = NO_FCM_TOKEN_ERROR_MESSAGE,
73+
)
74+
}
75+
6076
return apiCallBuilder(
6177
ioDispatcher = ioDispatcher,
6278
apiCall = { apiService.postLogin(PostLoginRequest("ANDROID", "APP")) },
@@ -114,5 +130,7 @@ class AuthRepositoryImpl
114130

115131
companion object {
116132
const val TAG = "AuthRepositoryImpl"
133+
const val NO_FCM_TOKEN_ERROR_CODE = "NO_FCM_TOKEN"
134+
private const val NO_FCM_TOKEN_ERROR_MESSAGE = "FCM 토큰이 없어 서버 로그인을 보내지 않았습니다."
117135
}
118136
}

data/src/main/java/com/project200/data/utils/FcmTokenProvider.kt

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,19 @@ package com.project200.data.utils
33
import android.content.SharedPreferences
44
import com.project200.common.constants.FcmConstants.KEY_FCM_TOKEN
55
import com.project200.common.utils.EncryptedPrefs
6-
import timber.log.Timber
76
import javax.inject.Inject
87
import javax.inject.Singleton
98

9+
/**
10+
* FCM 토큰을 읽는 단일 창구입니다
11+
* 토큰은 FcmService.onNewToken()이 저장소에 직접 씁니다
12+
* 메모리에 캐시하면 갱신 이후에도 옛 토큰이 헤더에 실리므로 호출할 때마다 저장소를 읽습니다
13+
*/
1014
@Singleton
1115
class FcmTokenProvider
1216
@Inject
1317
constructor(
1418
@EncryptedPrefs private val prefs: SharedPreferences,
1519
) {
16-
private var fcmToken: String? = null
17-
18-
init {
19-
loadTokenFromPrefs()
20-
}
21-
22-
private fun loadTokenFromPrefs() {
23-
fcmToken = prefs.getString(KEY_FCM_TOKEN, null)
24-
Timber.tag("FcmTokenProvider").d("FCM 토큰 : $fcmToken")
25-
}
26-
27-
fun getFcmToken(): String? {
28-
if (fcmToken == null) {
29-
loadTokenFromPrefs()
30-
}
31-
return fcmToken
32-
}
33-
34-
fun refreshToken() {
35-
loadTokenFromPrefs()
36-
}
20+
fun getFcmToken(): String? = prefs.getString(KEY_FCM_TOKEN, null)
3721
}

0 commit comments

Comments
 (0)