버미

[KMP] Ktor Auth Plugin은 401을 어떻게 처리할까? Bearer 토큰 자동 갱신 흐름 분석 본문

안드로이드/KMP

[KMP] Ktor Auth Plugin은 401을 어떻게 처리할까? Bearer 토큰 자동 갱신 흐름 분석

Bum_2 2026. 2. 14. 00:26

OkHttp를 사용할 때는 OkHttp 클라이언트에 체이닝을 걸어 Interceptor와 Authenticator로 access token을 헤더에 추가하고, 401이 발생하면 refresh token을 통해 토큰을 갱신하는 구조를 직접 설계했다.

하지만 Ktor Client에서는 Auth 플러그인과 Bearer Provider만 설정하여 이러한 흐름을 처리할 수 있다.

그렇다면 Ktor는 내부적으로 어떤 흐름으로 401을 감지하고, 언제 refreshTokens를 호출하며, 어떻게 동일한 요청을 재시도하는 걸까? 이번 글에서는 Ktor Bearer 인증의 내부 동작 과정을 코드 레벨에서 분석해보려고 한다.


Ktor

Ktor는 코틀린에서 사용할 수 있는 비동기 HTTP 클라이언트 기반 라이브러리다.

내부적으로 코루틴을 기반으로 동작하며 각 플랫폼(iOS, Android, Desktop)에 맞는 엔진을 통해 네트워크 통신을 수행한다.


AuthProvider

ktor-client-auth 에서 공식적으로 제공하는 AuthProvider는 Bearer, Basic, Digest Auth 프로바이더가 있다.

이 외에도 AuthProvider를 자신의 서비스에 맞게 생성하여 사용할 수 있다.

Basic은 (TLS 위에서) 단순 자격증명 방식이라 토큰 기반(OAuth/JWT) 시나리오에는 보통 사용하지 않으며, Digest는 현대 API에서는 덜 쓰인다.

 


BearerAuthProvider

BearerAuthProvider는 Authorization 에 "Bearer $token"을 붙이는 API에 사용하는 프로바이더다.

시그니처는 아래와 같다.

 

public class BearerAuthProvider(
    private val refreshTokens: suspend RefreshTokensParams.() -> BearerTokens?,
    loadTokens: suspend () -> BearerTokens?,
    private val sendWithoutRequestCallback: (HttpRequestBuilder) -> Boolean = { true },
    private val realm: String?,
    cacheTokens: Boolean = true,
    private val nonCancellableRefresh: Boolean = false,
)

 

bearer 을 사용하면 AuConfig 의 bearer가 호출되고 생성자를 통해 BearerAUthProvider를 통해 초기화되는 구조다.

 

흐름과 같이 이해하기 위해 아래와 같이 코드를 작성했다고 하자.

 

fun creatApiHttpClient(
    engine: HttpClientEngine,
    tokenManager: TokenManager,
    isDebug: Boolean = true,
    baseUrl: String,
    additionalConfig: HttpClientConfig<*>.() -> Unit = {}
): HttpClient = HttpClient(engine = engine) {
    defaultRequest {
        url.takeFrom(baseUrl)
    }

	...

    install(Auth) {
        bearer {
            sendWithoutRequest { request ->
                val path = request.url.encodedPath
                !(path.startsWith(ApiPaths.LOGIN_PLATFORM) ||
                        path.startsWith(ApiPaths.TOKEN_REFRESH))
            }
            loadTokens {
                val access = tokenManager.loadAccessToken().takeIf { it.isNotBlank() }
                val refresh = tokenManager.loadRefreshToken().takeIf { it.isNotBlank() }
                if (access != null && refresh != null) {
                    BearerTokens(accessToken = access, refreshToken = refresh)
                } else null
            }
            refreshTokens {
                tokenManager.refreshAndGetNewAccessToken()
                    .takeIf { it.isNotBlank() }
                    ?.let { newAccess ->
                        val newRefresh = tokenManager.loadRefreshToken().takeIf { it.isNotBlank() }
                        BearerTokens(accessToken = newAccess, refreshToken = newRefresh ?: "")
                    }
            }
        }
    }

    additionalConfig()
}

 

 

설명할 부분은 bearer 블럭에서 sendWithoutRequest 블럭, loadTokens 블럭, refreshTokens 블럭이다.

먼저 sendWithoutRequest 블럭부터 살펴보자.


sendWithoutRequest 블럭

이 블럭은 API 요청을 하기 직전에, onRequest 블럭에서 sendWithoutRequest가 true를 반환하는 provider에 한해서 Authorization 헤더를 붙인다. Authorization 헤더를 붙이지 말아야할 API Path 들에 대해서 사용한다.

