Skip to content

C6-DANDAN/ios-network-module

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 

Repository files navigation

iOS JWT Network Layer

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          │
└─────────────────────────────────────────┘

핵심 패턴

1. 인터셉터 패턴

모든 네트워크 요청은 인터셉터를 거치며, 요청 전후에 커스텀 로직을 실행할 수 있습니다.

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
}

2. Actor 기반 토큰 갱신 동기화

여러 요청이 동시에 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

핵심 컴포넌트

1. NetworkService (Core)

모든 API 요청의 진입점입니다. 인터셉터를 적용하고 내부적으로 async/await을 사용하며, 외부에는 Combine API를 제공합니다.

주요 메서드:

func request<T: Decodable>(_ endpoint: any APIEndpoint) -> AnyPublisher<T, NetworkError>

특징:

  • 제네릭을 통한 타입 안전성
  • 자동 JSON 디코딩
  • 인터셉터 체인 실행
  • 401 에러 시 자동 재시도

2. AuthenticationInterceptor (Interceptors)

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(...)
    // 새 토큰으로 요청 재구성
}

3. TokenManager (Auth)

토큰의 저장, 조회, 삭제를 담당합니다. Keychain을 통해 안전하게 토큰을 관리합니다.

주요 메서드:

func saveTokens(accessToken: String, refreshToken: String) throws
func getAccessToken() throws -> String
func getRefreshToken() throws -> String
func clearTokens() throws
func isAuthenticated() -> Bool

4. TokenRefreshCoordinator (Auth)

Actor를 사용하여 토큰 갱신 작업을 동기화합니다. 여러 요청이 동시에 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
    }
}

5. MultipartUploadHelper

이미지를 포함한 multipart/form-data 업로드를 처리합니다. 자동 리사이징 및 압축 기능을 제공합니다.

주요 기능:

  • 이미지를 최대 1024x1024로 리사이징
  • 점진적 압축 (0.8 → 0.1 품질)
  • 5MB 크기 제한 준수

사용 예:

let response = try await MultipartUploadHelper.uploadGuestRegister(
    name: "사용자",
    image: profileImage
)

6. KeychainService (Auth)

iOS Keychain을 통해 안전하게 데이터를 저장합니다.

주요 메서드:

func save(key: String, data: Data) throws
func read(key: String) throws -> Data
func delete(key: String) throws

플로우 설명

1. 게스트 등록 플로우

┌─────────┐
│  사용자  │ 이름 + 이미지 입력
└────┬────┘
     │
     ▼
┌─────────────────────────┐
│ GuestLoginViewModel     │ registerGuest()
└────┬────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ MultipartUploadHelper   │ 이미지 리사이징 + 압축
└────┬────────────────────┘
     │
     ▼ POST /auth/guest/register
┌─────────────────────────┐
│      Backend API        │
└────┬────────────────────┘
     │
     ▼ 200 OK { data: { user, accessToken, refreshToken } }
┌─────────────────────────┐
│    TokenManager         │ saveTokens()
└────┬────────────────────┘
     │
     ▼
┌─────────────────────────┐
│   KeychainService       │ Keychain에 토큰 저장
└─────────────────────────┘

2. 인증이 필요한 API 요청 플로우

┌─────────┐
│  사용자  │ 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
     ┌─────────────────────────┐
     │   사용자에게 응답 반환   │
     └─────────────────────────┘

3. 동시 다발적 401 에러 처리 플로우

여러 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 모두   │
│  새 토큰으로 재시도     │
└─────────────────────────┘

사용 예제

1. 게스트 등록

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)")
            }
        }
    }
}

2. 인증이 필요한 API 호출

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)
    }
}

3. 커스텀 엔드포인트 정의

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는 인증 필요
    }
}

4. 로그아웃

func logout() {
    do {
        try tokenManager.clearTokens()
        isAuthenticated = false
        print("✅ 로그아웃 성공")
    } catch {
        print("❌ 로그아웃 실패: \(error)")
    }
}

설치 및 사용

1. 프로젝트에 추가

NetworkLayer 폴더를 Xcode 프로젝트에 드래그 앤 드롭하여 추가합니다.

2. Base URL 설정

NetworkConfig.swift에서 서버 URL을 설정합니다:

struct NetworkConfig {
    static let baseURL = "https://your-server.com/api/v1"
    static let timeout: TimeInterval = 30
}

3. 사용

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

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages