안드로이드 개발에서 멀티플랫폼을 사용하지 않는 이상, 대부분의 네트워크 통신은 Retrofit을 기반으로 합니다. 저도 예외는 아닌데요ㅎㅎ 잘 아시다시피 Retrofit은 내부적으로 OkHttp 위에서 동작하고, 토큰 기반 인증 로직을 구현하려면 결국 OkHttp의 특성을 직접 다루게 됩니다.
특히 리프레시 토큰을 이용한 토큰 재발급 과정은 단순히 “새 토큰 받아오기”가 아니라 여러 요청이 동시에 들어오는 상황에서의 동시성 문제까지 함께 고민해야 해서 생각보다 까다로운데요... 동시성은 어느 언어든 개발자를 괴롭히네요... 😅
이 글은 제가 OkHttp + Retrofit + Coroutines 조합을 사용하면서, Authenticator 안에 runBlocking을 섞어 쓰던 구조를 어떻게 리팩토링했는지를 정리한 기록입니다.
최근 코루틴을 공부하다가 문득 “동기와 비동기의 경계를 runBlocking으로 이어 붙이는 구조가 정말 안전한 걸까?” 하는 의문이 들었습니다. 아직까지 프로덕션 앱에서 문제가 보고된 적은 없지만 OkHttp가 사용하는 스레드를 직접 블로킹하는 방식은 언젠가 이슈를 만들 수 있다는 불안감이 계속 남아 있었어요.
그래서 시작된 말 그대로 예방 차원의 좌충우돌 리팩토링 이야기입니다. 😊
TL;DR
- OkHttp
Authenticator안에서runBlocking { suspend API 호출 }을 사용하면 Dispatcher 스레드를 점유한 채 같은 Dispatcher 기반 비동기 요청(enqueue)을 다시 보내는 구조가 만들어질 수 있습니다. maxRequestsPerHost(기본 5개) 제한과 겹치면 토큰 재발급 요청이 영원히 실행되지 않는 데드락 시나리오가 생길 수 있습니다.- 다행히 우리 프로젝트는 이미 토큰 갱신용 클라이언트를 분리해서 데드락 위험은 없었습니다.
- 하지만 비효율을 개선하고 구조적 안전성을 확보하기 위해 다음과 같이 리팩토링했습니다.
- 토큰 재발급을 동기
Call.execute()기반 API로 분리 - 리프레시 전용
OkHttpClient를 분리해 메인 요청 Dispatcher와 분리 synchronized로 동시 갱신 제어- 토큰은 메모리 캐시를 즉시 갱신, 영속 저장은 코루틴으로 비동기 처리
- 토큰 재발급을 동기
1. 환경과 출발점: Authenticator 안의 runBlocking
하이링구얼의 네트워크 스택은 대략 이렇게 구성되어 있습니다.
- Android + Kotlin
- Retrofit (코루틴 어댑터 사용,
suspendAPI) - OkHttp (공용
OkHttpClient하나를 여러 API에서 공유) - 토큰 기반 인증 + 자동 재발급
- 401 응답이 오면 OkHttp
Authenticator에서 토큰을 재발급한 뒤 요청을 재시도