아래 코드를 보자.

 

@OptIn(InternalAPI::class)
public val Auth: ClientPlugin<AuthConfig> = createClientPlugin("Auth", ::AuthConfig) {
    val providers = pluginConfig.providers.toList()

    client.attributes.put(AuthProvidersKey, providers)

    val tokenVersions = ConcurrentMap<AuthProvider, AtomicCounter>()
    val tokenVersionsAttributeKey =
        AttributeKey<MutableMap<AuthProvider, Int>>("ProviderVersionAttributeKey")

	...
    
   onRequest { request, _ ->
        providers.filter { it.sendWithoutRequest(request) }.forEach { provider ->
            LOGGER.trace { "Adding auth headers for ${request.url} from provider $provider" }
            val tokenVersion = tokenVersions.computeIfAbsent(provider) { AtomicCounter() }
            val requestTokenVersions = request.attributes
                .computeIfAbsent(tokenVersionsAttributeKey) { mutableMapOf() }
            requestTokenVersions[provider] = tokenVersion.atomic.value
            provider.addRequestHeaders(request)
        }
    } 
}

<ktor-client-auth의 Auth.kt 중 일부>

 

 

토큰의 최신화 파악을 위해, 프로바이더에서 사용한 토큰의 Counter 값을 스냅샷 처리한다. 이는 refreshTokens에서 사용한다.

 

이 부분에서 이해가가지 않은 점이 있었다. sendWithoutRequest 네이밍을 사용해서 이 프로바이더가 True라면 헤더에 Authorization 값을 붙여서 보낸다는 점이다. 그렇다면 sendWithoutRequest가 아니라, sendRequest 이라는 네이밍을 사용했다면 직관적으로 와닿지 않았을까라는 생각을 했다.

찾아보니, 이 콜백의 원래 의도는 401을 기다리지 말고, 처음 요청부터(= without challenge) Authorization을 붙일지 말지 결정하는 용도라고 한다.
즉 네이밍은
"send (auth) without request(ing auth)" 보다는 "challenge(WWW-Authenticate)를 기다리지 않고 먼저 보낼래?” 같은 뉘앙스에 더 가깝다. 이는 “challenge 없이도 Authorization을 선제적으로 붙일지” 의 의미가 숨어있다.

sendWithoutRequest 에서 "request"가 "내가 보내는 HTTP 요청"이 아니라 "서버가 보내는 인증 요구(challenge)" 아는 것이다.
결론적으로, 서버의 인증 없이 보내도 되는 API 라는 것.

 


loadTokens

이 블럭은 앱 내부에서 엑세스 토큰을 로드하는 로직을 담당한다.

BearerTokens(access_token, refresh_token)을 반환해야하며, access_token을 담아 리턴해야한다.

public val Auth: ClientPlugin<AuthConfig> 에서 addRequestHeaders 메소드를 사용할 때 내부에서 AuthTokenHolder 인스턴스가 호출한다.

 

public class BearerAuthProvider(
    private val refreshTokens: suspend RefreshTokensParams.() -> BearerTokens?,
    loadTokens: suspend () -> BearerTokens?,
    private val sendWithoutRequestCallback: (HttpRequestBuilder) -> Boolean = { true },
    private val realm: String?,
    cacheTokens: Boolean = true,
    private val nonCancellableRefresh: Boolean = false,
)  : AuthProvider {

    private val tokensHolder = AuthTokenHolder(loadTokens, cacheTokens)
    
        override suspend fun addRequestHeaders(request: HttpRequestBuilder, authHeader: HttpAuthHeader?) {
        val token = tokensHolder.loadToken() ?: return

        request.headers {
            val tokenValue = "Bearer ${token.accessToken}"
            if (contains(HttpHeaders.Authorization)) {
                remove(HttpHeaders.Authorization)
            }
            if (request.attributes.contains(AuthCircuitBreaker).not()) {
                append(HttpHeaders.Authorization, tokenValue)
            }
        }
    }

    public override suspend fun refreshToken(response: HttpResponse): Boolean {
        val newToken = tokensHolder.setToken(nonCancellableRefresh) {
            refreshTokens(RefreshTokensParams(response.call.client, response, tokensHolder.loadToken()))
        }
        return newToken != null
    }
	
	...
}

<ktor-client-auth의 BearerAuthProvider.kt 중 일부>


refreshTokens

이 블럭은 엑세스 토큰 갱신 로직을 수행하는 블럭이다.

이것 또한, loadTokens와 마찬가지로 BearerTokens(access_token, refresh_token)을 반환해야하며, access_token은 담아 리턴해야한다. 

 

