안녕하세요!
매우.. 오랜만에 글을 쓰는 것 같은 기분이네요🥲
이 글은 최근 프로젝트를 진행하면서 구현했던 부분을 잊지 않기 위해 기록하는 겸 쓰는 거라 틀린 개념이나 불필요한 코드가 포함되어 있을 수 있습니다 :)
혹시 유사한 방법을 찾으신다면 참고만 해주세요!!
문제 상황
우선 상황은 한 View에서 API 콜이 동시에 여러 번 일어나고 그 과정에서 토큰이 만료되는 경우 Alamofire의 Interceptor를 활용해 토큰을 갱신해주고 있는 상황입니다. 그리고 서버에서 토큰이 갱신되는 경우, Access뿐만 아닌 Refresh가 함께 갱신되면서 생길 수 있는 문제를 단계별로 나타내면 아래와 같았습니다.
- 한 View에서 API 콜이 A, B가 거의 동시에 발생
- 두 콜 모두 사용하던 AceessToken이 만료되면 서버에서 토큰을 갱신해달라는 상태코드 419 반환
- 이때 A에서 먼저 RequestInterceptor의 retry를 통해 새로운 AccessToken과 Refresh Token을 갱신
- 콜 B는 여전히 이전 토큰을 가지고 있기 때문에 또다시 토큰 갱신을 시도
- 하지만 이미 서버가 가지고 있는 RefreshToken과 값이 일치하지 않아 RefreshToken이 만료됐다는 응답이 반환될 수 있음
- 결과적으로 이전에 토큰 갱신이 성공적으로 이뤄졌음에도 B에서 토큰 갱신이 실패하면서 재로그인을 요구할 수 있음
- 하지만 이미 서버가 가지고 있는 RefreshToken과 값이 일치하지 않아 RefreshToken이 만료됐다는 응답이 반환될 수 있음
이렇게 위처럼 토큰이 만료된 시점에 연달아 호출이 발생, Alamofire-RequestInterceptor 내 Retry에서 아무 고려 없이 토큰을 갱신해 주는 식으로 구현해 주게 된다면 토큰이 갱신되는 순간마다 불필요한 재로그인이 일어나는 문제가 발생할 수 있는 상황이었습니다.
그래서 제가 구현하고자 한 목표는 토큰 갱신을 한 번만 하게 해주고 이후 콜의 토큰 갱신 작업들은 취소 + 첫 번째로 갱신된 토큰을 사용하게 해 주자! 였습니다.
해결
해결을 위해 사용해보고자 한 방식에는 NSLock과 큐를 통한 직접적인 스레드 제어방식과 최근 등장한 Actor를 활용해 보는 방식이었습니다.
저는 둘 중에 좀 더 흥미가 가던 Actor를 활용했습니다 :)
여기서 Actor의 개념을 간단하게 짚고 넘어가자면 '한 번에 하나의 작업만 수행할 수 있게 해주는 공간'이라고 생각하시면 됩니다!
만약 다른 추가 작업이 Actor로 들어온다 하더라도 이전 작업이 끝나기까지 기다린 후에 해당 작업을 진행할 수 있는 특성을 가지고 있어요.
이는 곧 작업이 직렬화 돼서 진행된다는 의미이고 이를 위해 Actor는 내부적으로 큐(Serial Queue)에 작업이 추가하고 순서대로 진행시킨다고 합니다. 그래서 NSLock을 사용해주지 않아도 되는 것이죠!
그래서 사실 NSLock + 큐 vs Actor는 작업 스레드를 개발자가 직접 제어한다 vs 언어 차원에서 안정성 있게 관리 가능의 차이였어요🤣
그렇게 해서 Actor로 구현한 토큰 갱신 로직은 다음과 같습니다!
refreshTokenTask가 토큰 갱신 작업을 의미하고 토큰을 갱신하기 이전에 이미 수행 중인 작업이 있는 경우 해당 작업이 끝날 때까지 기다리게 한 후, 그 작업의 결과를 사용하도록 처리해 줬어요.
그리고 저는 토큰을 KeyChain에서 관리해주고 있어서 토큰 갱신이 성공한 경우 KeyChain에 Access, Refresh Token을 저장해 주도록 했습니다. 이때, 데이터가 저장되는 단순 작업의 경우 꼭 메인 스레드가 아니어도 되지만 저 같은 경우에는 백그라운드 스레드에서 KeyChain에 접근하면서 데이터가 생각지 못한 시점에 변경되는 등의 발생 가능한 문제를 고려해 메인 스레드 내에서만 가능하도록 구현해 주었습니다.
또한 진행하던 프로젝트에서 네트워크에 전체적으로 Swift Concurrency 환경을 도입하면서 토큰을 갱신하는 로직 또한 async-await으로 처리할 수 있도록 구현해 주었고 만약 해당 과정 중 문제가 발생할 경우 Error가 반환되도록 했습니다.
actor TokenStore {
// 토큰 갱신 작업
private var refreshTokenTask: Task<TokenResponse, Error>?
func refreshToken() async throws -> TokenResponse {
if let task = refreshTokenTask {
// 이전 요청이 있다면 기존 task를 기다려서 반환하기
return try await task.value
}
// 들어온 작업이 없다면 새로운 refresh token task 생성
let task = Task { () -> TokenResponse in
// task 끝나면 nil로 초기화
defer { refreshTokenTask = nil }
// 토큰 갱신
let result = try await NetworkService.shared.refreshToken()
// refreshToken결과 KeyChain에 저장
await MainActor.run {
KeychainManager.shared.save(key: .accessToken, token: result.accessToken)
KeychainManager.shared.save(key: .refreshToken, token: result.refreshToken)
}
// 갱신된 결과 반환
return result
}
// 현재 진행 중인 task 저장 및 반환
// - 이후에 요청이 또 들어오면 이 작업을 기다림
refreshTokenTask = task
return try await task.value // 성공 시 TokenResponse, 실패 시 throw Error
}
}
이를 사용하기 위해서는 Retry 내에서도 Task로 묶어 처리해 줍니다!
만약 토큰 갱신 중 에러가 발생할 경우는 인증 불가능한 Refresh Token이거나 Refresh Token이 만료된 경우로 총 두 가지이기 때문에 이때에는 사용자에게 재로그인을 하도록 유도해 토큰을 아예 새로이 받아오도록 처리하였습니다 :)
func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) {
guard let response = request.task?.response as? HTTPURLResponse else {
completion(.doNotRetryWithError(error))
return
}
guard response.statusCode == 419 else {
completion(.doNotRetry)
return
}
print("⭐️ 토큰 리프레시!!")
Task {
do {
// 이미 refresh 중이면 기다리기, 아니면 갱신
_ = try await tokenStore.refreshToken()
completion(.retry) // 갱신 후 재시도
} catch {
// 재로그인 필요
DispatchQueue.main.async {
ReloginState.shared.showLoginAlert = true
}
completion(.doNotRetryWithError(error))
}
}
}
후기
다양한 코드를 Actor 내에 작성해 본 것은 아니지만 Actor 자체를 처음 사용해 봐서 조금 의미 있는 경험이지 않았을까 싶네요!
부족한 부분이 많은 만큼 참고용으로만 봐주세요 :D
더 좋은 의견이 있으시다면 조용히 알려주셔도 감사하겠습니다!!
'iOS' 카테고리의 다른 글
| ✏️ 로컬 푸시 알림 보내기(Local Notification) (0) | 2024.06.19 |
|---|---|
| ❓ AppDelegate와 SceneDelegate(2) (0) | 2024.06.14 |
| ❓ AppDelegate와 SceneDelegate(1) (0) | 2024.06.12 |
| ✏️ 아래로 당겨서 새로고침 (0) | 2024.06.03 |
| ✏️ Link Presentation으로 메타데이터 가져오기 (0) | 2024.05.29 |