Skip to content

Commit 32f48ef

Browse files
authored
[Fix] 토큰 재발급 미동작 해결 및 로직 전면 리팩토링 (#453)
* refactor: preserve token reissue failure reason * fix: make refresh flow resilient in authenticator * fix: reissue token during auto-login when access token expired * fix: serialize token reissue with coroutine mutex * fix: avoid duplicate Authorization headers * refactor: simplify auth events and drop unused token error state * refactor: centralize token reissue and persistence * chore: remove unused authenticator import * fix: keep session on transient reissue failure and retry * refactor: limit splash to health check only * feat: 토큰 만료/부재 원인 구분 로깅 추가 (LogoutReason) * fix: ApiResultCall JSON 파싱 에러 수정 (Lenient parsing 적용) * chore: clean import * Refactor token reissue logic to use ReissueTokenResult
1 parent 03cd6d7 commit 32f48ef

15 files changed

Lines changed: 187 additions & 152 deletions

File tree

app/src/main/java/com/eatssu/android/App.kt

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ package com.eatssu.android
33
import android.app.Application
44
import androidx.hilt.work.HiltWorkerFactory
55
import androidx.work.Configuration
6-
import com.eatssu.android.domain.model.TokenState
7-
import com.eatssu.android.domain.model.TokenStateManager
8-
import com.eatssu.android.presentation.base.TokenEventBus
96
import com.google.firebase.FirebaseApp
107
import com.google.firebase.analytics.ktx.analytics
118
import com.google.firebase.crashlytics.FirebaseCrashlytics
@@ -14,22 +11,13 @@ import com.kakao.sdk.common.KakaoSdk
1411
import com.posthog.android.PostHogAndroid
1512
import com.posthog.android.PostHogAndroidConfig
1613
import dagger.hilt.android.HiltAndroidApp
17-
import kotlinx.coroutines.CoroutineScope
18-
import kotlinx.coroutines.Dispatchers
19-
import kotlinx.coroutines.SupervisorJob
20-
import kotlinx.coroutines.launch
2114
import timber.log.Timber
2215
import javax.inject.Inject
2316

2417
/** App: 앱이 살아있는 동안 공통 리소스 관리를 위한 클래스 */
2518
@HiltAndroidApp
2619
class App : Application(), Configuration.Provider {
2720

28-
/** 앱 전체에서 사용할 수 있는 CoroutineScope(독립적인 공간을 만들어 안정성 높임)
29-
* 자식 CoroutineScope가 취소되더라도 부모 CoroutineScope는 취소되지 않음
30-
* */
31-
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
32-
3321
@Inject
3422
lateinit var workerFactory: HiltWorkerFactory
3523

@@ -48,23 +36,9 @@ class App : Application(), Configuration.Provider {
4836
Firebase.analytics.setAnalyticsCollectionEnabled(true)
4937
}
5038

51-
collectTokenState()
5239
setupPostHog()
5340
}
5441

55-
/** 토큰 상태를 application에서 감지하여 TokenEventBus에 전달 */
56-
private fun collectTokenState() {
57-
appScope.launch {
58-
TokenStateManager.state.collect { state ->
59-
if (state == TokenState.EXPIRED) {
60-
TokenEventBus.notifyTokenExpired()
61-
} else if (state == TokenState.ERROR) {
62-
TokenEventBus.notifyServerError()
63-
}
64-
}
65-
}
66-
}
67-
6842
private fun setupPostHog() {
6943
// Create a PostHog Config with the given API key and host
7044
val config = PostHogAndroidConfig(
@@ -88,4 +62,4 @@ class App : Application(), Configuration.Provider {
8862
get() = Configuration.Builder()
8963
.setWorkerFactory(workerFactory)
9064
.build()
91-
}
65+
}

app/src/main/java/com/eatssu/android/data/remote/repository/OauthRepositoryImpl.kt

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.eatssu.android.data.remote.repository
22

3+
import com.eatssu.android.data.model.ApiResult
4+
import com.eatssu.android.domain.model.ReissueTokenResult
35
import com.eatssu.android.data.model.map
46
import com.eatssu.android.data.model.orElse
57
import com.eatssu.android.data.model.orNull
@@ -14,8 +16,19 @@ import javax.inject.Inject
1416

1517
class OauthRepositoryImpl @Inject constructor(private val oauthService: OauthService) :
1618
OauthRepository {
17-
override suspend fun reissueToken(refreshToken: String): Token? =
18-
oauthService.getNewToken(refreshToken).map { it.toDomain() }.orNull()
19+
override suspend fun reissueToken(refreshToken: String): ReissueTokenResult {
20+
val headerValue = refreshToken.asAuthorizationHeaderValue()
21+
return when (val result = oauthService.getNewToken(headerValue)) {
22+
is ApiResult.Success -> ReissueTokenResult.Success(result.data.toDomain())
23+
is ApiResult.Failure -> ReissueTokenResult.Failure(
24+
responseCode = result.responseCode,
25+
message = result.message
26+
)
27+
28+
is ApiResult.NetworkError -> ReissueTokenResult.Failure(throwable = result.exception)
29+
is ApiResult.UnknownError -> ReissueTokenResult.Failure(throwable = result.exception)
30+
}
31+
}
1932

2033
override suspend fun login(
2134
email: String,
@@ -33,3 +46,6 @@ class OauthRepositoryImpl @Inject constructor(private val oauthService: OauthSer
3346
override suspend fun checkValidToken(body: CheckValidTokenRequest): Boolean =
3447
oauthService.checkValidToken(body).orElse(false)
3548
}
49+
50+
private fun String.asAuthorizationHeaderValue(): String =
51+
if (startsWith("Bearer ")) this else "Bearer $this"

app/src/main/java/com/eatssu/android/di/NetworkModule.kt

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,9 @@ import com.eatssu.android.BuildConfig.BASE_URL
66
import com.eatssu.android.di.network.ApiResultCallAdapterFactory
77
import com.eatssu.android.di.network.TokenAuthenticator
88
import com.eatssu.android.di.network.TokenInterceptor
9-
import com.eatssu.android.domain.usecase.auth.GetRefreshTokenUseCase
9+
import com.eatssu.android.domain.usecase.auth.GetAccessTokenUseCase
1010
import com.eatssu.android.domain.usecase.auth.LogoutUseCase
11-
import com.eatssu.android.domain.usecase.auth.ReissueTokenUseCase
12-
import com.eatssu.android.domain.usecase.auth.SetAccessTokenUseCase
13-
import com.eatssu.android.domain.usecase.auth.SetRefreshTokenUseCase
11+
import com.eatssu.android.domain.usecase.auth.ReissueAndStoreTokenUseCase
1412
import dagger.Module
1513
import dagger.Provides
1614
import dagger.hilt.InstallIn
@@ -125,18 +123,14 @@ object NetworkModule {
125123
@Provides
126124
@Singleton
127125
fun provideTokenAuthenticator(
128-
getRefreshTokenUseCase: GetRefreshTokenUseCase,
129-
setAccessTokenUseCase: SetAccessTokenUseCase,
130-
setRefreshTokenUseCase: SetRefreshTokenUseCase,
131-
reissueTokenUseCase: ReissueTokenUseCase,
126+
getAccessTokenUseCase: GetAccessTokenUseCase,
127+
reissueAndStoreTokenUseCase: ReissueAndStoreTokenUseCase,
132128
logoutUseCase: LogoutUseCase,
133129
): TokenAuthenticator {
134130
return TokenAuthenticator(
135-
getRefreshTokenUseCase,
136-
setAccessTokenUseCase,
137-
setRefreshTokenUseCase,
138-
reissueTokenUseCase,
131+
getAccessTokenUseCase,
132+
reissueAndStoreTokenUseCase,
139133
logoutUseCase,
140134
)
141135
}
142-
}
136+
}

app/src/main/java/com/eatssu/android/di/network/ApiResultCall.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ class ApiResultCall<T : Any>(
6565
// errorBody를 JSON으로 파싱 시도
6666
if (!errorBodyString.isNullOrEmpty()) {
6767
try {
68-
val errorResponse = gson.fromJson(errorBodyString, BaseResponse::class.java)
68+
val reader = com.google.gson.stream.JsonReader(java.io.StringReader(errorBodyString))
69+
reader.isLenient = true
70+
val errorResponse = gson.fromJson<BaseResponse<*>>(reader, BaseResponse::class.java)
6971

7072
// BaseResponse 형태인지 확인 (isSuccess가 false이고 code와 message가 있는 경우)
7173
if (errorResponse?.isSuccess == false &&

app/src/main/java/com/eatssu/android/di/network/TokenAuthenticator.kt

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package com.eatssu.android.di.network
22

3-
import com.eatssu.android.domain.model.TokenStateManager
4-
import com.eatssu.android.domain.usecase.auth.GetRefreshTokenUseCase
3+
import com.eatssu.android.domain.usecase.auth.GetAccessTokenUseCase
54
import com.eatssu.android.domain.usecase.auth.LogoutUseCase
6-
import com.eatssu.android.domain.usecase.auth.ReissueTokenUseCase
7-
import com.eatssu.android.domain.usecase.auth.SetAccessTokenUseCase
8-
import com.eatssu.android.domain.usecase.auth.SetRefreshTokenUseCase
5+
import com.eatssu.android.domain.usecase.auth.ReissueAndStoreResult
6+
import com.eatssu.android.domain.usecase.auth.ReissueAndStoreTokenUseCase
7+
import com.eatssu.android.presentation.base.LogoutReason
8+
import com.eatssu.android.presentation.base.TokenEventBus
99
import kotlinx.coroutines.runBlocking
10+
import kotlinx.coroutines.sync.Mutex
11+
import kotlinx.coroutines.sync.withLock
1012
import okhttp3.Authenticator
1113
import okhttp3.Request
1214
import okhttp3.Response
@@ -20,13 +22,15 @@ import javax.inject.Inject
2022
* 원래 요청을 새 토큰으로 다시 보내주는 클래스 - 백그라운드 스레드에서 실행
2123
* */
2224
class TokenAuthenticator @Inject constructor(
23-
private val getRefreshTokenUseCase: GetRefreshTokenUseCase,
24-
private val setAccessTokenUseCase: SetAccessTokenUseCase,
25-
private val setRefreshTokenUseCase: SetRefreshTokenUseCase,
26-
private val reissueTokenUseCase: ReissueTokenUseCase,
25+
private val getAccessTokenUseCase: GetAccessTokenUseCase,
26+
private val reissueAndStoreTokenUseCase: ReissueAndStoreTokenUseCase,
2727
private val logoutUseCase: LogoutUseCase,
2828
) : Authenticator {
2929

30+
private companion object {
31+
val mutex = Mutex()
32+
}
33+
3034
/**
3135
* 401 Unauthorized 응답을 받았을 때 호출되는 메서드
3236
* @param route : 요청한 경로
@@ -41,31 +45,49 @@ class TokenAuthenticator @Inject constructor(
4145
}
4246

4347
return runBlocking {
44-
Timber.d("TokenAuthenticator → refreshToken으로 재발급 시도")
48+
mutex.withLock {
49+
val currentAccessToken = getAccessTokenUseCase()
50+
val requestAuthHeader = response.request.header("Authorization")
4551

46-
val expiredRefreshToken = getRefreshTokenUseCase()
47-
val newToken = reissueTokenUseCase(expiredRefreshToken)
52+
// 이미 다른 요청이 토큰을 재발급/저장한 경우, 저장된 토큰으로만 재시도
53+
if (!requestAuthHeader.isNullOrBlank() && requestAuthHeader != "Bearer $currentAccessToken") {
54+
Timber.d("TokenAuthenticator → token already refreshed by another call; retrying with stored token")
55+
return@withLock response.request.newBuilder()
56+
.header("Authorization", "Bearer $currentAccessToken")
57+
.build()
58+
}
4859

49-
val newAccessToken = newToken?.accessToken
50-
val newRefreshToken = newToken?.refreshToken
60+
Timber.d("TokenAuthenticator → attempting token reissue")
61+
when (val result = reissueAndStoreTokenUseCase()) {
62+
is ReissueAndStoreResult.Success -> response.request.newBuilder()
63+
.header("Authorization", "Bearer ${result.accessToken}")
64+
.build()
5165

52-
if (newAccessToken.isNullOrEmpty() ||
53-
newRefreshToken.isNullOrEmpty()
54-
) {
55-
Timber.e("TokenAuthenticator → 새 토큰 발급 실패")
56-
logoutUseCase() // 로그아웃 처리
57-
TokenStateManager.setTokenError()
58-
return@runBlocking null
59-
}
66+
is ReissueAndStoreResult.MissingRefreshToken -> {
67+
Timber.e("TokenAuthenticator → refreshToken is blank; forcing logout")
68+
logoutUseCase()
69+
TokenEventBus.notifyTokenExpired(LogoutReason.MISSING_REFRESH_TOKEN)
70+
null
71+
}
6072

61-
Timber.d("TokenAuthenticator → 새 토큰 발급 성공")
62-
setAccessTokenUseCase(newAccessToken)
63-
setRefreshTokenUseCase(newRefreshToken)
73+
is ReissueAndStoreResult.RefreshInvalid -> {
74+
Timber.e(
75+
"TokenAuthenticator → refresh invalid: code=${result.responseCode}, message=${result.message}"
76+
)
77+
logoutUseCase()
78+
TokenEventBus.notifyTokenExpired(LogoutReason.REFRESH_TOKEN_EXPIRED)
79+
null
80+
}
6481

65-
Timber.d("TokenAuthenticator → 새 토큰 저장 및 기존 API 재요청")
66-
response.request.newBuilder()
67-
.header("Authorization", "Bearer $newAccessToken")
68-
.build()
82+
is ReissueAndStoreResult.TransientFailure -> {
83+
Timber.w(
84+
result.throwable,
85+
"TokenAuthenticator → transient reissue failure: code=${result.responseCode}, message=${result.message}"
86+
)
87+
null
88+
}
89+
}
90+
}
6991
}
7092
}
7193

app/src/main/java/com/eatssu/android/di/network/TokenInterceptor.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ class TokenInterceptor @Inject constructor(
2323
val accessToken = runBlocking { getAccessTokenUseCase() }
2424
val originalRequest = chain.request()
2525

26-
val request = originalRequest.newBuilder()
27-
.addHeader(HEADER_CONTENT_TYPE, "application/json")
28-
.addHeader(HEADER_AUTHORIZATION, "Bearer $accessToken")
29-
.build()
26+
val requestBuilder = originalRequest.newBuilder()
27+
.header(HEADER_CONTENT_TYPE, "application/json")
3028

31-
return chain.proceed(request)
29+
if (accessToken.isNotBlank()) {
30+
requestBuilder.header(HEADER_AUTHORIZATION, "Bearer $accessToken")
31+
}
32+
33+
return chain.proceed(requestBuilder.build())
3234
}
33-
}
35+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.eatssu.android.domain.model
2+
3+
sealed interface ReissueTokenResult {
4+
data class Success(val token: Token) : ReissueTokenResult
5+
6+
data class Failure(
7+
val responseCode: Int? = null,
8+
val message: String? = null,
9+
val throwable: Throwable? = null
10+
) : ReissueTokenResult
11+
}

app/src/main/java/com/eatssu/android/domain/model/TokenState.kt

Lines changed: 0 additions & 31 deletions
This file was deleted.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
package com.eatssu.android.domain.repository
22

3+
import com.eatssu.android.domain.model.ReissueTokenResult
34
import com.eatssu.android.data.remote.dto.request.CheckValidTokenRequest
45
import com.eatssu.android.domain.model.Token
56
import com.eatssu.common.enums.DeviceType
67

78
interface OauthRepository {
89
suspend fun reissueToken(
910
refreshToken: String,
10-
): Token?
11+
): ReissueTokenResult
1112

1213
suspend fun login(
1314
email: String,
@@ -17,4 +18,3 @@ interface OauthRepository {
1718

1819
suspend fun checkValidToken(body: CheckValidTokenRequest): Boolean
1920
}
20-

0 commit comments

Comments
 (0)