@@ -24,6 +24,9 @@ import okhttp3.OkHttpClient
2424import okhttp3.Request
2525import okhttp3.RequestBody.Companion.toRequestBody
2626import okio.Timeout
27+ import java.io.IOException
28+ import java.util.concurrent.locks.ReentrantLock
29+ import kotlin.concurrent.withLock
2730import retrofit2.Retrofit
2831import retrofit2.converter.jackson.JacksonConverterFactory
2932import timber.log.Timber
@@ -84,45 +87,82 @@ object NetworkModule {
8487 sessionModule : SessionModule ,
8588 ): Authenticator {
8689 val authenticatorClient = createOkHttpClient(null , null )
90+ val refreshLock = ReentrantLock ()
91+ var lastRefreshedAccessToken: String? = null
92+
8793 return Authenticator { _, response ->
8894 if (response.code == 401 ) {
89- Timber .d(" [NetworkModule] Refresh tokens with Authenticator" )
90- val currentSession = sessionModule.sessionState.value
91- if (! currentSession.isLoggedIn()) return @Authenticator null
92-
93- val previousApiToken = currentSession.apiToken
94- val headers = Headers .headersOf(
95- " Accept" , " application/json" ,
96- " X-APP-KEY" , BuildConfig .appKey,
97- " X-APP-VERSION" , BuildConfig .VERSION_NAME ,
98- " X-USER-PLATFORM" , " AOS" ,
99- " X-USER-ID" , currentSession.memberId,
100- )
101- val refreshRequest = Request .Builder ()
102- .url(BuildConfig .apiBaseUrl + " v1/auth/refresh" )
103- .headers(headers)
104- .post(
105- " {\" refreshToken\" : \" ${previousApiToken.refreshToken} \" }"
106- .toRequestBody(" application/json" .toMediaType())
95+ Timber .d(" [NetworkModule] 401 received, attempting token refresh" )
96+
97+ refreshLock.withLock {
98+ val currentSession = sessionModule.sessionState.value
99+ if (! currentSession.isLoggedIn()) return @Authenticator null
100+
101+ val failedRequestToken = response.request.header(" X-AUTH-TOKEN" )
102+ val currentAccessToken = currentSession.apiToken.accessToken
103+
104+ // 다른 스레드가 이미 refresh에 성공한 경우, 새 토큰으로 재시도
105+ if (failedRequestToken != null
106+ && failedRequestToken != currentAccessToken
107+ && currentAccessToken == lastRefreshedAccessToken
108+ ) {
109+ Timber .d(" [NetworkModule] Token already refreshed by another thread, retrying" )
110+ return @Authenticator response.request
111+ .newBuilder()
112+ .removeHeader(" X-AUTH-TOKEN" )
113+ .addHeader(" X-AUTH-TOKEN" , currentAccessToken)
114+ .build()
115+ }
116+
117+ val previousApiToken = currentSession.apiToken
118+ val headers = Headers .headersOf(
119+ " Accept" , " application/json" ,
120+ " X-APP-KEY" , BuildConfig .appKey,
121+ " X-APP-VERSION" , BuildConfig .VERSION_NAME ,
122+ " X-USER-PLATFORM" , " AOS" ,
123+ " X-USER-ID" , currentSession.memberId,
107124 )
108- .build()
109- kotlin.runCatching {
110- authenticatorClient.newCall(refreshRequest).execute().use { refreshResponse ->
111- if (refreshResponse.isSuccessful) {
112- val newToken = refreshResponse.body!! .string()
113- val newTokenObject = Gson ().fromJson(newToken, AuthResult ::class .java)
114-
115- sessionModule.onRefreshToken(newTokenObject)
116- return @Authenticator response.request
117- .newBuilder()
118- .removeHeader(" X-AUTH-TOKEN" )
119- .addHeader(" X-AUTH-TOKEN" , newTokenObject.accessToken)
120- .build()
121- } else throw RuntimeException ()
125+ val refreshRequest = Request .Builder ()
126+ .url(BuildConfig .apiBaseUrl + " v1/auth/refresh" )
127+ .headers(headers)
128+ .post(
129+ " {\" refreshToken\" : \" ${previousApiToken.refreshToken} \" }"
130+ .toRequestBody(" application/json" .toMediaType())
131+ )
132+ .build()
133+
134+ try {
135+ authenticatorClient.newCall(refreshRequest).execute()
136+ .use { refreshResponse ->
137+ if (refreshResponse.isSuccessful) {
138+ val newToken = refreshResponse.body!! .string()
139+ val newTokenObject =
140+ Gson ().fromJson(newToken, AuthResult ::class .java)
141+
142+ sessionModule.onRefreshToken(newTokenObject)
143+ lastRefreshedAccessToken = newTokenObject.accessToken
144+ Timber .d(" [NetworkModule] Token refresh succeeded" )
145+
146+ return @Authenticator response.request
147+ .newBuilder()
148+ .removeHeader(" X-AUTH-TOKEN" )
149+ .addHeader(" X-AUTH-TOKEN" , newTokenObject.accessToken)
150+ .build()
151+ } else {
152+ // 서버가 refresh token을 명시적으로 거부 (만료/무효)
153+ Timber .w(
154+ " [NetworkModule] Refresh failed with HTTP %d - invalidating session" ,
155+ refreshResponse.code
156+ )
157+ requireTokenInvalidRestart.value = true
158+ sessionModule.invalidateSession()
159+ }
160+ }
161+ } catch (e: IOException ) {
162+ // 네트워크 오류 (타임아웃, 연결 끊김 등)
163+ // 세션을 삭제하지 않음 — 디스크에 유효한 토큰이 남아있음
164+ Timber .w(e, " [NetworkModule] Network error during token refresh - NOT invalidating session" )
122165 }
123- }.onFailure {
124- requireTokenInvalidRestart.value = true
125- sessionModule.invalidateSession()
126166 }
127167 }
128168 null
0 commit comments