1. 서론
OkHttp+Retrofit2 조합이 안드로이드에서 보편적으로 쓰이는데,
Ktor Client를 사용해보면서, 코드작성시 좀더 코루틴 친화적으로 구현이 가능해서 편했다.
인터셉터부분을 ktor에서 처리하면서 배운 내용들이 있어 작성한다.
2. 프로젝트 설정
Ktor CIO 및 k-serialization을 추가한다.
[versions]
ktor = "3.1.2"
kotlin = "2.0.10"
[libraries]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
[bundles]
ktor = [
"ktor-client-core",
"ktor-client-cio",
"ktor-client-content-negotiation",
"ktor-serialization-kotlinx-json",
"ktor-client-logging",
"ktor-client-auth",
]
plugins{
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.bundles.ktor)
}
3. Ktor Auth 기본 사용법
처음에는 인터셉터 로직을 직접 구현하려고 했었다.
그러나, 리프래시토큰 재발급이 계속 작동이 안돼서, 공식문서를 찾아보니 이미 Auth 플러그인이 존재했다(!..)
재발급시 동시성 문제 까지 적당히 코루틴으로 구현이 되어있다.
Auth 플러그인은 주로 Bearer 토큰 기반 인증을 다룰때 사용하고,
서버로 요청을 보낼 때 자동으로 Authorization 헤더를 붙이거나, 토큰이 만료되었을 경우 자동으로 refresh 로직을 실행해 준다.
install(Auth) {
bearer {
loadTokens { ... }
refreshTokens { ... }
sendWithoutRequest { ... }
}
}
loadTokens
- 현재 저장된 토큰을 불러오는데 사용한다.
refreshTokens
- 서버에서 401 Unauthorized 응답이 왔을 때 호출된다.
- 기존 accessToken 만료시, 새 accessToken을 발급받는 로직을 정의한다.
sendWithoutRequest
- 이 요청에 Authorization 헤더를 붙일지 말지를 판단한다.
- 로그인이나 회원가입 요청에는 토큰이 없거나 필요하지 않기 때문에, 해당 요청엔 헤더를 빼야한다.
전체 코드
install(Auth) {
bearer {
loadTokens {
val jwt = jwtRepository.getJwtFlow().first()
jwt?.let {
BearerTokens(
accessToken = it.accessToken,
refreshToken = it.refreshToken
)
}
}
refreshTokens {
if (oldTokens != null) {
val jwt = oldTokens!!
return@refreshTokens try {
val res = client.post("/auth/token") {
headers[HttpHeaders.Authorization] = "Bearer ${jwt.refreshToken}"
}.body<AccessTokenRes>()
jwtRepository.saveAccessToken(res.accessToken)
BearerTokens(
accessToken = res.accessToken,
refreshToken = jwt.refreshToken
)
} catch (_: Exception) {
jwtRepository.delete()
client.authProvider<BearerAuthProvider>()?.clearToken()
null
}
} else {
// Emit Refresh Event
null
}
}
// sendWithoutRequest가 true면 사전인증에 해당
// 사전인증: 요청 보내기 전에 헤더 붙임 (sendWithoutRequest == true)
// 도전 기반 인증 (challenge-based auth): 401 응답 받고 나서 붙임 (sendWithoutRequest == false)
sendWithoutRequest { request ->
// If it is a login request, do not send the token
val shouldNotJwt = sendWithoutJwtUrls.any {
request.url.encodedPath == it
}
Log.d("Request", "Not Apply JWT Request: ${shouldNotJwt}")
!shouldNotJwt
}
}
}
4. Ktor Auth 구현 내용
Auth 플러그인은 onRequest, on(Send) 두 군데에서 구현되어있다.
요청이 나가기 직전에, 앞서 설정한, sendWithoutRequest가 true인 경우,
각 인증 공급자(AuthProvider)가 인증정보를 헤더를 붙인다.
또한, 현재 토큰의 버전을 ktor request의 attributes에 넣는작업을 진행한다.
401 응답이라면, 리프래시 처리를 수행한다.
- AuthCircuitBreaker 체크: executeWithNewToken()함수에서 AuthCircuitBreaker를 붙이고 요청이 가기때문에,
신규 토큰 발급 무한 루프를 방지한다. - 이후 providers를 while 루프를 통해 순회하며 재발급을 진행한다.(응답이 401이 오지않을때까지 반복)
- refreshTokenIfNeeded를 통해 토큰 갱신시도
성공 시, 토큰 버전을 증가시켜 중복 요청 방지 - 이후, 재발급된 토큰을 통해 기존 요청
tokenVersion : 현재 provider의 최신 버전에 해당한다.
requestTokenVersion : 요청당시에 onRequest에 부착했던 시점의 토큰 버전에 해당한다.
- 우선 if 바깥조건, (요청당시 버전 < 현재 provider 최신 토큰 버전)이라면, 신규토큰으로 교채시켜줘야하므로 true로 빠져나간다.
- if 내부조건, provider.refreshToken() 함수가 실패하면, false를 리턴하여, return@on call로 에러를 전파한다.
- provider.refreshToken()함수가 성공하면, 해당 provider의 토큰 버전을 증가시킨다.
여기까지만 보면, 동시성에 대한 대비책은 없는것으로 보인다.
동시에 여러번 실패했을때, 같은 버전토큰정보를 가지고, 동시에 재발급을 시도할 수 있다.(provider.refreshToken() 동시에 호출)
그렇다면 이에 대한 방지는 해당 구현체에서 제공해야한다는 것이다.
BearerAuthProvider 구현체를 보면, tokensHolder라는 저장소가 있고, 해당 클래스의 함수를 호출한다.
여기까지는 여전히 동시성에 대한 구현이 없다.
우선 해당 코드의 구현을 보면, 다음과 같은 전제에서 사용된다.
- loadToken()은 인증정보 헤더 부착시 호출될 수 있다.
- setToken()함수 호출시, 내부에서 loadToken()함수가 호출된다.
아래 구현내용을보면,
setToken과 loadToken 호출시, 모든 요청은 직렬화된다는 내용이다.
즉,
val prevValue = value
해당 블록에 동시에 진입하면 재발급은 원자적으로 진행되지만, 만약 setToken호출한 시점에 이미, 다른 곳에서 setToken이 완료된다면, 여러번 재발급이 이루어질수있다.
하지만, 현실적으로 재발급은 cpu레벨이 아닌, 네트워크콜을 거치므로 가능성은 낮다고본다..
즉, 일반적인 상황에서 리프래시 토큰 재발급은 1회만 이루어지며,
토큰재발급시, 신규요청 큐잉처리는 해당하지 않는다.(401실패이후, 토큰 재발급 동안 suspend된다)
이를 위해서는 따로 mutex 처리를 해주어야 한다.
internal class AuthTokenHolder<T>(private val loadTokens: suspend () -> T?) {
@Volatile private var value: T? = null
@Volatile private var isLoadRequest = false
private val mutex = Mutex()
/**
* Exist only for testing
*/
internal fun get(): T? = value
/**
* Returns a cached value if any. Otherwise, computes a value using [loadTokens] and caches it.
* Only one [loadToken] call can be executed at a time. The other calls are suspended and have no effect on the cached value.
*/
internal suspend fun loadToken(): T? {
if (value != null) return value // Hot path
val prevValue = value
return if (coroutineContext[SetTokenContext] != null) { // Already locked by setToken
value = loadTokens()
value
} else {
mutex.withLock {
isLoadRequest = true
try {
if (prevValue == value) { // Raced first
value = loadTokens()
}
} finally {
isLoadRequest = false
}
value
}
}
}
private class SetTokenContext : CoroutineContext.Element {
override val key: CoroutineContext.Key<*>
get() = SetTokenContext
companion object : CoroutineContext.Key<SetTokenContext>
}
private val setTokenMarker = SetTokenContext()
/**
* Replaces the current cached value with one computed with [block].
* Only one [loadToken] or [setToken] call can be executed at a time,
* although the resumed [setToken] call recomputes the value cached by [loadToken].
*/
internal suspend fun setToken(block: suspend () -> T?): T? {
val prevValue = value
val lockedByLoad = isLoadRequest
return mutex.withLock {
if (prevValue == value || lockedByLoad) { // Raced first
val newValue = withContext(coroutineContext + setTokenMarker) {
block()
}
if (newValue != null) {
value = newValue
}
}
value
}
}
/**
* Resets the cached value.
*/
@OptIn(DelicateCoroutinesApi::class)
internal fun clearToken(coroutineScope: CoroutineScope = GlobalScope) {
if (mutex.tryLock()) {
value = null
mutex.unlock()
} else {
coroutineScope.launch {
mutex.withLock {
value = null
}
}
}
}
}
'Android' 카테고리의 다른 글
[Android] Jetpack Compose에서 Props drilling을 피하는 패턴 (1) | 2025.04.11 |
---|---|
[Android] 선언형과 리액티브 프로그래밍을 왜 사용하는가 (0) | 2025.04.07 |
[Android] 아키텍처 정리 (1) | 2025.01.31 |
[Android] Navigation 정리 (0) | 2025.01.30 |