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