iOS에서 URLSession과 async/await를 활용한 현대 네트워킹 – 파트 2

발행: (2025년 12월 25일 오후 10:29 GMT+9)
11 분 소요
원문: Dev.to

Source: Dev.to

번역을 진행하려면 번역하고자 하는 전체 텍스트를 제공해 주시겠어요?
코드 블록, URL 및 마크다운 형식은 그대로 유지하면서 본문만 한국어로 번역해 드리겠습니다. 텍스트를 복사해서 보내주시면 바로 도와드릴게요.

Part 2 – 보안 토큰 처리

Part 1에서 Swift의 최신 동시성 및 URLSession을 사용해 깔끔한 네트워킹 레이어를 구축했습니다. 예시 엔드포인트는 공개되어 있었고 인증이 필요하지 않았습니다. 그러나 실제 앱에서는 보통 사용자를 인증하고 모든 요청에 짧은 수명의 액세스 토큰을 첨부해야 합니다. 이러한 토큰은 결국 만료되며, 사용자를 다시 로그인시키지 않고 새로운 액세스 토큰을 얻을 방법이 필요합니다.

이번 파트에서는 Part 1의 네트워킹 클라이언트를 기반으로 안전하고 견고한 토큰 처리 메커니즘을 구현하는 방법을 단계별로 살펴보겠습니다.

액세스 및 리프레시 토큰 이해

액세스 토큰은 보호된 API에 대한 접근을 허용합니다. 이 토큰은 일반적으로 수분에서 수시간 정도의 짧은 수명을 가지며, 토큰이 유출될 경우 공격자가 이용할 수 있는 시간 창을 제한합니다.

리프레시 토큰은 클라이언트가 새로운 액세스 토큰을 요청할 수 있게 해 주는 인증 정보입니다. 새로운 액세스 토큰을 발급하는 데 사용될 수 있기 때문에, 사용자 비밀번호와 마찬가지로 보호되어야 합니다.

토큰 모델

import Foundation

/// A container for access/refresh tokens and their expiration date.
/// Conforms to `Codable` so it can be encoded to and decoded from JSON.
struct TokenBundle: Codable {
    let accessToken: String
    let refreshToken: String
    let expiresAt: Date

    /// Returns `true` if the access token is expired.
    var isExpired: Bool {
        return expiresAt  // original logic placeholder
    }

    /// Loads a token bundle from the Keychain.
    static func load() throws -> TokenBundle? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var item: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        if status == errSecItemNotFound {
            return nil
        }
        guard status == errSecSuccess, let data = item as? Data else {
            throw KeychainError.unhandled(status: status)
        }
        return try JSONDecoder().decode(TokenBundle.self, from: data)
    }

    /// Saves a token bundle to the Keychain.
    static func save(_ bundle: TokenBundle) throws {
        let data = try JSONEncoder().encode(bundle)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String: data
        ]
        let status = SecItemAdd(query as CFDictionary, nil)
        if status != errSecSuccess && status != errSecDuplicateItem {
            throw KeychainError.unhandled(status: status)
        }
    }

    /// Removes the token bundle from the Keychain.
    static func delete() throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unhandled(status: status)
        }
    }
}

이 헬퍼는 TokenBundleData로 인코딩하여 키체인에 단일 키로 저장하고, 필요할 때 다시 디코딩합니다. 또한 사용자가 로그아웃할 때 저장된 토큰을 삭제할 수 있도록 delete() 메서드를 포함하고 있습니다.

동시성‑안전 토큰 관리

여러 네트워크 요청이 동시에 유효한 토큰을 필요로 할 때, 토큰을 한 번에 여러 번 갱신하지 않도록 해야 합니다. Swift actor를 사용하면 동시에 하나의 갱신 호출만 실행되도록 보장할 수 있습니다.

import Foundation