문제는 Authenticator의 구현 방식이었습니다.
1.1 기존 Authenticator 구현
당시 Authenticator 코드는 대략 다음과 같았습니다.
@Singleton
class TokenAuthenticator @Inject constructor(
private val tokenManager: TokenManager,
private val tokenRefreshService: TokenRefreshService,
private val appRestarter: AppRestarter
) : Authenticator {
private val mutex = Mutex()
override fun authenticate(route: Route?, response: Response): Request? {
return runBlocking {
handleAuthentication(response)
}
}
private suspend fun handleAuthentication(response: Response): Request? = mutex.withLock {
// ... 토큰 갱신 로직 ...
val result = tokenRefreshService.refreshToken(refreshToken) // suspend 함수
// ...
}
}
이렇게 작성했던 이유는 토큰 재발급 과정에서 사용하는 Service 함수와 로컬에서 토큰을 읽고 쓰는 함수가 모두 suspend 형태였기 때문입니다.
401을 감지한 뒤 토큰을 로컬에서 읽고, 재발급 API를 호출한 뒤 다시 저장하는 일련의 과정은 비동기 처리가 기본이라 동기적인 authenticate() 안에서는 이 간극을 채우기 위해 runBlocking을 사용했어요.
여기서 핵심은 두 가지에요.
authenticate는 동기 함수- 그 안에서
runBlocking { suspend fun refreshToken(...) }을 호출하고 있다는 점
runBlocking은 새 코루틴을 실행하되 현재 스레드를 블로킹하는 함수입니다. 당시에는 “어차피 OkHttp가 내부적으로 별도 스레드풀에서 돌아가니까 여기서 스레드를 잠시 막아도 메인 스레드를 막지 않는다”라고 생각했어요.
하지만 실제로 OkHttp Dispatcher가 스레드를 어떻게 관리하는지 분석해보면 이 판단이 틀렸다는 걸 알 수 있었습니다. 단순히 “별도 스레드에서 돌기 때문에 안전하다”는 보장은 없었고, 구조적으로 데드락이 발생할 수 있는 형태였어요.
2. OkHttp Dispatcher와 호출 모델 이해하기
데드락 가능성을 판단하려면 OkHttp의 요청 실행 모델을 먼저 명확히 이해해야 합니다.
2.1 요청 실행 방식: execute vs enqueue
OkHttp는 요청을 크게 두 가지 방식으로 실행합니다.
- 동기 호출 (
execute)- 호출한 스레드에서 네트워크 작업을 직접 수행
- 비동기 호출 (
enqueue)- 현재 스레드에서 큐에 등록한 뒤 즉시 반환
- 실제 네트워크 처리는
Dispatcher의 스레드풀에서 처리됨
요약하면 아래와 같습니다.
execute() : 호출한 스레드가 직접 네트워크 처리
enqueue() : Dispatcher 스레드가 네트워크 처리
2.2 Dispatcher의 동시성 제한
OkHttp의 Dispatcher는 비동기 호출을 두 개의 큐로 관리해요.
runningAsyncCalls: 현재 실행 중인 비동기 호출들readyAsyncCalls: 동시성 제한 때문에 아직 실행되지 못하고 대기 중인 호출들
그리고 두 가지 숫자로 동시 실행 개수를 제한합니다.
maxRequests: 전체 비동기 요청 동시 실행 수 (기본 64개)maxRequestsPerHost: 호스트당 비동기 요청 동시 실행 수 (기본 5개)
흐름을 단순화해서 그리면 다음과 같습니다.

여기서 중요한 점은!!
동시 실행 개수 제한에 걸리면 새로운 비동기 호출은 readyAsyncCalls에만 쌓이고
실행 슬롯이 비워질 때까지 시작조차 하지 못한다는 점입니다.
3. 비결정적 데드락 시나리오와 팩트 체크
이제 기존 구현과 OkHttp의 동작이 어떻게 겹치는지 그리고 실제 상황은 어땠는지 살펴보겠습니다.
3.1 이론적인 데드락 시나리오
만약 우리가 하나의 OkHttp 클라이언트를 공유해서 쓰고 있었다면 다음과 같은 시나리오에서 데드락이 발생했을 거에요.
- 동일 호스트로 비동기 API를 5개 동시에 호출합니다.
maxRequestsPerHost = 5이므로 이 5개 요청은 모두 실행 중(runningAsyncCalls)
- 5개 모두 401 Unauthorized를 받습니다.
- OkHttp는 각 응답에 대해
Authenticator.authenticate()를 호출
- OkHttp는 각 응답에 대해
- 각
authenticate()에서runBlocking { refreshToken(...) }이 실행됩니다.- 이 시점에서 OkHttp Dispatcher의 5개 스레드가 모두 runBlocking 안에서 멈춰 있는 상태가 됩니다.
runBlocking은 어떤 디스패처를 쓰든 “호출한 스레드”를 끝까지 붙잡습니다.
refreshToken()은 Retrofit의suspendAPI이고 내부적으로 다시 같은OkHttpClient에 비동기 요청(enqueue)을 보냅니다.- 이게 6번째 요청입니다.
- Dispatcher 입장에서 보면
- “이 호스트에는 이미 5개 요청이 실행 중이네?
- →
maxRequestsPerHost를 넘으니까 6번째 요청은readyAsyncCalls에 넣고 대기시키자.”

