목록, 아바타, 메시지 수를 가져오는 API 요청이 동시에 실행될 때 기존 토큰이 무효화되면 세 요청 모두 인증 실패 응답을 받습니다. 각 요청이 따로 토큰을 갱신하면 서버가 갱신용 인증 정보를 교체하는 과정에서 서로 덮어쓸 수 있습니다. 그사이에 사용자가 로그아웃하면 뒤늦게 도착한 응답이 로그인 상태를 되살릴 수도 있습니다. 클라우드 Mac은 이런 경쟁 조건을 반복 가능한 테스트로 만드는 데 적합합니다. 운영 API에 연결하거나 우연히 네트워크가 느려지기를 기다리지 않고, 인증 조정기의 동작만 검증합니다.
먼저 세 가지 검증 조건 정의하기
첫째, 같은 세션에서 동시에 발생한 토큰 갱신은 한 번만 실행해야 합니다. 둘째, 로그아웃한 뒤에는 이전 갱신 결과가 현재 인증 정보를 변경할 수 없어야 합니다. 셋째, 하나의 업무 요청은 토큰 갱신과 재시도를 최대 한 번만 수행해야 합니다. 인증 실패 시 갱신을 허용할지는 API 명세에 따라 판단해야 합니다. 모든 권한 오류를 토큰 만료로 취급해서는 안 됩니다.
조정기는 “해당 요청이 실제로 사용한 토큰”을 전달받습니다. 현재 토큰이 이미 다른 값이라면 다른 요청이 갱신을 마친 것이므로, 다시 갱신하지 않고 새 값을 그대로 사용합니다. 그렇지 않으면 뒤늦게 도착한 실패 응답이 불필요한 두 번째 갱신을 일으킵니다.
actor는 격리된 상태에 대한 접근만 보장합니다. 비동기 메서드 전체가 다른 실행과 엇갈리지 않는다는 보장은 없습니다. await에 도달하면 로그아웃 작업이나 다른 요청이 들어올 수 있습니다.
예제에서는 토큰을 문자열로 단순화하고, 갱신 통신 로직을 클로저로 주입합니다. 실제 프로젝트에서는 유효 기간, 갱신용 인증 정보의 교체, 안전한 영구 저장도 관리해야 합니다. 테스트 보고서에 인증 정보 전체를 기록하지 마세요.
공유 Task로 갱신 합치기
프로젝트의 테스트 타깃에 다음 조정기를 추가합니다. 이 조정기는 메모리 상태만 보관하며, 업무 요청을 보내는 역할은 하지 않습니다. 갱신 클로저는 서버에서 명확한 실패 응답을 받거나 타임아웃이 발생하면 오류를 던져야 합니다. 무한정 대기해서는 안 됩니다.
import Foundation
actor RefreshGate {
enum Failure: Error { case signedOut }
private var token: String?
private var generation = 0
private var flight: (id: UUID, task: Task<String, Error>)?
private let refresh: @Sendable () async throws -> String
init(token: String, refresh: @escaping @Sendable () async throws -> String) {
self.token = token
self.refresh = refresh
}
func recover(rejected: String) async throws -> String {
guard let current = token else { throw Failure.signedOut }
if current != rejected {
try Task.checkCancellation()
return current
}
let epoch = generation
let active: (id: UUID, task: Task<String, Error>)
if let existing = flight {
active = existing
} else {
let operation = refresh
active = (UUID(), Task { try await operation() })
flight = active
}
do {
let updated = try await active.task.value
guard generation == epoch else { throw Failure.signedOut }
if flight?.id == active.id {
token = updated
flight = nil
}
try Task.checkCancellation()
return updated
} catch {
if flight?.id == active.id {
flight = nil
}
throw error
}
}
func logout() {
generation += 1
token = nil
flight?.task.cancel()
flight = nil
}
}
Task를 먼저 등록한 다음 결과를 기다려야 다른 호출도 같은 Task를 재사용할 수 있습니다. 정리할 때는 작업 식별자를 비교하여, 이전 호출이 실행을 재개하면서 새 작업을 잘못 제거하지 않도록 합니다. 여기서 사용하는 비구조적 Task는 대기 중인 호출 하나가 취소되어도 자동으로 취소되지 않습니다. 이는 의도적으로 유지한 공유 동작이므로, 취소 핸들러를 추가해 공유 작업을 종료해서는 안 됩니다.
갱신이 성공하면 먼저 공유 상태를 업데이트한 뒤 호출자의 취소 여부를 확인합니다. 이렇게 하면 이미 닫힌 화면은 결과를 더 이상 사용하지 않고, 여전히 대기 중인 요청은 새 토큰을 받을 수 있습니다. 취소 여부는 대기가 끝난 뒤에만 확인하므로, 취소 즉시 반환하는 인터페이스는 아닙니다.
클라우드 Mac에서 로컬 동시성 테스트 실행하기
갱신 API 대신 카운터를 사용하고, 테스트 인증 정보는 고정된 가짜 값만 사용합니다. 다음 메서드를 XCTestCase 하위 클래스에 넣고, 조정기와 테스트를 같은 테스트 타깃에 포함하면 됩니다.
import XCTest
actor RefreshProbe {
private(set) var calls = 0
func run() async throws -> String {
calls += 1
try await Task.sleep(nanoseconds: 100_000_000)
return "fresh-token"
}
}
func testRefreshIsShared() async throws {
let probe = RefreshProbe()
let gate = RefreshGate(token: "old-token") {
try await probe.run()
}
let values = try await withThrowingTaskGroup(of: String.self) { group in
for _ in 0..<20 {
group.addTask {
try await gate.recover(rejected: "old-token")
}
}
var output: [String] = []
for try await value in group {
output.append(value)
}
return output
}
let calls = await probe.calls
XCTAssertEqual(calls, 1)
XCTAssertEqual(values.count, 20)
XCTAssertTrue(values.allSatisfy { $0 == "fresh-token" })
}
짧은 대기는 실행이 겹칠 가능성을 높이기 위한 것이지 성능 지표가 아닙니다. 이 테스트에는 “갱신이 이미 끝난 뒤에 이전 토큰의 실패를 전달하는” 사례도 추가하고, 호출 횟수가 여전히 한 번인지 검증해야 합니다. 이를 통해 대기 중인 호출만 합치고 뒤늦게 도착한 응답 처리는 빠뜨린 구현을 찾아낼 수 있습니다.
로그아웃과의 경쟁을 테스트할 때는 고정된 sleep 시간에 의존하지 마세요. 테스트에서 제어할 수 있는 continuation을 사용해 가짜 API가 “갱신이 시작되었다”고 테스트에 알리도록 합니다. 이어서 테스트가 logout을 호출한 다음 결과를 반환하도록 허용합니다. 가짜 API가 취소를 무시하고 성공을 반환하더라도, 이전부터 대기하던 호출은 반드시 오류를 던져야 합니다. 로그아웃 이후의 새 호출은 곧바로 실패해야 합니다.
실패, 취소, 재시도를 따로 처리하기
공유된 실패가 재시도 폭주로 이어지지 않게 하기
갱신 작업이 오류를 던지면 기존 대기 호출들은 모두 같은 실패를 전달받고, 조정기는 작업을 비웁니다. 이후 새 시도를 허용할지는 상위 계층의 정책으로 결정합니다. 일시적인 네트워크 실패에는 백오프를 적용할 수 있고, 인증 정보가 명확히 무효화된 경우에는 세션을 종료해야 합니다. 각 대기 호출이 catch 안에서 즉시 recover를 다시 호출하게 해서는 안 됩니다.
업무 요청을 재시도하기 전에는 요청 본문을 다시 전송할 수 있는지도 확인해야 합니다. 파일 스트림, 한 번만 소비할 수 있는 데이터 소스, 쓰기를 발생시키는 작업은 요청을 재구성하거나 서버의 멱등성 보장 방식과 연계해야 합니다. 토큰 갱신이 성공했다고 해서 모든 요청을 안전하게 다시 보낼 수 있는 것은 아닙니다.
로그아웃을 상태의 경계로 다루기
작업 취소는 협력적으로 이루어집니다. 전송 계층의 작업이 이미 끝났을 수도 있고, 취소에 즉시 반응하지 않을 수도 있습니다. 따라서 로그아웃할 때는 인증 정보를 비우는 동시에 세대 번호를 증가시켜야 합니다. 운영 환경의 영구 저장에도 같은 검사를 적용해야 합니다. 전송 클로저가 새 인증 정보를 직접 저장하게 해서는 안 됩니다. 그렇게 하면 조정기가 보장하는 로그아웃 격리를 우회하게 됩니다.
경쟁 조건을 상시 회귀 테스트 항목으로 만들기
커밋 전에는 최소한 다음 사례를 검증합니다.
- 이전 토큰을 사용한 여러 요청이 동시에 실패함: 갱신 횟수는 한 번이다.
- 새 토큰이 적용된 뒤 이전 실패 응답이 도착함: 다시 갱신하지 않는다.
- 갱신 실패: 모든 대기 호출이 실패하고 공유 작업이 정리된다.
- 대기 호출 하나가 취소됨: 다른 대기 호출은 여전히 성공한다.
- 로그아웃 후 갱신 결과가 반환됨: 인증 정보를 복원하지 않고 업무 요청도 재시도하지 않는다.
- 재시도에서도 인증 실패: 오류를 바로 반환하고 재귀적으로 갱신하지 않는다.
테스트 이름, 갱신 횟수, 최종 상태만 기록하면 충분하며, 토큰 내용은 기록하지 않습니다. 클라우드 Mac에서 이 테스트 모음을 반복 실행하는 목적은 경쟁이 절대로 발생하지 않음을 증명하는 것이 아닙니다. 경쟁이 발생하더라도 결과가 세션 경계를 지키는지 확인하는 것이 핵심입니다.
자주 묻는 질문
화면 요청을 취소하면 공유 갱신 작업도 취소해야 하나요?
아닙니다. 다른 요청이 같은 갱신을 기다릴 수 있습니다. 화면의 후속 처리만 취소하고, 로그아웃할 때 공유 작업을 취소하면서 세션 세대 번호를 증가시킵니다.
갱신 후 재시도에서도 인증 오류가 나면 다시 갱신하나요?
다시 갱신하지 않습니다. 업무 요청 하나당 갱신과 재시도는 각각 한 번까지만 허용합니다. 두 번째 오류는 호출자에게 전달하고 세션 무효 여부는 서버 규약에 따라 판단합니다.
개발 작업에 적합한 전용 클라우드 Mac 선택하기
Apple Silicon 기반 물리 노드로, 가상 머신이 아닙니다. 일·주·월·분기 단위로 대여할 수 있으며, 모델과 요금은 공개되어 있습니다. 실제 이용 가능 여부는 콘솔에서 실시간으로 확인할 수 있습니다.