💻

API 동시 호출 시에도 안전하게 토큰 재발급하기

joho2022 2025. 5. 7. 01:54

 

 

 

access 토큰 재발급 로직을 단순 싱글톤 클래스로 구현했었는데,

토큰이 만료된 상태에서 뷰 하나에 여러 API가 동시에 호출되면

요청이 동시다발적으로 발생할 수 있겠구나 하고 생각이 들었다.

 

이로 인해 race condition이 발생할 수 있음을 인지했고,

이를 방지하기 위해 안전한 동시성 처리가 필요하다고 느꼈다.

 

결론적으로, actor를 활용해 한 번에 하나의 작업만 처리하도록 구성하고,

내부에는 Task를 캐싱해 요청 결과를 재사용할 수 있도록 했다.

 


 

실제 서버로 직접 확인하는 데에는 제약이 있기 때문에,

테스트 코드를 작성하여 아래 두 가지 목적을 중심으로 동작을 검증하고자 했다.

 

  1. actor를 통해 동시성 보호가 실제로 잘 이루어지는지 확인
  2. Task 캐싱 유무에 따라 실제로 중복 요청이 어떻게 발생하는지 비교

 

Actor로 동시성 보호가 이뤄지는가?

Actor란 무엇인가?

actor는 내부 상태를 직렬화된 컨텍스트에서만 접근하도록 보장한다.

직렬화는 결국 비동기 환경에서 여러 Task들이 동시에 같은 자원에 접근하는 race Condition과 같은 상황을 방지하고,

한 번에 하나씩만 실행하여 일관된 상태를 보장해주는 의미이다.

 

 

 

Actor | Apple Developer Documentation

Common protocol to which all actors conform.

developer.apple.com

 

 

정말 actor가 데이터 무결성을 보장해주는지

100개의 병렬작업을 시키고, 내부에서 카운팅을 하는 간단한 테스트로 확인을 해봤다.

 

액터가 없는 경우

final class PlainCounter {
    static let shared = PlainCounter()
    private var refreshTask: Task<String, Never>?
    private(set) var accessCount = 0

    @discardableResult
    func refresh() async -> String {
        accessCount += 1
        
        if let t = refreshTask {
            return await t.value
        }
        
        let t = Task {
            try? await Task.sleep(for: .seconds(2));
            return "noActor_\(UUID())"
        }
        
        refreshTask = t
        
        let token = await t.value
        refreshTask = nil
        
        return token
    }

    func incrementCount() {
        accessCount += 1
    }
    
    func resetCount() {
        accessCount = 0
        refreshTask = nil
    }
}

 

 

액터가 있는 경우

actor ActorCounter {
    static let shared = ActorCounter()
    private var refreshTask: Task<String, Never>?
    private(set) var accessCount = 0

    @discardableResult
    func refresh() async -> String {
        if let t = refreshTask {
            return await t.value
        }
        
        let t = Task {
            try? await Task.sleep(for: .seconds(2));
            return "actor_\(UUID())"
        }
        
        refreshTask = t
        
        let token = await t.value
        refreshTask = nil
        
        return token
    }

    func incrementCount() {
        accessCount += 1
    }
    
    func resetCount() {
        accessCount = 0
        refreshTask = nil
    }
}

액터 유무에 따른 구현체를 만들었고,

 

 

for _ in 0..<100 {
    group.addTask {
        // PlainCounter 또는 ActorCounter 호출
        await Counter.shared.refresh()
        await Counter.shared.incrementCount()
    }
}

withTaskGroup을 사용하여 병렬작업을 의도하였다.

 

 

 

Actor를 사용하지 않은 버전에서는 Race Condition이 발생해, 데이터 결과가 매번 일관되지 않게 나타나는 것을 확인할 수 있다.

 

반면 Actor 내부 상태 변화는 자동으로 직렬화되어 한 번에 하나씩 처리되기 때문에,

동일한 병렬 작업에서도 항상 일관된 결과를 얻을 수 있는 것으로 확인이 가능하다.

 

그래서 Actor를 도입하면 재발급 로직의 상태 변경이 순차적으로 안전하게 처리되기 때문에,

동시다발적인 요청에도 데이터의 무결성을 지킬 수 있기 때문에 사용하였다.

 


 

Task를 캐싱하면 어떠한 장점이 있는가?

final actor CounterWithTaskCache {
    static let shared = CounterWithTaskCache()
    private var cachedTask: Task<String, Never>?
    private(set) var accessCount = 0

    @discardableResult
    func refresh() async -> String {
        if let task = cachedTask {
            return await task.value
        }
        accessCount += 1
        let task = Task {
            try? await Task.sleep(for: .seconds(1))
            return "taskCache_\(UUID().uuidString)"
        }
        cachedTask = task
        let token = await task.value
        return token
    }

    func resetCount() {
        accessCount = 0
        cachedTask = nil
    }
}

동일한 구현체 조건인 actor로 만들었고 Task 캐싱유무 차이로 테스트를 진행했다

그리고 똑같이 group에 작업을 추가하여 병렬작업을 의도하였다.

 

우선 actor로 했기 때문에, 데이터는 일관되게 나타난다.

 

결과를 통해 알 수 있는 사실은

Task를 캐싱한 구현체는 병렬작업이여도 오직 한 번만 실제 작업이 실행되고,

나머지는 같은 Task의 결과를 재사용하는 것으로 이해할 수 있다.

 

반면에, 캐싱을 안하면 병렬 호출마다 매번 재발급 작업이 실행되고,

결국 불필요한 네트워크 호출이 발생을 초래할 수 있다는 점으로 예상이 가능하다.

 

결국 토큰 재발급 로직에서 401 에러 발생했을 때,

Task를 캐싱하는 것이

동시다발적으로 요청이 들어와도 안정적인 토큰 관리를 하는데 도움을 준다.

 

논캐싱 2번 캐싱 2번

 

그리고 실제로 수치로 확인해보면

메모리에 남아 있는 Task 수를 비교했을 때,

 

캐시를 통해 불필요한 Task 생성이 절반으로 줄여졌음을 확인할 수 있다.

 


 

정리하면 재발급 로직을 안정적으로 관리하기 위해,

 

actor를 사용하여 한 번에 하나의 작업을 처리하도록 명시하여, 데이터의 무결성을 보장한다.

 

Task 캐싱을 사용하여 한 번만 재발급 작업을 실행하고 이후 호출은 최초의 결과를 재사용하여 불필요한 네트워크 호출을 방지한다.