결과적으로 서로가 서로를 기다리는 구조가 만들어집니다.
- 원래 5개의 요청은 토큰 재발급 요청이 끝나야
authenticate()를 빠져나오고 - 토큰 재발급 요청은 Dispatcher의 슬롯이 비어야 실행됩니다.
결국 둘 중 어느 쪽도 먼저 일어나지 않기 때문에 데드락이 됩니다. 🪨
3.2 근데 왜 괜찮았을까?
하지만 이미 프로젝트 내에서는 interceptor 분리를 위해 토큰 갱신용 클라이언트(@RefreshClient)를 이미 분리해서 사용하고 있었어요.
즉, 6번째 요청(토큰 갱신)은 꽉 찬 메인 클라이언트의 Dispatcher가 아니라 텅 빈 리프레시 클라이언트의 Dispatcher로 들어갔기 때문에 데드락은 발생하지 않는 구조였습니다. 💪🏻
3.3 그럼에도 리팩토링한 이유
데드락 위험이 없는데도 굳이 리팩토링을 진행한 이유는 runBlocking의 구조적 비효율성 때문입니다.
- 불필요한 스레드 점유:
runBlocking은 호출한 스레드를 아무 일도 못하게 붙잡아 둡니다. - 불필요한 컨텍스트 스위칭: 스레드를 잡아둔 채로 비동기 작업을 위해 또 다른 스레드로 이동했다가 돌아오는 비용이 발생합니다.
- 잠재적 위험: 만약 나중에 누군가 실수로 클라이언트를 합치게 된다면 즉시 데드락이 터지게 됩니다.
그래서 더 효율적이고 안전한 구조로 개선하기로 했습니다. 😄
4. 해결처럼 보였지만 실패한 접근들
실제 리팩토링 과정에서 몇 가지 방법을 시도해 봤어요. 결론적으로 모두 구조적 문제를 근본적으로 해결하지는 못했습니다. 😵
4.1 runBlocking(Dispatchers.IO)로 바꾸기
override fun authenticate(route: Route?, response: Response): Request? {
return runBlocking(Dispatchers.IO) {
val newToken = tokenRefreshService.refreshToken(...)
// ...
}
}
가정은 굉장히 단순한데요
“Dispatchers.IO를 쓰면 OkHttp 스레드를 점유하지 않는 거 아닐까?”
하지만 runBlocking의 블로킹 대상은 항상 자신을 호출한 스레드입니다. 컨텍스트를 IO로 바꾼다고 해서 호출 스레드가 해방되는 것이 아니라 “내가 지금 서 있는 스레드를 끝까지 붙잡고 있는” 구조는 그대로였습니다.
결국 OkHttp Dispatcher 스레드는 여전히 runBlocking 안에서 멈춰 있고 토큰 재발급 비동기 요청은 여전히 같은 Dispatcher의 빈 슬롯을 기다리는 상태가 됩니다.
4.2 Call.suspendExecute() 헬퍼 함수 만들기
“enqueue 대신 execute를 쓰면 어떨까?” 하는 생각으로 이런 헬퍼를 시도해봤어요.
suspend fun <T> Call<T>.suspendExecute(): Response<T> =
withContext(Dispatchers.IO) {
execute()
}
이 함수만 보면 괜찮아 보이지만 문제는 Authenticator가 여전히 동기 함수라는 점이에요. 여기서 이 suspend 함수를 호출하려면 또다시 runBlocking이 필요합니다.
override fun authenticate(...): Request? {
return runBlocking {
reissueService.reissueToken(refreshToken).suspendExecute()
}
}
다시 “동기 컨텍스트 + runBlocking + 내부에서 네트워크” 조합으로 돌아오게 되고, Dispatcher와의 충돌 가능성은 그대로 남습니다.
4.3 Interceptor로 로직을 옮기기
401 응답 처리를 Authenticator가 아닌 Interceptor에서 처리하는 방식도 고려했어요.
class AuthInterceptor(
private val tokenRefreshService: TokenRefreshService,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
if (response.code == 401) {
runBlocking {
val newToken = tokenRefreshService.refreshToken(...)
// ...
}
// 새 토큰으로 재시도...
}
return response
}
}
하지만 Interceptor 역시 OkHttp 내부 스레드에서 동기적으로 실행되는 구조라서 결국 같은 문제를 다른 위치로만 옮기는 셈이 됩니다.
Authenticator에서runBlocking을 돌리든Interceptor에서runBlocking을 돌리든
“Dispatcher 스레드를 막아둔 채 다시 같은 Dispatcher에 네트워크 요청을 보내는 구조” 자체가 변하지 않습니다. 네트워크 계층에서 도메인/데이터 계층을 직접 참조하게 되어 의존성이 꼬이는 Side Effect도 존재했어요.
5. 최종 설계 방향: 동기/비동기 경계를 다시 정의
결국 해결 방향은 돌고 돌아 정공법으로 돌아왔어요.
- 동기 컨텍스트 (
Authenticator, OkHttp 콜백)에서는 순수 동기 코드만 사용한다. - 비동기 컨텍스트 (UI, ViewModel, Repository)에서는
suspend코루틴 기반 코드만 사용한다.
그래서 아래 네 가지를 적용했습니다.
- 토큰 재발급 API를 동기
Call.execute()기반 인터페이스로 분리 - 토큰 재발급용 전용
OkHttpClient분리 - 동시 토큰 갱신 제어를
synchronized블록으로 처리 - 토큰은 메모리 캐시를 즉시 갱신하고, 영속 저장은 코루틴으로 비동기 처리

