Retrofit Authenticator와 Mutex로 토큰 Race Condition 해결하기

이제는 개발을 하면서 JWT와 REST API가 없는 개발은 보기 힘들게 됐어요.

앱에서도 예외는 아닌데요, 끊김 없는 UX를 유지하기 위해 액세스 토큰의 만료 처리는 필수입니다.

이 글에서는 프로젝트를 진행하면서 적용했던 Retrofit과 OkHttp의 Authenticator를 활용해 토큰을 자동으로 갱신하는 방법을 다룹니다. 이 과정에서 발생했던 경쟁 상태 문제를 Mutex로 해결한 경험을 공유합니다 😊

1. 왜 Authenticator를 사용하나요?

보통 API 요청 시 액세스 토큰이 만료되면 서버는 401 Unauthorized 응답을 내려줍니다. 이때 클라이언트는 리프레시 토큰을 사용해 새로운 액세스 토큰을 발급받고, 실패했던 요청을 다시 보내야 합니다.

이 과정을 구현하는 방법으로 OkHttp의 Authenticator 인터페이스를 사용할 수 있어요. Authenticator는 401 응답이 발생했을 때 자동으로 호출되는 '재인증 처리기'로서 다음과 같은 장점이 있습니다.

  • 비즈니스 로직마다 별도의 if (code == 401) 분기 처리를 넣을 필요가 없습니다.
  • 갱신된 토큰으로 새로운 Request를 반환하면 OkHttp가 자동으로 기존 요청을 재시도합니다.

하지만 단순히 Authenticator를 구현하는 것만으로는 해결되지 않는 문제가 있습니다. 바로 동시성 문제입니다.😵

2. 동시 요청과 경쟁 상태

앱을 실행하자마자 홈 화면에서 사용자 정보, 알림 목록, 배너 정보 등 여러 API를 동시에 호출한다고 가정해볼게요. 만약 이때 액세스 토큰이 만료된 상태라면 어떤 일이 벌어질까요?

  1. API 요청 A와 B가 거의 동시에 서버에 도달하고, 모두 401 에러를 받습니다.
  2. OkHttp는 각 요청에 대해 Authenticator를 별도로 호출합니다. 즉, 토큰 갱신 로직이 2번 실행됩니다.
  3. 서버에 동일한 리프레시 토큰으로 중복 갱신 요청이 전송됩니다. 먼저 도착한 요청에 의해 리프레시 토큰이 갱신되면, 뒤이어 도착한 요청은 이미 무효화된 토큰을 사용하게 되어 실패하게 됩니다.

3. Mutex로 동시성 문제 해결하기

이 문제를 해결하려면 "토큰 갱신은 한 번에 하나의 요청만 수행해야 한다"는 원칙을 지켜야 합니다. 이를 위해 Kotlin Coroutines의 Mutex를 사용했습니다.

Mutex란?

Mutex는 특정 코드 블록에 단 하나의 코루틴만 진입할 수 있도록 잠금을 거는 동기화 도구입니다.

Authenticator에 Mutex를 적용하면 흐름이 다음과 같이 정돈됩니다.

  1. 요청 A 진입: 락(Lock)을 획득하고 토큰 갱신을 시작
  2. 요청 B 대기: 락이 걸려있으므로 갱신이 끝날 때까지 대기
  3. 요청 A 완료: 갱신된 토큰을 저장하고 락을 해제
  4. 요청 B 진입 & 확인: 락을 획득한 요청 B는 "이미 토큰이 갱신되었는지" 먼저 확인. 갱신되었다면 별도의 API 호출 없이 저장된 새 토큰을 바로 사용

실제 작성한 코드

class TokenAuthenticator(
    private val tokenManager: TokenManager,
    private val tokenRefreshService: TokenRefreshService
) : Authenticator {

    private val mutex = Mutex()

    override fun authenticate(route: Route?, response: Response): Request? {
        // 1. runBlocking으로 동기 메서드와 코루틴 연결
        return runBlocking {
            // 2. Mutex로 임계 구역 보호 (한 번에 하나만 진입)
            mutex.withLock {
                // 3. 중복 갱신 방지: 현재 토큰이 이미 갱신되었는지 확인
                val currentToken = tokenManager.getToken()
                if (isTokenRefreshed(response, currentToken)) {
                    // 이미 갱신되었다면 API 호출 없이 새 토큰만 적용하여 반환
                    return@withLock newRequestWithToken(response.request, currentToken)
                }

                // 4. 토큰 갱신 API 호출 (실제 갱신은 여기서만 일어남)
                val newToken = tokenRefreshService.refreshToken() ?: return@withLock null

                // 5. 결과 저장 및 재시도 요청 반환
                tokenManager.saveToken(newToken)
                newRequestWithToken(response.request, newToken)
            }
        }
    }

    // 이미 갱신된 토큰인지 확인하는 헬퍼 함수
    private fun isTokenRefreshed(response: Response, currentToken: String): Boolean {
        val failedRequestToken = response.request.header("Authorization")
        return failedRequestToken != "Bearer $currentToken"
    }

    // 새 토큰을 적용한 Request 생성
    private fun newRequestWithToken(request: Request, token: String): Request {
        return request.newBuilder()
            .header("Authorization", "Bearer $token")
            .build()
    }
}

