1. 문제 상황
알림 기능 구현 이후 앱 실행 시 어째선지 자꾸 크래시가 난다.

당시 로그엔 Keychain 데이터 저장 시 문제가 생겼다는 말과 함께 명확히 어떤 문제인지 파악이 되지 않았는데,
AI를 조금 써가면서 원인 분석을 해보았더니, 초기 로그에 데이터 불러올 때 로그를 출력하도록 한 부분이 단서가 되었다.
그리고 해당 크래시는 15분마다 일어났다. 이 15분은 서비스에서 정의했던 액세스 토큰 유효 시간이다.
우선 기존 구조에선 초기에 액세스 토큰 만료 시 토큰을 재발급하는 처리 하나밖에 없었어서 UserDefaultsManager, KeychainManager를 통한 유저 관련 정보 저장이 이루어졌다.
import Foundation
import Alamofire
final class AuthInterceptor: RequestInterceptor {
private let tokenUseCase: TokenUseCaseProtocol // TODO: - thread safety 추가 후 sendable 채택 필요
init(tokenUseCase: TokenUseCaseProtocol) {
self.tokenUseCase = tokenUseCase
}
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
var urlRequest = urlRequest
if let accessToken = tokenUseCase.fetchAccessToken() {
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
completion(.success(urlRequest))
}
func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) {
guard let response = request.task?.response as? HTTPURLResponse,
response.statusCode == 401 else {
completion(.doNotRetryWithError(error)) // TODO: - 로그인 Coordinator로 전환
return
}
// TODO: - 재발급 요청
Task {
do {
try await tokenUseCase.renewAccessToken() // 액세스 토큰 재발급
completion(.retry)
} catch {
completion(.doNotRetryWithError(error))
await MainActor.run {
NotificationCenter.default.post(
name: .unauthorizedAccessDetected,
object: nil
)
}
}
}
}
}
기존 AuthInerceptor는 그저 bearer에 accessToken을 넣고 성공 시 해당 urlRequest를 통해 api 요청을 하도록 되어있고,
retry를 통해 인증 관련 오류에 대한 대응을 했다.
2. 문제 해결
알림 관련 기능이 추가되고 나서, 앱 시작부터 단 하나의 요청을 하는게 아니라 알림 관련 api 요청을 동시에 하게 되었는데
두 요청이 한번에 몰리게 되면서 같은 AuthInterceptor를 쓰게 된 것.
이 과정 속에서 AuthInterceptor를 통한 요청을 하게 되고 이에서 Keychain을 통한 데이터 저장에도 사이드 이펙트가 생겼던 것이다.
KeychainManager를 보면서 이해해보자.
/// 토큰을 키체인에 저장합니다.
func save(key: String, token: String) {
guard let data = token.data(using: .utf8) else {
assertionFailure("Token to Data 변환 실패")
return
}
let query: NSDictionary = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecValueData: data
]
SecItemDelete(query)
let status = SecItemAdd(query, nil)
assert(status == noErr, "failed to save Token")
}
구현했던 KeychainManager의 save 로직이다.
관련 설정을 해주고 SecItemDelete를 통해 기존에 정보가 저장되어 있었다면 삭제해주고 그 자리에 새 값을 넣어주도록 한다.
그럼 이 로직에서 동시에 세 요청을 처리하게 되었다면 어떤 그림이 있었을까?
A-B 순서로 api 요청을 하게 되었다고 가정하면
A 요청 시 키체인에 기존 정보를 지우고 새 정보를 저장.
하지만 동시에 B 요청을 하게 되어서 A 요청이 저장되기도 전에 동시에 삭제 요청(이 경우 이미 삭제되어있으므로 무시)하고 A 요청 내 저장 처리를 하자마자 B 요청 내 저장이 이루어졌다면?
errSecDuplicateItem (-25299) 중복된 아이템을 넣었다고 크래시가 난다 !
결국 이미 저장되어있던 정보에는 삭제 후 저장을 해야하는데 삭제 후 저장할 틈이 없었던 것.

이를 해결한 코드는 다음과 같다.
import Foundation
import Alamofire
final class AuthInterceptor: RequestInterceptor {
// MARK: - Properties
private let tokenUseCase: TokenUseCaseProtocol // TODO: - thread safety 추가 후 sendable 채택 필요
private let lock = NSLock() // 동시성 제어용 락
private var isRefreshing = false // 현재 재발급 중인지
private typealias RetryHandler = (RetryResult) -> Void
private var retryHandlers: [RetryHandler] = [] // 재발급 완료 후 처리할 completion 모음
enum AuthInterceptorError: Error {
case tokenRefreshFailed
}
// MARK: - Initializer
init(tokenUseCase: TokenUseCaseProtocol) {
self.tokenUseCase = tokenUseCase
}
// MARK: - Adapt
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
var urlRequest = urlRequest
if let accessToken = tokenUseCase.fetchAccessToken() {
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
completion(.success(urlRequest))
}
// MARK: - Retry
func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) {
guard let response = request.task?.response as? HTTPURLResponse,
response.statusCode == 401 else {
completion(.doNotRetryWithError(error)) // TODO: - 로그인 Coordinator로 전환
return
}
lock.lock() // 현재 스레드가 점유
retryHandlers.append(completion) // 재발급 이후 동작 정의
if isRefreshing { // 발급 중일 경우 점유 해제 및 처리 종료
lock.unlock()
return
}
// 재발급 중인 스레드가 없을 경우, 해당 스레드가 리더가 되어 재발급 시작
isRefreshing = true
lock.unlock()
refreshTokens()
// TODO: - 재발급 요청
}
private func refreshTokens() {
Task {
let success: Bool
do {
try await tokenUseCase.renewAccessToken() // 액세스 토큰 재발급
success = true
} catch {
success = false
await MainActor.run {
NotificationCenter.default.post(
name: .unauthorizedAccessDetected,
object: nil
)
}
}
finishRefreshing(success: success)
}
}
private func finishRefreshing(success: Bool) {
// 락 설정 후 큐 상태 복사 후 초기화
lock.lock()
let handlers = retryHandlers
retryHandlers.removeAll()
isRefreshing = false
lock.unlock()
// 락을 풀고 나서 completion 호출
if success { // 재발급 성공, 다시 요청 시도
handlers.forEach { $0(.retry) }
} else { // 재발급 실패, 모두 실패 처리
let error = AuthInterceptorError.tokenRefreshFailed
handlers.forEach { $0(.doNotRetryWithError(error)) }
}
}
}
우선 문제의 핵심은 한 순간에 여러 요청을 해버리니 이에 대한 내부적인 처리가 필요했다.
관련 경험이 없어서 찾아봤을 땐 NSLock을 사용하는 방향으로 잡혔고, 이는 스레드간의 상호 배제를 돕는 락이라고 한다.
작동 원리는 lock.lock()을 처음 실행하는 스레드가 lock.unlock()을 하기 전까지의 코드를 독점하게 된다.
그리고 또 중요한게 retryHandlers. 요청에 따른 처리들을 저장해두는 배열이다.
문제의 상황과 유사하게 A, B 요청이 있다고 가정하면 A 요청이 처음 처리하면서 lock을 걸고 retryHandlers에 본인의 처리RetryHandler 대기열을 걸어둔다.
그리고 기존 isRefreshing 프로퍼티를 true로 만들어 재발급을 시작하겠다고 플래그를 걸어둔다.
이후 자원 독점을 unlock을 통해 풀어둔다.
아마 unlock()을 했으니 이후 요청인 B가 똑같이 lock()을 통해 자원을 독점하겠다 선언하고 본인의 retryHandler 또한 대기열에 걸어둔다. 하지만 A 요청 때 isRefreshing을 걸어두었으니 이후로는 더 갈 수 없고 unlock()을 통해 자원 독점을 해제하고 물러난다.
A의 unlock() 혹은 해당 시점 이후 api 요청을 통해 토큰 재발급을 시도하고 그에 대한 결과에 따른 finishRefreshing 함수 내 분기 처리를 한다.
finishRefreshing에서는 마찬가지로 AuthInterceptor 내 프로퍼티를 안전하게 초기화할 수 있도록 lock() 처리, 그리고 함수 내 지역 변수에 대기열에 있는 retryHandler들을 그대로 가져오고 AuthInterceptor의 retryHandlers는 초기화한다. isRefreshing 또한 토큰 재발급은 끝났으니 다시 false로 설정한다.
만약 그 전 단계인 토큰 재발급 당시 성공을 했다면 completion에 대한 .retry 처리를 하도록 순서대로 지시하고, 실패했다면 에러와 함께 시도하지 않도록 지시한다.
아마 isRefreshing 이후 관련 retryHandlers를 일괄 처리하기 전에 lock을 걸어두었으니 이후에 들어오는 요청들을 온전히 지역변수로 handlers를 옮긴 뒤에 처리할 것이다.

이렇게 하니 요청 성공!
data race, race condition, 동시성 제어 등 키워드는 많이 들어봤는데 직접 겪어본 적은 이번이 처음이다.
앞으로는 더 알 수 없는 상황이 많을텐데 lock 외에도 다양한 기법이 있는 것으로 아는데 스레드 관련 공부도 좀 더 해야 이해가 쉬울 것 같다:)