@OptIn(InternalAPI::class)
public val Auth: ClientPlugin<AuthConfig> = createClientPlugin("Auth", ::AuthConfig) {
    val providers = pluginConfig.providers.toList()

    client.attributes.put(AuthProvidersKey, providers)

    val tokenVersions = ConcurrentMap<AuthProvider, AtomicCounter>()
    val tokenVersionsAttributeKey =
        AttributeKey<MutableMap<AuthProvider, Int>>("ProviderVersionAttributeKey")

	...
    
    suspend fun Send.Sender.executeWithNewToken(
        call: HttpClientCall,
        provider: AuthProvider,
        oldRequest: HttpRequestBuilder,
        authHeader: HttpAuthHeader?
    ): HttpClientCall {
        val request = HttpRequestBuilder()
        request.takeFromWithExecutionContext(oldRequest)
        provider.addRequestHeaders(request, authHeader)
        request.attributes.put(AuthCircuitBreaker, Unit)

        LOGGER.trace { "Sending new request to ${call.request.url}" }
        return proceed(request)
    } 
    
    suspend fun refreshTokenIfNeeded(
        call: HttpClientCall,
        provider: AuthProvider,
        request: HttpRequestBuilder
    ): Boolean {
        val tokenVersion = tokenVersions.computeIfAbsent(provider) { AtomicCounter() }
        val requestTokenVersions = request.attributes
            .computeIfAbsent(tokenVersionsAttributeKey) { mutableMapOf() }
        val requestTokenVersion = requestTokenVersions[provider]

        if (requestTokenVersion != null && requestTokenVersion >= tokenVersion.atomic.value) {
            LOGGER.trace { "Refreshing token for ${call.request.url}" }
            if (!provider.refreshToken(call.response)) {
                LOGGER.trace { "Refreshing token failed for ${call.request.url}" }
                return false
            } else {
                requestTokenVersions[provider] = tokenVersion.atomic.incrementAndGet()
            }
        }
        return true
    }
    
    on(Send) { originalRequest ->
        val origin = proceed(originalRequest)
        if (!pluginConfig.isUnauthorizedResponse(origin.response)) return@on origin
        if (origin.request.attributes.contains(AuthCircuitBreaker)) return@on origin

        var call = origin

        val candidateProviders = HashSet(providers)

        while (pluginConfig.isUnauthorizedResponse(call.response)) {
            LOGGER.trace { "Unauthorized response for ${call.request.url}" }

            val (provider, authHeader) = findProvider(call, candidateProviders) ?: run {
                LOGGER.trace { "Can not find auth provider for ${call.request.url}" }
                return@on call
            }

            LOGGER.trace { "Using provider $provider for ${call.request.url}" }

            candidateProviders.remove(provider)
            if (!refreshTokenIfNeeded(call, provider, originalRequest)) return@on call
            call = executeWithNewToken(call, provider, originalRequest, authHeader)
        }
        return@on call
    }
}

 

<Auth.kt>

 

on(Send) 블럭에서 API를 호출한 resonse값을 pluginConfig.isUnauthorizedResponse() 메서드로 401이 발생했는지 아닌지 확인해서 401이 아니라면 return 하여 추가적인 로직을 수행하지 않는다.

401이 발생했다면, Providers 중에서 최신화된 토큰 counter을 사용하지 않는 것을 대상이 있다면 refreshTokenIfNeeded 메소드를 통해 업데이트한다. 

refreshTokenIfNeeded 메소드에서 최신화된 Counter 값을 사용하여 401이 발생한 경우, refreshTokens 블럭이 콜백으로 호출된다.

 

refreshTokenIfNeeded 가 호출되기 까지의 흐름을 정리해보자면 아래와 같다.

  • 요청을 보낼 때(onRequest) “이 요청이 어떤 토큰 버전으로 나갔는지” 스냅샷을 request attribute에 저장함.
  • 401이 왔을 때(on(Send)), 무조건 refresh를 요청하면 동시에 여러 요청이 401일 때 refresh 폭발이 날 수 있음.
  • 그래서 아래의 케이스인지 비교:

case 1) 이 요청이 보낸 토큰이 ‘최신 버전’이었다
→ 그럼 “진짜로 토큰이 만료된 것”일 가능성이 높으니 refresh 시도.

case 2) 이 요청이 보낸 토큰이 ‘최신이 아니게 되어버렸다’ (다른 코루틴/요청이 이미 refresh 해버림)
→ 굳이 또 refresh 하지 말고, 그냥 새 토큰으로 재시도.

 

AuthCircuitBreaker로 내가 재시도한 요청이 또 401이어도 다시 auth 로직 타지 않도록 무한 루프 방지.

 

 

 

- Refrences