/// Manages access and refresh tokens.
/// Uses an actor to serialize token access and refresh operations safely.
actor AuthManager {
    /// The currently running refresh task, if any.
    private var refreshTask: Task<TokenBundle, Error>?
    /// Cached token bundle loaded from the Keychain.
    private var currentTokens: TokenBundle?

    init() {
        // Load any persisted tokens at initialization.
        currentTokens = try? KeychainService.load()
    }

    /// Returns a valid token bundle, refreshing if necessary.
    /// Throws if no tokens are available or if refresh fails.
    func validTokenBundle() async throws -> TokenBundle {
        // If a refresh is already in progress, await its result.
        if let task = refreshTask {
            return try await task.value
        }

        // If we have a cached token that isn’t expired, return it.
        if let tokens = currentTokens, !tokens.isExpired {
            return tokens
        }

        // No valid token – start a refresh.
        let task = Task { try await refreshTokens() }
        refreshTask = task
        defer { refreshTask = nil }

        let newTokens = try await task.value
        currentTokens = newTokens
        try KeychainService.save(newTokens)
        return newTokens
    }

    /// Performs the actual network call to exchange the refresh token for a new access token.
    private func refreshTokens() async throws -> TokenBundle {
        guard let refreshToken = currentTokens?.refreshToken else {
            throw AuthError.missingRefreshToken
        }

        // Build the request (replace URL & parameters with your own API).
        var request = URLRequest(url: URL(string: "https://api.example.com/auth/refresh")!)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        let body = ["refresh_token": refreshToken]
        request.httpBody = try JSONEncoder().encode(body)

        // Use the networking client from Part 1.
        let (data, response) = try await URLSession.shared.data(for: request)

        // Basic response validation.
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw AuthError.refreshFailed
        }

        // Decode the new token bundle.
        let newBundle = try JSONDecoder().decode(TokenBundle.self, from: data)
        return newBundle
    }

    /// Clears stored tokens (e.g., on logout).
    func clearTokens() throws {
        currentTokens = nil
        try KeychainService.delete()
    }
}

// MARK: - Supporting Types

enum AuthError: Error {
    case missingRefreshToken
    case refreshFailed
}
  • AuthManager actor는 한 번에 하나의 갱신 작업만 실행되도록 보장합니다.
  • validTokenBundle()은 먼저 진행 중인 갱신 작업이 있는지 확인하고, 그 다음 캐시된 비만료 토큰을 확인하며, 필요하면 갱신을 트리거합니다.
  • 갱신된 토큰은 키체인에 영구 저장되고 메모리에도 캐시되어 이후 호출에서 재사용됩니다.

이제 AuthManager를 네트워킹 레이어에 주입(예: 의존성 주입)하면 모든 요청이 자동으로 최신 유효 토큰을 받아 사용할 수 있습니다.

AuthManager 액터 (대체 구현)

/// An actor that lazily loads tokens from the keychain at initialization.
actor AuthManager {
    private var currentTokens: TokenBundle?
    private var refreshTask: Task<TokenBundle, Error>?

    init() {
        // Load tokens from the keychain if they exist.
        currentTokens = try? KeychainService.load()
    }

    /// Returns a valid token bundle, refreshing it if necessary.
    func validTokenBundle() async throws -> TokenBundle {
        // No stored tokens means the user must log in.
        guard let tokens = currentTokens else {
            throw AuthError.noCredentials
        }

        // If not expired, return immediately.
        if !tokens.isExpired {
            return tokens
        }

        // Otherwise refresh.
        return try await refreshTokens()
    }

    /// Forces a refresh of the tokens regardless of expiration status.
    func refreshTokens() async throws -> TokenBundle {
        // If a refresh is already happening, await it.
        if let task = refreshTask {
            return try await task.value
        }

        // Ensure we have a refresh token.
        guard let tokens = currentTokens else {
            throw AuthError.noCredentials
        }

        // Create a new task to perform the refresh.
        let task = Task { () throws -> TokenBundle in
            defer { refreshTask = nil }

            // Build a request to your auth server’s token endpoint.
            // Replace `api.example.com` and path with your actual auth server and endpoint.
            var components = URLComponents()
            components.scheme = "https"
            components.host = "api.example.com"   // change to your auth server
            components.path = "/oauth/token"

            var request = URLRequest(url: components.url!)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")

            let body: [String: String] = ["refresh_token": tokens.refreshToken]
            request.httpBody = try JSONEncoder().encode(body)

            // Perform the network call.
            let (data, response) = try await URLSession.shared.data(for: request)

            guard let httpResponse = response as? HTTPURLResponse,
                  (200...299).contains(httpResponse.statusCode) else {
                throw AuthError.refreshFailed
            }

            // Decode the new token bundle.
            return try JSONDecoder().decode(TokenBundle.self, from: data)
        }

        refreshTask = task
        return try await task.value
    }
}