4. runBlocking은 쓰지 말라던데요?

안드로이드 개발에서 "메인 스레드에서 runBlocking을 사용하지 말라"는 것은 잘 알려진 룰인데요. 하지만 Authenticator 내부에서는 runBlocking을 안전하게 사용할 수 있습니다.

1. 인터페이스의 불일치 → 동기 vs 비동기

가장 근본적인 이유는 OkHttp가 제공하는 Authenticator 인터페이스의 설계에 있어요.

// OkHttp Authenticator
fun authenticate(route: Route?, response: Response): Request?

authenticate 메서드는 suspend 키워드가 없는 동기 함수입니다. 반면에 우리가 호출해야 할 토큰 갱신 로직(tokenRefreshService.refreshToken())은 네트워크 통신을 하므로 비동기 함수입니다.

코틀린의 규칙상 동기 함수 내부에서는 비동기 함수를 직접 호출할 수 없으므로 runBlocking을 사용하여 동기 함수 내에서 코루틴을 실행할 수 있는 환경을 만들어주어야 해요.

2. "나중에 줄게"는 안 된다

GlobalScope.launch나 async는 결과를 나중에 주겠다는 '약속'(Job, Deferred)을 반환하고 다음 코드로 넘어가지만 authenticate 메서드는 지금 당장 갱신된 정보를 담은 Request 객체를 리턴값으로 요구합니다.

OkHttp는 이 함수가 Request 객체를 반환할 때까지 멈춰서 기다리다가 반환받는 즉시 재시도를 수행하도록 설계되어 있어요. 따라서 비동기 작업이 끝날 때까지 현재 스레드를 붙잡아두고 결과를 받아내야만 정상적인 재시도가 가능합니다.

도메인 관점에서도 생각해보면 인증이 만료된 사용자가 재인증을 하기까지의 과정을 비동기로 만든다면 그 사이에 다른 행동들을 허락한다는 의미가 되겠죠? 금고의 비밀번호를 바꾸는 동안 자유롭게 출입을 허용하는 것과 같습니다.

3. 그래도 UI가 멈추지 않나요?

runBlocking은 현재 스레드를 차단하므로, 만약 메인 스레드에서 실행된다면 앱이 멈추는 문제가 발생합니다. 하지만 Authenticator에서는 안전해요.

  • 통상적으로 안드로이드는 메인 스레드에서의 네트워크 통신을 금지(NetworkOnMainThreadException)합니다. 따라서 모든 정상적인 API 요청은 이미 백그라운드 워커 스레드에서 실행되고 있어요.
  • 401 에러가 발생하여 Authenticator가 호출되는 시점 또한 해당 요청을 처리하던 백그라운드 스레드 내부입니다.

결과적으로 runBlocking이 멈추게 하는 것은 사용자에게 보이지 않는 네트워크 전용 스레드일 뿐입니다.👍🏻

마무리

이번 글에서는 Android 앱에서 토큰 자동 갱신을 구현하면서 만났던 동시성 문제와 그 해결 과정을 공유해봤어요.

핵심 내용을 정리하면 다음과 같습니다.

  • 동시에 여러 API가 호출될 때 Race Condition으로 인해 토큰이 중복 갱신되는 문제가 발생할 수 있다.
  • Mutex를 사용하면 토큰 갱신이 한 번만 수행되도록 보장하여 이 문제를 해결할 수 있다.
  • Authenticator의 동기 인터페이스 특성상 runBlocking이 필요하지만 백그라운드 스레드에서 동작하므로 안전다.

이 방법을 적용하면 사용자는 토큰 만료를 전혀 의식하지 않고 자연스러운 사용 경험을 누릴 수 있습니다.

혹은 토큰 갱신이 실패했을 때의 에러 처리(로그아웃, 재로그인 유도 등)나 갱신 재시도 로직을 추가하면 더욱 좋은 앱이 되겠네요!

여러분의 프로젝트에서도 이 방법이 도움이 되길 바랍니다! 😊