6. 구현해봅시다
이제 실제 프로젝트 코드 기준으로 어떻게 바꿨는지 단계별로 정리합니다.
6.1 Retrofit 토큰 재발급 API: 동기 Call 기반으로 분리
먼저 suspend 기반이었던 토큰 재발급 API를 동기 Call<T> 기반 인터페이스로 나눴습니다.
// data/auth/service/ReissueService.kt
interface ReissueService {
@POST("api/v1/users/reissue")
fun reissueToken(
@Header(AUTHORIZATION) refreshToken: String
): Call<BaseResponse<ReissueTokenResponseDto>> // suspend 제거, Call 반환
}
그리고 이 API를 감싸는 서비스 레이어에서 동기 호출을 수행합니다.
// data/auth/service/TokenRefreshServiceImpl.kt
internal class TokenRefreshServiceImpl @Inject constructor(
private val reissueService: ReissueService,
private val tokenManager: TokenManager,
@ApplicationScope private val appScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : TokenRefreshService {
override fun refreshToken(refreshToken: String): Result<Pair<String, String>> = runCatching {
synchronized(this) {
// ... 중복 갱신 체크 로직 ...
// 1. 동기 네트워크 호출 (Call.execute)
val response = reissueService.reissueToken("$BEARER $refreshToken").execute()
val data = response.body()?.data ?: throw Exception("...")
// 2. 캐시 즉시 업데이트 (동기)
tokenManager.updateTokensInCache(data.accessToken, data.refreshToken)
// 3. 영속 저장은 비동기 (Fire-and-forget)
appScope.launch(ioDispatcher) {
try {
tokenManager.saveTokens(data.accessToken, data.refreshToken)
} catch (e: Throwable) {
Timber.e(e, "Failed to save tokens to DataStore")
}
}
Pair(data.accessToken, data.refreshToken)
}
}
}
여기서 execute()는 호출한 스레드에서 블로킹 방식으로 동작합니다.
비동기 Dispatcher의 슬롯을 사용하지 않기 때문에 maxRequestsPerHost와 직접적인 충돌 없이 동작합니다.
6.2 토큰 재발급 전용 OkHttpClient 분리
토큰 재발급 요청이 메인 API 요청과 동일한 Dispatcher 제한을 공유하지 않도록, 전용 OkHttpClient를 분리해서 사용했습니다. 코드 상에서는 @RefreshClient 같은 Qualifier를 두고, 별도의 OkHttp 인스턴스를 주입받도록 구성했습니다.
핵심 아이디어는 다음과 같습니다.
- 메인 API →
MainOkHttpClient(공용,Authenticator부착) - 토큰 재발급 →
RefreshOkHttpClient(전용,Authenticator비부착)
이렇게 하면 토큰 재발급 요청은 메인 요청의 동시성 제한 상황과 독립적으로 실행할 수 있습니다.
6.3 TokenManager: 메모리 캐시 + 비동기 영속화
토큰 관리 책임은 TokenManagerImpl이 가지고 있습니다.
// core/localstorage/TokenManagerImpl.kt
@Singleton
class TokenManagerImpl @Inject constructor(
private val dataStore: DataStore<UserPreferences>,
@ApplicationScope private val externalScope: CoroutineScope
) : TokenManager {
@Volatile private var cachedAccessToken: String? = null
@Volatile private var cachedRefreshToken: String? = null
init {
// 앱 시작 시 비동기 초기화
externalScope.launch {
val preferences = dataStore.data.first()
cachedAccessToken = preferences.token
cachedRefreshToken = preferences.refreshToken
}
}
// 동기 함수
override fun getAccessToken(): String? = cachedAccessToken
// ... updateTokensInCache, saveTokens 등 구현 ...
}
여기서의 포인트는 동기 컨텍스트에서는 cachedAccessToken / cachedRefreshToken만 읽습니다.
디스크(제 경우에는 DataStore)에 쓰는 작업은 suspend 함수로 제공하고 호출 측에서 코루틴 컨텍스트를 정하도록 했습니다. 결국init 블록에서 미리 캐시를 올려두기 때문에 Authenticator에서 바로 사용이 가능합니다.
6.4 Authenticator: runBlocking 제거 + synchronized
최종적으로 Authenticator는 순수 동기 코드로 정리했습니다.
// core/network/auth/TokenAuthenticator.kt
@Singleton
class TokenAuthenticator @Inject constructor(
private val tokenManager: TokenManager,
private val tokenRefreshService: TokenRefreshService,
// ...
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
synchronized(this) {
// ...
val refreshToken = tokenManager.getRefreshToken() ?: return handleRefreshFailure()
// 동기 재발급 요청 실행 (내부에서 execute 호출)
val result = tokenRefreshService.refreshToken(refreshToken)
return if (result.isSuccess) {
val (newAccessToken, _) = result.getOrThrow()
response.request.newBuilder()
.header(AUTHORIZATION, "$BEARER $newAccessToken")
.build()
} else {
handleRefreshFailure()
}
}
}
}
여기서의 흐름은 다음과 같습니다.
synchronized(this)로 Authenticator 전체를 직렬화합니다.- 요청에 포함된 토큰과 현재 캐시된 토큰을 비교해서 이미 다른 스레드가 토큰을 갱신했다면 기존 요청만 새 토큰으로 교체해서 재시도합니다.
- 정말 갱신이 필요한 경우에만
tokenRefreshService.refreshToken을 호출합니다. - 실패한 경우에는 별도의 실패 처리 로직(
handleRefreshFailure)로 넘깁니다.
7. 새 구조의 실행 흐름 정리
리팩토링 이후 토큰 만료 상황에서 호출 흐름은 다음과 같이 정리할 수 있어요.