지원 타입

enum AuthError: Error {
    case noCredentials
    case refreshFailed
}

네트워크 클라이언트와 통합

/// A generic network client that can automatically attach authentication headers.
struct NetworkClient {
    let authManager: AuthManager

    /// Sends a request, automatically adding the bearer token when required.
    func send<T: Request>(_ request: T,
                          allowRetry: Bool = true) async throws -> T.Response {
        var urlRequest = try request.makeURLRequest()

        // Add the Authorization header when needed.
        if request.endpoint.requiresAuthentication {
            let tokens = try await authManager.validTokenBundle()
            urlRequest.setValue("Bearer \(tokens.accessToken)",
                                 forHTTPHeaderField: "Authorization")
        }

        // Perform the request.
        let (data, response) = try await URLSession.shared.data(for: urlRequest)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.invalidResponse
        }

        // Handle 401 Unauthorized.
        if httpResponse.statusCode == 401 {
            guard allowRetry else {
                throw NetworkError.unauthorized
            }

            do {
                // Attempt to refresh the token and retry once.
                _ = try await authManager.refreshTokens()
                return try await send(request, allowRetry: false)
            } catch {
                // Refresh failed → propagate the error.
                throw error
            }
        }

        // Decode the successful response.
        return try JSONDecoder().decode(T.Response.self, from: data)
    }
}

지원 타입

enum NetworkError: Error {
    case invalidResponse
    case unauthorized
    // … other cases …
}

요약

  1. **AuthManager**는 토큰 저장, 검증 및 갱신을 처리하며, 동시에 하나의 갱신 요청만 실행되도록 보장합니다.
  2. **Endpoint.requiresAuthentication**은 클라이언트에게 해당 요청에 인증 헤더가 필요한지 여부를 알려줍니다.
  3. **NetworkClient.send**는 필요할 때 베어러 토큰을 삽입하고, 401 응답이 발생하면 토큰을 갱신하여 한 번 재시도하며, 그 외에는 오류를 그대로 전달합니다.

전체 프로젝트는 GitHub에서 확인할 수 있습니다:
https://github.com/Pro-Mobile-Dev/ios-modern-network-layer

Back to Blog

관련 글

더 보기 »

SwiftUI 제스처 시스템 내부

!Sebastien Latohttps://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%...

SwiftUI View Diffing 및 Reconciliation

SwiftUI는 화면을 “다시 그리”는 것이 아닙니다. 뷰 트리를 차이(diff)합니다. SwiftUI가 무엇이 변경되고 무엇이 동일하게 유지되는지를 어떻게 결정하는지 이해하지 못한다면, 불필요한 …

Swift Combine의 Hot 및 Cold Publishers

핫 퍼블리셔와 콜드 퍼블리셔란 무엇인가? 콜드 퍼블리셔 콜드 퍼블리셔는 구독자마다 새로운 실행을 생성합니다. 구독할 때 작업이 새롭게 시작됩니다. swift...