JWT κΈ°λ°μ μλ ν ν° κ°±μ κΈ°λ₯μ κ°μΆ iOS λ€νΈμν¬ λ μ΄μ΄μ λλ€. μΈν°μ ν° ν¨ν΄μ νμ©νμ¬ 401 μλ¬ λ°μ μ μλμΌλ‘ ν ν°μ κ°±μ νκ³ μμ²μ μ¬μλν©λλ€.
- β JWT μΈμ¦: Access Tokenκ³Ό Refresh Token κΈ°λ° μΈμ¦
- β μλ ν ν° κ°±μ : 401 μλ¬ λ°μ μ μλμΌλ‘ ν ν° μ¬λ°κΈ λ° μμ² μ¬μλ
- β μΈν°μ ν° ν¨ν΄: μμ² μ νμ 컀μ€ν λ‘μ§ μ½μ κ°λ₯
- β ν ν° κ°±μ λκΈ°ν: Actor κΈ°λ° λμμ± μ μ΄λ‘ μ€λ³΅ κ°±μ λ°©μ§
- β κ²μ€νΈ μ΅λͺ λ‘κ·ΈμΈ: κ°νΈν κ²μ€νΈ μΈμ¦ μ§μ
- β Multipart/form-data μ λ‘λ: μ΄λ―Έμ§ μλ 리μ¬μ΄μ§ λ° μμΆ
- β Keychain μ μ₯μ: μμ ν ν ν° μ μ₯
- β Combine + async/await: λ΄λΆλ async/await, μΈλΆ APIλ Combine μ 곡
- β νλ‘ν μ½ κΈ°λ° μ€κ³: ν μ€νΈμ μμ‘΄μ± μ£Όμ μ©μ΄
βββββββββββββββββββββββββββββββββββββββββββ
β UI Layer (SwiftUI) β
β ContentView, GuestLoginViewModel β
ββββββββββββββββ¬βββββββββββββββββββββββββββ
β
ββββββββββββββββΌβββββββββββββββββββββββββββ
β Service Layer (Combine) β
β GuestAuthService, MultipartUpload β
ββββββββββββββββ¬βββββββββββββββββββββββββββ
β
ββββββββββββββββΌβββββββββββββββββββββββββββ
β Network Layer (async/await) β
β NetworkService + Interceptors β
ββββββββββββββββ¬βββββββββββββββββββββββββββ
β
ββββββββββββββββΌβββββββββββββββββββββββββββ
β Storage Layer (Keychain) β
β TokenManager, KeychainService β
βββββββββββββββββββββββββββββββββββββββββββ
λͺ¨λ λ€νΈμν¬ μμ²μ μΈν°μ ν°λ₯Ό κ±°μΉλ©°, μμ² μ νμ 컀μ€ν λ‘μ§μ μ€νν μ μμ΅λλ€.
protocol RequestInterceptor {
func adapt(_ request: URLRequest, for endpoint: any APIEndpoint) async throws -> URLRequest
func retry(_ request: URLRequest, for endpoint: any APIEndpoint, dueTo error: NetworkError) async throws -> URLRequest
}μ¬λ¬ μμ²μ΄ λμμ 401μ λ°μλ ν ν° κ°±μ μ ν λ²λ§ μ€νλ©λλ€.
actor TokenRefreshCoordinator {
private var refreshTask: Task<(String, String), Error>?
func refresh(using refreshToken: String, endpoint: AuthEndpoint) async throws -> (String, String) {
if let existingTask = refreshTask {
return try await existingTask.value
}
// μλ‘μ΄ κ°±μ μμ
μμ
}
}network-module/
βββ NetworkLayer/
β βββ Core/ # ν΅μ¬ λ€νΈμν¬ λ‘μ§
β β βββ APIEndpoint.swift # μλν¬μΈνΈ νλ‘ν μ½
β β βββ NetworkService.swift # λ€νΈμν¬ μλΉμ€ (λ©μΈ)
β β βββ NetworkError.swift # μλ¬ μ μ
β β βββ NetworkConfig.swift # μ€μ (Base URL λ±)
β β
β βββ Interceptors/ # μΈν°μ
ν°
β β βββ RequestInterceptor.swift # μΈν°μ
ν° νλ‘ν μ½
β β βββ AuthenticationInterceptor.swift # JWT μΈμ¦ μΈν°μ
ν°
β β
β βββ Auth/ # μΈμ¦ κ΄λ ¨
β β βββ TokenManager.swift # ν ν° κ΄λ¦¬
β β βββ TokenRefreshCoordinator.swift # ν ν° κ°±μ λκΈ°ν
β β βββ KeychainService.swift # Keychain μ μ₯μ
β β βββ AuthEndpoint.swift # μΈμ¦ API μλν¬μΈνΈ
β β βββ GuestAuthService.swift # κ²μ€νΈ μΈμ¦ μλΉμ€
β β
β βββ Models/ # λ°μ΄ν° λͺ¨λΈ
β β βββ User.swift # μ¬μ©μ λͺ¨λΈ
β β βββ GuestRegisterResponse.swift # κ²μ€νΈ λ±λ‘ μλ΅
β β βββ TokenResponse.swift # ν ν° μλ΅
β β βββ EmptyResponse.swift # λΉ μλ΅
β β
β βββ Examples/ # μ¬μ© μμ
β βββ UsageExample.swift # Combine κΈ°λ° μ¬μ© μμ
β
βββ MultipartUploadHelper.swift # μ΄λ―Έμ§ μ
λ‘λ ν¬νΌ
βββ GuestLoginViewModel.swift # κ²μ€νΈ λ‘κ·ΈμΈ ViewModel
βββ ContentView.swift # λ°λͺ¨ UI
λͺ¨λ API μμ²μ μ§μ μ μ λλ€. μΈν°μ ν°λ₯Ό μ μ©νκ³ λ΄λΆμ μΌλ‘ async/awaitμ μ¬μ©νλ©°, μΈλΆμλ Combine APIλ₯Ό μ 곡ν©λλ€.
μ£Όμ λ©μλ:
func request<T: Decodable>(_ endpoint: any APIEndpoint) -> AnyPublisher<T, NetworkError>νΉμ§:
- μ λ€λ¦μ ν΅ν νμ μμ μ±
- μλ JSON λμ½λ©
- μΈν°μ ν° μ²΄μΈ μ€ν
- 401 μλ¬ μ μλ μ¬μλ
JWT ν ν°μ μλμΌλ‘ μ£Όμ νκ³ , 401 μλ¬ λ°μ μ ν ν°μ κ°±μ ν©λλ€.
adapt λ¨κ³:
func adapt(_ request: URLRequest, for endpoint: any APIEndpoint) async throws -> URLRequest {
guard endpoint.requiresAuthentication else { return request }
var authenticatedRequest = request
authenticatedRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
return authenticatedRequest
}retry λ¨κ³:
func retry(_ request: URLRequest, for endpoint: any APIEndpoint, dueTo error: NetworkError) async throws -> URLRequest {
guard case .unauthorized = error else { throw error }
// ν ν° κ°±μ ν μ¬μλ
let (newAccessToken, newRefreshToken) = try await coordinator.refresh(...)
// μ ν ν°μΌλ‘ μμ² μ¬κ΅¬μ±
}ν ν°μ μ μ₯, μ‘°ν, μμ λ₯Ό λ΄λΉν©λλ€. Keychainμ ν΅ν΄ μμ νκ² ν ν°μ κ΄λ¦¬ν©λλ€.
μ£Όμ λ©μλ:
func saveTokens(accessToken: String, refreshToken: String) throws
func getAccessToken() throws -> String
func getRefreshToken() throws -> String
func clearTokens() throws
func isAuthenticated() -> BoolActorλ₯Ό μ¬μ©νμ¬ ν ν° κ°±μ μμ μ λκΈ°νν©λλ€. μ¬λ¬ μμ²μ΄ λμμ 401μ λ°μλ λ¨ ν λ²λ§ κ°±μ ν©λλ€.
λμ λ°©μ:
actor TokenRefreshCoordinator {
private var refreshTask: Task<(String, String), Error>?
func refresh(...) async throws -> (String, String) {
// μ§ν μ€μΈ κ°±μ μμ
μ΄ μμΌλ©΄ κ·Έ κ²°κ³Όλ₯Ό κΈ°λ€λ¦Ό
if let existingTask = refreshTask {
return try await existingTask.value
}
// μλ‘μ΄ κ°±μ μμ
μμ
let task = Task { ... }
refreshTask = task
defer { refreshTask = nil }
return try await task.value
}
}μ΄λ―Έμ§λ₯Ό ν¬ν¨ν multipart/form-data μ λ‘λλ₯Ό μ²λ¦¬ν©λλ€. μλ 리μ¬μ΄μ§ λ° μμΆ κΈ°λ₯μ μ 곡ν©λλ€.
μ£Όμ κΈ°λ₯:
- μ΄λ―Έμ§λ₯Ό μ΅λ 1024x1024λ‘ λ¦¬μ¬μ΄μ§
- μ μ§μ μμΆ (0.8 β 0.1 νμ§)
- 5MB ν¬κΈ° μ ν μ€μ
μ¬μ© μ:
let response = try await MultipartUploadHelper.uploadGuestRegister(
name: "μ¬μ©μ",
image: profileImage
)iOS Keychainμ ν΅ν΄ μμ νκ² λ°μ΄ν°λ₯Ό μ μ₯ν©λλ€.
μ£Όμ λ©μλ:
func save(key: String, data: Data) throws
func read(key: String) throws -> Data
func delete(key: String) throwsβββββββββββ
β μ¬μ©μ β μ΄λ¦ + μ΄λ―Έμ§ μ
λ ₯
ββββββ¬βββββ
β
βΌ
βββββββββββββββββββββββββββ
β GuestLoginViewModel β registerGuest()
ββββββ¬βββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββ
β MultipartUploadHelper β μ΄λ―Έμ§ 리μ¬μ΄μ§ + μμΆ
ββββββ¬βββββββββββββββββββββ
β
βΌ POST /auth/guest/register
βββββββββββββββββββββββββββ
β Backend API β
ββββββ¬βββββββββββββββββββββ
β
βΌ 200 OK { data: { user, accessToken, refreshToken } }
βββββββββββββββββββββββββββ
β TokenManager β saveTokens()
ββββββ¬βββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββ
β KeychainService β Keychainμ ν ν° μ μ₯
βββββββββββββββββββββββββββ
βββββββββββ
β μ¬μ©μ β API νΈμΆ
ββββββ¬βββββ
β
βΌ
βββββββββββββββββββββββββββ
β NetworkService β request()
ββββββ¬βββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββ
β AuthenticationInterceptorβ adapt() - ν ν° μ£Όμ
ββββββ¬βββββββββββββββββββββ
β
βΌ Authorization: Bearer <accessToken>
βββββββββββββββββββββββββββ
β Backend API β
ββββββ¬βββββββββββββββββββββ
β
βββΊ 200 OK β μλ΅ λ°ν
β
βββΊ 401 Unauthorized
β
βΌ
βββββββββββββββββββββββββββ
β AuthenticationInterceptorβ retry()
ββββββ¬βββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββ
β TokenRefreshCoordinator β refresh() - μ€λ³΅ λ°©μ§
ββββββ¬βββββββββββββββββββββ
β
βΌ POST /auth/refresh { refreshToken }
βββββββββββββββββββββββββββ
β Backend API β
ββββββ¬βββββββββββββββββββββ
β
βΌ 200 OK { accessToken, refreshToken }
βββββββββββββββββββββββββββ
β TokenManager β saveTokens()
ββββββ¬βββββββββββββββββββββ
β
βΌ μλ μμ² μ¬μλ (μ ν ν° μ¬μ©)
βββββββββββββββββββββββββββ
β Backend API β
ββββββ¬βββββββββββββββββββββ
β
βΌ 200 OK
βββββββββββββββββββββββββββ
β μ¬μ©μμκ² μλ΅ λ°ν β
βββββββββββββββββββββββββββ
μ¬λ¬ API μμ²μ΄ λμμ 401μ λ°μ κ²½μ°:
Request A βββ
β
Request B βββΌββΊ 401 Unauthorized
β
Request C βββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β TokenRefreshCoordinator (Actor) β
β β
β Request A: refresh() μμ β
β Request B: μ§ν μ€μΈ Task λκΈ° β
β Request C: μ§ν μ€μΈ Task λκΈ° β
ββββββ¬βββββββββββββββββββββββββββββββββ
β
βΌ λ¨ ν λ²λ§ ν ν° κ°±μ μμ²
βββββββββββββββββββββββββββ
β Backend API β
ββββββ¬βββββββββββββββββββββ
β
βΌ μλ‘μ΄ ν ν°
βββββββββββββββββββββββββββ
β Request A, B, C λͺ¨λ β
β μ ν ν°μΌλ‘ μ¬μλ β
βββββββββββββββββββββββββββ
import Combine
class GuestLoginViewModel: ObservableObject {
@Published var userName = ""
@Published var selectedImage: UIImage?
@Published var isAuthenticated = false
private let tokenManager: TokenManagerProtocol
func registerGuest() {
Task {
do {
// multipart/form-dataλ‘ μ
λ‘λ
let response = try await MultipartUploadHelper.uploadGuestRegister(
name: userName,
image: selectedImage
)
// ν ν° μ μ₯
try tokenManager.saveTokens(
accessToken: response.data.accessToken,
refreshToken: response.data.refreshToken
)
isAuthenticated = true
print("β
λ±λ‘ μ±κ³΅: \(response.data.user.name)")
} catch {
print("β λ±λ‘ μ€ν¨: \(error)")
}
}
}
}import Combine
class ArticleService {
private let networkService: NetworkServiceProtocol
private var cancellables = Set<AnyCancellable>()
init(networkService: NetworkServiceProtocol = NetworkService()) {
self.networkService = networkService
}
func fetchArticles() {
networkService.request(ArticleEndpoint.getArticles)
.receive(on: DispatchQueue.main)
.sink { completion in
if case .failure(let error) = completion {
print("β μλ¬: \(error)")
}
} receiveValue: { (articles: [Article]) in
print("β
κ²μκΈ \(articles.count)κ° λ‘λ")
}
.store(in: &cancellables)
}
}enum ArticleEndpoint: APIEndpoint {
case getArticles
case getArticle(id: Int)
case createArticle(title: String, content: String)
var path: String {
switch self {
case .getArticles:
return "/articles"
case .getArticle(let id):
return "/articles/\(id)"
case .createArticle:
return "/articles"
}
}
var method: HTTPMethod {
switch self {
case .getArticles, .getArticle:
return .get
case .createArticle:
return .post
}
}
var headers: [String: String]? {
return ["Content-Type": "application/json"]
}
var body: Data? {
switch self {
case .createArticle(let title, let content):
let params = ["title": title, "content": content]
return try? JSONEncoder().encode(params)
default:
return nil
}
}
var requiresAuthentication: Bool {
return true // λͺ¨λ κ²μκΈ APIλ μΈμ¦ νμ
}
}func logout() {
do {
try tokenManager.clearTokens()
isAuthenticated = false
print("β
λ‘κ·Έμμ μ±κ³΅")
} catch {
print("β λ‘κ·Έμμ μ€ν¨: \(error)")
}
}NetworkLayer ν΄λλ₯Ό Xcode νλ‘μ νΈμ λλκ·Έ μ€ λλ‘νμ¬ μΆκ°ν©λλ€.
NetworkConfig.swiftμμ μλ² URLμ μ€μ ν©λλ€:
struct NetworkConfig {
static let baseURL = "https://your-server.com/api/v1"
static let timeout: TimeInterval = 30
}import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}νλ‘ν μ½ κΈ°λ° μ€κ³λ‘ μ½κ² ν μ€νΈν μ μμ΅λλ€:
// Mock TokenManager
class MockTokenManager: TokenManagerProtocol {
var savedAccessToken: String?
var savedRefreshToken: String?
func saveTokens(accessToken: String, refreshToken: String) throws {
savedAccessToken = accessToken
savedRefreshToken = refreshToken
}
func getAccessToken() throws -> String {
return savedAccessToken ?? ""
}
// ...
}
// Mock NetworkService
class MockNetworkService: NetworkServiceProtocol {
var mockResponse: Any?
var mockError: NetworkError?
func request<T: Decodable>(_ endpoint: any APIEndpoint) -> AnyPublisher<T, NetworkError> {
if let error = mockError {
return Fail(error: error).eraseToAnyPublisher()
}
if let response = mockResponse as? T {
return Just(response)
.setFailureType(to: NetworkError.self)
.eraseToAnyPublisher()
}
return Fail(error: .invalidResponse).eraseToAnyPublisher()
}
}μ΄ νλ‘μ νΈλ λ€μκ³Ό κ°μ νμ€ μλ΅ νμμ κΈ°λν©λλ€:
{
"success": true,
"code": "OK",
"message": "μμ²μ΄ μ±κ³΅μ μΌλ‘ μ²λ¦¬λμμ΅λλ€.",
"data": {
"user": {
"id": "uuid-string",
"name": "μ¬μ©μ",
"profileUrl": "https://...",
"profileImageKey": "profiles/...",
},
"accessToken": "eyJ...",
"refreshToken": "eyJ..."
},
"errors": [],
"meta": {
"timestamp": "2025-11-02T06:18:09.844Z",
"path": "/api/v1/auth/guest/register",
"requestId": "uuid-string",
"durationMs": 115
}
}- Swift 5.5+ (async/await, Actor)
- Combine (μΈλΆ API)
- SwiftUI (UI)
- Keychain (보μ μ μ₯μ)
- URLSession (λ€νΈμν¬)
MIT License
μ΄μμ PRμ νμν©λλ€!
λ¬Έμ κ° μκ±°λ μ§λ¬Έμ΄ μμΌμλ©΄ μ΄μλ₯Ό λ±λ‘ν΄μ£ΌμΈμ.
Made with β€οΈ for iOS Developers