아래의 변화를 중점적으로 보면 좋아요.
- 토큰 재발급 호출은
RefreshOkHttpClient를 사용합니다. - 위 클라이언트는 메인 클라이언트와 서로 다른 Dispatcher를 사용하므로 메인 API 요청이
maxRequestsPerHost로 꽉 차 있어도 토큰 재발급 호출은 독립적으로 실행할 수 있습니다. - Authenticator 내부에는
runBlocking이나suspend가 없습니다. - 동기 코드만 있고 동기 코드가 다시 비동기 Dispatcher에 의존하는 구조를 제거했습니다.
8. 마무리..
이번 리팩토링에서 정리할 수 있었던 포인트를 간단히 다시 적어보면 다음과 같습니다.
- 라이브러리의 동시성/스레드 모델을 이해하는 것이 중요하다.
- OkHttp의
Dispatcher는 비동기 요청의 동시 실행 개수를 제한하고 이 제약과runBlocking을 섞었을 때 예상치 못한 데드락이 생길 수 있습니다. - 동기 컨텍스트에서 코루틴을 강제로 돌리는 패턴은 신중해야 한다.
- 특히 이미 제한된 스레드풀 위에서 동작하는 콜백(
Authenticator,Interceptor) 안에서는runBlocking하나가 전체 호출 흐름을 막는 원인이 될 수 있습니다. - 동기/비동기 경계를 명확히 나누면 설계가 단순해진다.
- Authenticator 같은 동기 API는 동기 네트워크 호출 +
synchronized만 사용 - UI/UseCase/Repository 쪽은
suspend/코루틴만 사용하도록 분리
- Authenticator 같은 동기 API는 동기 네트워크 호출 +
전체적으로 보면, “동기 컨텍스트에서는 동기 코드만, 비동기 컨텍스트에서는 코루틴만”이라는 단순한 원칙을 지키는 것만으로도 데드락 가능성을 크게 줄일 수 있었습니다. 긴글 읽어주셔서 감사합니다. 🙇🏻♂️
'Android' 카테고리의 다른 글
| Credential Manager로 로그인이 안돼요. 이유는 몰라요 (0) | 2025.11.18 |
|---|---|
| [Android] 앱을 배포해 봅시다. (0) | 2025.10.02 |
| [Android] 컨벤션 플러그인 뜯어고치기 (0) | 2025.09.25 |
| [Android] Baseline Profile 적용기 feat. Fake 주입 실패 (0) | 2025.09.23 |
| [Android] 실용적인 멀티모듈 아키텍처 설계 실전기 (Domain 레이어와 build-logic) (0) | 2025.08.10 |