WeaveDI + Networking - URLSession, Alamofire, 그리고 Mock Testing

Ios_Roy·2025년 9월 17일

라이브러리 개발

목록 보기
10/25
post-thumbnail

한 줄 요약: 네트워킹을 깔끔하게 추상화하고, 환경별 설정을 손쉽게 관리하며, 테스트까지 완벽하게 자동화하는 방법을 알려드립니다.

안녕하세요! iOS 개발자 Roy입니다. 네트워킹은 거의 모든 앱에서 핵심적인 부분인데요, 오늘은 DiContainer를 활용해서 네트워킹 레이어를 깔끔하게 구성하는 방법을 공유해드리겠습니다. URLSession부터 Alamofire까지, 그리고 테스트 자동화까지 실전에서 바로 사용할 수 있는 방법들을 담았습니다.

🎯 네트워킹 레이어 설계 원칙

기존 네트워킹 코드의 문제점

// ❌ 전형적인 문제가 있는 네트워킹 코드
class ProductService {
    func fetchProducts() {
        let url = URL(string: "https://api.myapp.com/products")!
        
        URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                print("Error: \(error)")
                return
            }
            
            guard let data = data else { return }
            
            do {
                let products = try JSONDecoder().decode([Product].self, from: data)
                DispatchQueue.main.async {
                    // UI 업데이트...
                }
            } catch {
                print("Decode error: \(error)")
            }
        }.resume()
    }
}

문제점들:

  • 하드코딩된 URL (Dev/Staging/Prod 환경 구분 불가)
  • 에러 처리가 중복되고 일관성 없음
  • 테스트하기 어려운 구조
  • SSL Pinning, 인증 등 공통 기능 재사용 불가
  • 로깅 및 모니터링 기능 부재

DiContainer 기반 네트워킹 아키텍처

// ✅ 깔끔한 네트워킹 아키텍처
/*
┌─────────────────────────────────────────┐
│           Service Layer                 │  ← ProductService, UserService
│  (High-level business logic)           │
└─────────────────┬───────────────────────┘
                  │ uses
┌─────────────────▼───────────────────────┐
│          Network Layer                  │  ← NetworkClient, APIEndpoint
│  (Request building, Response parsing)   │
└─────────────────┬───────────────────────┘
                  │ uses
┌─────────────────▼───────────────────────┐
│         Transport Layer                 │  ← URLSession, Alamofire
│  (HTTP client implementation)          │
└─────────────────────────────────────────┘
*/

🌐 환경별 설정 관리

1. API 환경 구성

// API 환경 설정
enum APIEnvironment: String, CaseIterable {
    case development = "dev"
    case staging = "staging"
    case production = "prod"
    
    static var current: APIEnvironment {
        #if DEBUG
        return .development
        #elseif STAGING
        return .staging
        #else
        return .production
        #endif
    }
    
    var baseURL: String {
        switch self {
        case .development:
            return "https://dev-api.myapp.com"
        case .staging:
            return "https://staging-api.myapp.com"
        case .production:
            return "https://api.myapp.com"
        }
    }
    
    var timeout: TimeInterval {
        switch self {
        case .development:
            return 30.0 // 개발 시에는 길게
        case .staging, .production:
            return 15.0
        }
    }
    
    var logLevel: NetworkLogLevel {
        switch self {
        case .development:
            return .verbose
        case .staging:
            return .info
        case .production:
            return .error
        }
    }
    
    var enableSSLPinning: Bool {
        switch self {
        case .development, .staging:
            return false
        case .production:
            return true
        }
    }
}

enum NetworkLogLevel: String {
    case verbose = "verbose"
    case info = "info"
    case error = "error"
    case none = "none"
}

2. API 엔드포인트 정의

// API 엔드포인트 프로토콜
protocol APIEndpoint {
    var baseURL: String { get }
    var path: String { get }
    var method: HTTPMethod { get }
    var headers: [String: String] { get }
    var parameters: [String: Any]? { get }
    var body: Data? { get }
    var timeout: TimeInterval { get }
}

enum HTTPMethod: String {
    case GET = "GET"
    case POST = "POST"
    case PUT = "PUT"
    case DELETE = "DELETE"
    case PATCH = "PATCH"
}

// 구체적인 API 엔드포인트들
enum ProductAPI {
    case getAllProducts
    case getProduct(id: String)
    case searchProducts(query: String, category: String?)
    case createProduct(Product)
    case updateProduct(id: String, Product)
    case deleteProduct(id: String)
}

extension ProductAPI: APIEndpoint {
    var baseURL: String {
        return APIEnvironment.current.baseURL
    }
    
    var path: String {
        switch self {
        case .getAllProducts:
            return "/v1/products"
        case .getProduct(let id):
            return "/v1/products/\(id)"
        case .searchProducts:
            return "/v1/products/search"
        case .createProduct:
            return "/v1/products"
        case .updateProduct(let id, _):
            return "/v1/products/\(id)"
        case .deleteProduct(let id):
            return "/v1/products/\(id)"
        }
    }
    
    var method: HTTPMethod {
        switch self {
        case .getAllProducts, .getProduct, .searchProducts:
            return .GET
        case .createProduct:
            return .POST
        case .updateProduct:
            return .PUT
        case .deleteProduct:
            return .DELETE
        }
    }
    
    var headers: [String: String] {
        var headers = [
            "Content-Type": "application/json",
            "Accept": "application/json"
        ]
        
        // 인증 토큰이 있다면 추가
        if let token = TokenManager.shared.currentToken {
            headers["Authorization"] = "Bearer \(token)"
        }
        
        return headers
    }
    
    var parameters: [String: Any]? {
        switch self {
        case .searchProducts(let query, let category):
            var params = ["q": query]
            if let category = category {
                params["category"] = category
            }
            return params
        default:
            return nil
        }
    }
    
    var body: Data? {
        switch self {
        case .createProduct(let product), .updateProduct(_, let product):
            return try? JSONEncoder().encode(product)
        default:
            return nil
        }
    }
    
    var timeout: TimeInterval {
        return APIEnvironment.current.timeout
    }
}

// 사용자 관련 API
enum UserAPI: APIEndpoint {
    case login(email: String, password: String)
    case register(email: String, password: String, name: String)
    case getCurrentUser
    case updateProfile(User)
    case logout
    
    var baseURL: String { APIEnvironment.current.baseURL }
    
    var path: String {
        switch self {
        case .login: return "/v1/auth/login"
        case .register: return "/v1/auth/register"
        case .getCurrentUser, .updateProfile: return "/v1/user/profile"
        case .logout: return "/v1/auth/logout"
        }
    }
    
    var method: HTTPMethod {
        switch self {
        case .getCurrentUser: return .GET
        case .login, .register, .logout: return .POST
        case .updateProfile: return .PUT
        }
    }
    
    var headers: [String: String] {
        var headers = ["Content-Type": "application/json"]
        
        switch self {
        case .getCurrentUser, .updateProfile, .logout:
            if let token = TokenManager.shared.currentToken {
                headers["Authorization"] = "Bearer \(token)"
            }
        default:
            break
        }
        
        return headers
    }
    
    var parameters: [String: Any]? {
        return nil
    }
    
    var body: Data? {
        switch self {
        case .login(let email, let password):
            let loginData = ["email": email, "password": password]
            return try? JSONSerialization.data(withJSONObject: loginData)
            
        case .register(let email, let password, let name):
            let registerData = ["email": email, "password": password, "name": name]
            return try? JSONSerialization.data(withJSONObject: registerData)
            
        case .updateProfile(let user):
            return try? JSONEncoder().encode(user)
            
        default:
            return nil
        }
    }
    
    var timeout: TimeInterval { APIEnvironment.current.timeout }
}

🔧 URLSession 기반 네트워크 클라이언트

1. 기본 네트워크 클라이언트 구현

// 네트워크 결과 타입
enum NetworkResult<T> {
    case success(T)
    case failure(NetworkError)
}

enum NetworkError: LocalizedError {
    case invalidURL
    case noData
    case decodingError(Error)
    case networkError(Error)
    case serverError(statusCode: Int, message: String?)
    case unauthorized
    case timeout
    
    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "잘못된 URL입니다."
        case .noData:
            return "데이터를 받을 수 없습니다."
        case .decodingError(let error):
            return "데이터 파싱 오류: \(error.localizedDescription)"
        case .networkError(let error):
            return "네트워크 오류: \(error.localizedDescription)"
        case .serverError(let statusCode, let message):
            return "서버 오류 (\(statusCode)): \(message ?? "알 수 없는 오류")"
        case .unauthorized:
            return "인증이 필요합니다."
        case .timeout:
            return "요청 시간이 초과되었습니다."
        }
    }
}

// 네트워크 클라이언트 프로토콜
protocol NetworkClientProtocol {
    func request<T: Codable>(_ endpoint: APIEndpoint, responseType: T.Type) async throws -> T
    func request(_ endpoint: APIEndpoint) async throws -> Data
}

// URLSession 기반 구현
class URLSessionNetworkClient: NetworkClientProtocol {
    private let session: URLSession
    private let logger: NetworkLoggerProtocol
    
    init(session: URLSession = .shared, logger: NetworkLoggerProtocol) {
        self.session = session
        self.logger = logger
    }
    
    func request<T: Codable>(_ endpoint: APIEndpoint, responseType: T.Type) async throws -> T {
        let data = try await request(endpoint)
        
        do {
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            let result = try decoder.decode(responseType, from: data)
            
            await logger.logResponse(data: data, type: responseType)
            return result
            
        } catch {
            await logger.logError("Decoding failed for \(responseType): \(error)")
            throw NetworkError.decodingError(error)
        }
    }
    
    func request(_ endpoint: APIEndpoint) async throws -> Data {
        // URL 구성
        guard let url = buildURL(from: endpoint) else {
            await logger.logError("Invalid URL for endpoint: \(endpoint)")
            throw NetworkError.invalidURL
        }
        
        // URLRequest 생성
        var request = URLRequest(url: url)
        request.httpMethod = endpoint.method.rawValue
        request.timeoutInterval = endpoint.timeout
        
        // Headers 설정
        for (key, value) in endpoint.headers {
            request.setValue(value, forHTTPHeaderField: key)
        }
        
        // Body 설정
        if let body = endpoint.body {
            request.httpBody = body
        }
        
        // 로깅
        await logger.logRequest(request)
        
        do {
            let (data, response) = try await session.data(for: request)
            
            // Response 검증
            guard let httpResponse = response as? HTTPURLResponse else {
                throw NetworkError.networkError(URLError(.badServerResponse))
            }
            
            await logger.logResponse(response: httpResponse, data: data)
            
            // 상태 코드 검증
            try validateResponse(httpResponse, data: data)
            
            return data
            
        } catch let error as URLError {
            await logger.logError("URLError: \(error)")
            
            if error.code == .timedOut {
                throw NetworkError.timeout
            } else {
                throw NetworkError.networkError(error)
            }
        } catch let networkError as NetworkError {
            throw networkError
        } catch {
            await logger.logError("Unexpected error: \(error)")
            throw NetworkError.networkError(error)
        }
    }
    
    private func buildURL(from endpoint: APIEndpoint) -> URL? {
        guard var components = URLComponents(string: endpoint.baseURL + endpoint.path) else {
            return nil
        }
        
        // Query parameters 추가
        if let parameters = endpoint.parameters, !parameters.isEmpty {
            components.queryItems = parameters.map { key, value in
                URLQueryItem(name: key, value: String(describing: value))
            }
        }
        
        return components.url
    }
    
    private func validateResponse(_ response: HTTPURLResponse, data: Data) throws {
        switch response.statusCode {
        case 200...299:
            // 성공
            break
        case 401:
            throw NetworkError.unauthorized
        case 400...499, 500...599:
            // 서버 에러 메시지 파싱 시도
            let errorMessage = parseErrorMessage(from: data)
            throw NetworkError.serverError(statusCode: response.statusCode, message: errorMessage)
        default:
            throw NetworkError.serverError(statusCode: response.statusCode, message: nil)
        }
    }
    
    private func parseErrorMessage(from data: Data) -> String? {
        // 서버의 에러 응답 형태에 맞춰 파싱
        if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
           let message = json["message"] as? String {
            return message
        }
        
        if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
           let error = json["error"] as? String {
            return error
        }
        
        return nil
    }
}

2. 네트워크 로깅

protocol NetworkLoggerProtocol {
    func logRequest(_ request: URLRequest) async
    func logResponse(response: HTTPURLResponse, data: Data) async
    func logResponse<T>(data: Data, type: T.Type) async where T: Codable
    func logError(_ message: String) async
}

class NetworkLogger: NetworkLoggerProtocol {
    private let logLevel: NetworkLogLevel
    
    init(logLevel: NetworkLogLevel) {
        self.logLevel = logLevel
    }
    
    func logRequest(_ request: URLRequest) async {
        guard shouldLog(.info) else { return }
        
        var log = "🌐 [\(request.httpMethod ?? "GET")] \(request.url?.absoluteString ?? "")"
        
        if shouldLog(.verbose) {
            if let headers = request.allHTTPHeaderFields, !headers.isEmpty {
                log += "\n📤 Headers: \(headers)"
            }
            
            if let body = request.httpBody,
               let bodyString = String(data: body, encoding: .utf8) {
                log += "\n📤 Body: \(bodyString)"
            }
        }
        
        print(log)
    }
    
    func logResponse(response: HTTPURLResponse, data: Data) async {
        guard shouldLog(.info) else { return }
        
        let statusEmoji = response.statusCode < 300 ? "✅" : "❌"
        var log = "\(statusEmoji) \(response.statusCode) - \(data.count) bytes"
        
        if shouldLog(.verbose) {
            if let responseString = String(data: data, encoding: .utf8) {
                log += "\n📥 Response: \(responseString)"
            }
        }
        
        print(log)
    }
    
    func logResponse<T>(data: Data, type: T.Type) async where T: Codable {
        guard shouldLog(.verbose) else { return }
        
        print("🔄 Decoded \(type) from \(data.count) bytes")
    }
    
    func logError(_ message: String) async {
        guard shouldLog(.error) else { return }
        print("💥 Network Error: \(message)")
    }
    
    private func shouldLog(_ level: NetworkLogLevel) -> Bool {
        let levels: [NetworkLogLevel] = [.none, .error, .info, .verbose]
        guard let currentIndex = levels.firstIndex(of: logLevel),
              let targetIndex = levels.firstIndex(of: level) else {
            return false
        }
        return targetIndex <= currentIndex
    }
}

🚀 Alamofire 기반 구현

Alamofire 네트워크 클라이언트

import Alamofire

class AlamofireNetworkClient: NetworkClientProtocol {
    private let session: Session
    private let logger: NetworkLoggerProtocol
    
    init(session: Session = AF, logger: NetworkLoggerProtocol) {
        self.session = session
        self.logger = logger
    }
    
    func request<T: Codable>(_ endpoint: APIEndpoint, responseType: T.Type) async throws -> T {
        return try await withCheckedThrowingContinuation { continuation in
            let request = session.request(
                endpoint.baseURL + endpoint.path,
                method: alamofireMethod(from: endpoint.method),
                parameters: endpoint.parameters,
                encoding: endpoint.method == .GET ? URLEncoding.default : JSONEncoding.default,
                headers: HTTPHeaders(endpoint.headers)
            )
            .validate()
            .responseDecodable(of: responseType) { response in
                Task {
                    await self.logger.logResponse(
                        response: response.response ?? HTTPURLResponse(),
                        data: response.data ?? Data()
                    )
                }
                
                switch response.result {
                case .success(let value):
                    continuation.resume(returning: value)
                case .failure(let error):
                    continuation.resume(throwing: self.mapAlamofireError(error))
                }
            }
            
            // 요청 로깅
            Task {
                await self.logger.logRequest(request.request ?? URLRequest(url: URL(string: endpoint.baseURL)!))
            }
        }
    }
    
    func request(_ endpoint: APIEndpoint) async throws -> Data {
        return try await withCheckedThrowingContinuation { continuation in
            let request = session.request(
                endpoint.baseURL + endpoint.path,
                method: alamofireMethod(from: endpoint.method),
                parameters: endpoint.parameters,
                encoding: endpoint.method == .GET ? URLEncoding.default : JSONEncoding.default,
                headers: HTTPHeaders(endpoint.headers)
            )
            .validate()
            .responseData { response in
                Task {
                    await self.logger.logResponse(
                        response: response.response ?? HTTPURLResponse(),
                        data: response.data ?? Data()
                    )
                }
                
                switch response.result {
                case .success(let data):
                    continuation.resume(returning: data)
                case .failure(let error):
                    continuation.resume(throwing: self.mapAlamofireError(error))
                }
            }
            
            Task {
                await self.logger.logRequest(request.request ?? URLRequest(url: URL(string: endpoint.baseURL)!))
            }
        }
    }
    
    private func alamofireMethod(from httpMethod: HTTPMethod) -> Alamofire.HTTPMethod {
        switch httpMethod {
        case .GET: return .get
        case .POST: return .post
        case .PUT: return .put
        case .DELETE: return .delete
        case .PATCH: return .patch
        }
    }
    
    private func mapAlamofireError(_ error: AFError) -> NetworkError {
        switch error {
        case .responseValidationFailed(let reason):
            switch reason {
            case .unacceptableStatusCode(let code):
                return .serverError(statusCode: code, message: nil)
            default:
                return .networkError(error)
            }
        case .sessionTaskFailed(let error):
            if let urlError = error as? URLError, urlError.code == .timedOut {
                return .timeout
            }
            return .networkError(error)
        default:
            return .networkError(error)
        }
    }
}

🏢 서비스 레이어 구현

1. 제품 서비스

protocol ProductServiceProtocol {
    func getAllProducts() async throws -> [Product]
    func getProduct(id: String) async throws -> Product?
    func searchProducts(query: String, category: ProductCategory?) async throws -> [Product]
    func createProduct(_ product: Product) async throws -> Product
    func updateProduct(id: String, _ product: Product) async throws -> Product
    func deleteProduct(id: String) async throws
}

class ProductService: ProductServiceProtocol {
    @Inject private var networkClient: NetworkClientProtocol
    @Inject private var cacheService: CacheServiceProtocol
    
    func getAllProducts() async throws -> [Product] {
        // 캐시 확인
        let cacheKey = "all_products"
        if let cached: [Product] = await cacheService.get(key: cacheKey) {
            return cached
        }
        
        // 네트워크 요청
        let products = try await networkClient.request(
            ProductAPI.getAllProducts,
            responseType: [Product].self
        )
        
        // 캐시에 저장 (5분간 유효)
        await cacheService.set(key: cacheKey, value: products, ttl: 300)
        
        return products
    }
    
    func getProduct(id: String) async throws -> Product? {
        let cacheKey = "product_\(id)"
        if let cached: Product = await cacheService.get(key: cacheKey) {
            return cached
        }
        
        do {
            let product = try await networkClient.request(
                ProductAPI.getProduct(id: id),
                responseType: Product.self
            )
            
            await cacheService.set(key: cacheKey, value: product, ttl: 300)
            return product
            
        } catch NetworkError.serverError(let statusCode, _) where statusCode == 404 {
            return nil // 상품이 없는 경우
        }
    }
    
    func searchProducts(query: String, category: ProductCategory? = nil) async throws -> [Product] {
        // 검색은 항상 최신 데이터로
        return try await networkClient.request(
            ProductAPI.searchProducts(query: query, category: category?.rawValue),
            responseType: [Product].self
        )
    }
    
    func createProduct(_ product: Product) async throws -> Product {
        let createdProduct = try await networkClient.request(
            ProductAPI.createProduct(product),
            responseType: Product.self
        )
        
        // 캐시 무효화
        await cacheService.remove(key: "all_products")
        
        return createdProduct
    }
    
    func updateProduct(id: String, _ product: Product) async throws -> Product {
        let updatedProduct = try await networkClient.request(
            ProductAPI.updateProduct(id: id, product),
            responseType: Product.self
        )
        
        // 관련 캐시 무효화
        await cacheService.remove(key: "product_\(id)")
        await cacheService.remove(key: "all_products")
        
        return updatedProduct
    }
    
    func deleteProduct(id: String) async throws {
        _ = try await networkClient.request(ProductAPI.deleteProduct(id: id))
        
        // 관련 캐시 무효화
        await cacheService.remove(key: "product_\(id)")
        await cacheService.remove(key: "all_products")
    }
}

2. 사용자 서비스

protocol UserServiceProtocol {
    func login(email: String, password: String) async throws -> AuthResponse
    func register(email: String, password: String, name: String) async throws -> AuthResponse
    func getCurrentUser() async throws -> User
    func updateProfile(_ user: User) async throws -> User
    func logout() async throws
}

struct AuthResponse: Codable {
    let token: String
    let refreshToken: String
    let user: User
    let expiresIn: Int
}

class UserService: UserServiceProtocol {
    @Inject private var networkClient: NetworkClientProtocol
    @Inject private var tokenManager: TokenManagerProtocol
    @Inject private var analytics: AnalyticsServiceProtocol
    
    func login(email: String, password: String) async throws -> AuthResponse {
        let authResponse = try await networkClient.request(
            UserAPI.login(email: email, password: password),
            responseType: AuthResponse.self
        )
        
        // 토큰 저장
        await tokenManager.saveTokens(
            accessToken: authResponse.token,
            refreshToken: authResponse.refreshToken
        )
        
        // 분석 이벤트
        await analytics.track("user_login", properties: [
            "user_id": authResponse.user.id,
            "login_method": "email"
        ])
        
        return authResponse
    }
    
    func register(email: String, password: String, name: String) async throws -> AuthResponse {
        let authResponse = try await networkClient.request(
            UserAPI.register(email: email, password: password, name: name),
            responseType: AuthResponse.self
        )
        
        await tokenManager.saveTokens(
            accessToken: authResponse.token,
            refreshToken: authResponse.refreshToken
        )
        
        await analytics.track("user_register", properties: [
            "user_id": authResponse.user.id,
            "registration_method": "email"
        ])
        
        return authResponse
    }
    
    func getCurrentUser() async throws -> User {
        return try await networkClient.request(
            UserAPI.getCurrentUser,
            responseType: User.self
        )
    }
    
    func updateProfile(_ user: User) async throws -> User {
        let updatedUser = try await networkClient.request(
            UserAPI.updateProfile(user),
            responseType: User.self
        )
        
        await analytics.track("user_profile_updated", properties: [
            "user_id": user.id
        ])
        
        return updatedUser
    }
    
    func logout() async throws {
        _ = try await networkClient.request(UserAPI.logout)
        
        // 토큰 제거
        await tokenManager.clearTokens()
        
        await analytics.track("user_logout")
    }
}

🧪 Mock Testing 및 자동화

1. Mock 네트워크 클라이언트

class MockNetworkClient: NetworkClientProtocol {
    var mockResponses: [String: Any] = [:]
    var mockErrors: [String: NetworkError] = [:]
    var requestDelay: TimeInterval = 0.1
    var requestLog: [APIEndpoint] = []
    
    func request<T: Codable>(_ endpoint: APIEndpoint, responseType: T.Type) async throws -> T {
        // 요청 로깅
        requestLog.append(endpoint)
        
        // 지연 시뮬레이션
        try await Task.sleep(nanoseconds: UInt64(requestDelay * 1_000_000_000))
        
        let key = endpointKey(endpoint)
        
        // 에러가 설정되어 있다면 에러 던지기
        if let error = mockErrors[key] {
            throw error
        }
        
        // Mock 응답 반환
        if let mockResponse = mockResponses[key] {
            if let response = mockResponse as? T {
                return response
            } else if let data = mockResponse as? Data {
                let decoder = JSONDecoder()
                decoder.dateDecodingStrategy = .iso8601
                return try decoder.decode(T.self, from: data)
            } else {
                // JSON 객체를 Data로 변환 후 디코딩
                let jsonData = try JSONSerialization.data(withJSONObject: mockResponse)
                let decoder = JSONDecoder()
                decoder.dateDecodingStrategy = .iso8601
                return try decoder.decode(T.self, from: jsonData)
            }
        }
        
        throw NetworkError.noData
    }
    
    func request(_ endpoint: APIEndpoint) async throws -> Data {
        requestLog.append(endpoint)
        
        try await Task.sleep(nanoseconds: UInt64(requestDelay * 1_000_000_000))
        
        let key = endpointKey(endpoint)
        
        if let error = mockErrors[key] {
            throw error
        }
        
        if let mockResponse = mockResponses[key] {
            if let data = mockResponse as? Data {
                return data
            } else {
                return try JSONSerialization.data(withJSONObject: mockResponse)
            }
        }
        
        throw NetworkError.noData
    }
    
    private func endpointKey(_ endpoint: APIEndpoint) -> String {
        return "\(endpoint.method.rawValue) \(endpoint.path)"
    }
    
    // Test Helper 메서드들
    func setMockResponse<T: Codable>(_ response: T, for endpoint: APIEndpoint) {
        let key = endpointKey(endpoint)
        mockResponses[key] = response
    }
    
    func setMockError(_ error: NetworkError, for endpoint: APIEndpoint) {
        let key = endpointKey(endpoint)
        mockErrors[key] = error
    }
    
    func clearMocks() {
        mockResponses.removeAll()
        mockErrors.removeAll()
        requestLog.removeAll()
    }
    
    func wasRequestMade(to endpoint: APIEndpoint) -> Bool {
        let targetKey = endpointKey(endpoint)
        return requestLog.contains { endpointKey($0) == targetKey }
    }
    
    func requestCount(to endpoint: APIEndpoint) -> Int {
        let targetKey = endpointKey(endpoint)
        return requestLog.filter { endpointKey($0) == targetKey }.count
    }
}

2. 네트워킹 단위 테스트

class ProductServiceTests: XCTestCase {
    var productService: ProductService!
    var mockNetworkClient: MockNetworkClient!
    var mockCacheService: MockCacheService!
    
    override func setUp() async throws {
        await DependencyContainer.resetForTesting()
        
        mockNetworkClient = MockNetworkClient()
        mockCacheService = MockCacheService()
        
        await DependencyContainer.bootstrap { container in
            container.register(NetworkClientProtocol.self) {
                self.mockNetworkClient
            }
            container.register(CacheServiceProtocol.self) {
                self.mockCacheService
            }
        }
        
        productService = ProductService()
    }
    
    func testGetAllProducts_Success() async throws {
        // Given
        let mockProducts = [
            Product(id: "1", name: "iPhone", price: 999, description: "스마트폰",
                   imageURL: nil, category: .electronics, inStock: true, rating: 4.5),
            Product(id: "2", name: "MacBook", price: 1999, description: "노트북",
                   imageURL: nil, category: .electronics, inStock: true, rating: 4.8)
        ]
        
        mockNetworkClient.setMockResponse(mockProducts, for: ProductAPI.getAllProducts)
        
        // When
        let result = try await productService.getAllProducts()
        
        // Then
        XCTAssertEqual(result.count, 2)
        XCTAssertEqual(result.first?.name, "iPhone")
        XCTAssertTrue(mockNetworkClient.wasRequestMade(to: ProductAPI.getAllProducts))
        
        // 캐시에 저장되었는지 확인
        let cachedProducts: [Product]? = await mockCacheService.get(key: "all_products")
        XCTAssertNotNil(cachedProducts)
        XCTAssertEqual(cachedProducts?.count, 2)
    }
    
    func testGetAllProducts_FromCache() async throws {
        // Given - 캐시에 데이터가 있는 상황
        let cachedProducts = [
            Product(id: "1", name: "Cached iPhone", price: 899, description: "캐시된 데이터",
                   imageURL: nil, category: .electronics, inStock: true, rating: 4.5)
        ]
        
        await mockCacheService.set(key: "all_products", value: cachedProducts, ttl: 300)
        
        // When
        let result = try await productService.getAllProducts()
        
        // Then
        XCTAssertEqual(result.count, 1)
        XCTAssertEqual(result.first?.name, "Cached iPhone")
        
        // 네트워크 요청이 없었는지 확인
        XCTAssertFalse(mockNetworkClient.wasRequestMade(to: ProductAPI.getAllProducts))
    }
    
    func testGetProduct_NotFound() async throws {
        // Given
        mockNetworkClient.setMockError(
            NetworkError.serverError(statusCode: 404, message: "Product not found"),
            for: ProductAPI.getProduct(id: "999")
        )
        
        // When
        let result = try await productService.getProduct(id: "999")
        
        // Then
        XCTAssertNil(result) // 404는 nil 반환
    }
    
    func testSearchProducts_NetworkError() async throws {
        // Given
        mockNetworkClient.setMockError(
            NetworkError.networkError(URLError(.notConnectedToInternet)),
            for: ProductAPI.searchProducts(query: "iPhone", category: nil)
        )
        
        // When & Then
        do {
            _ = try await productService.searchProducts(query: "iPhone")
            XCTFail("Should throw network error")
        } catch NetworkError.networkError {
            // 예상된 에러
        } catch {
            XCTFail("Unexpected error: \(error)")
        }
    }
    
    func testCreateProduct_CacheInvalidation() async throws {
        // Given
        let newProduct = Product(id: "3", name: "iPad", price: 799, description: "태블릿",
                               imageURL: nil, category: .electronics, inStock: true, rating: 4.6)
        
        // 캐시에 기존 데이터가 있다고 가정
        await mockCacheService.set(key: "all_products", value: [], ttl: 300)
        
        mockNetworkClient.setMockResponse(newProduct, for: ProductAPI.createProduct(newProduct))
        
        // When
        let result = try await productService.createProduct(newProduct)
        
        // Then
        XCTAssertEqual(result.id, "3")
        XCTAssertEqual(result.name, "iPad")
        
        // 캐시가 무효화되었는지 확인
        let cachedProducts: [Product]? = await mockCacheService.get(key: "all_products")
        XCTAssertNil(cachedProducts)
    }
}

3. 통합 테스트

class NetworkIntegrationTests: XCTestCase {
    var networkClient: NetworkClientProtocol!
    
    override func setUp() async throws {
        // 실제 네트워크 클라이언트 사용 (테스트 서버 대상)
        let logger = NetworkLogger(logLevel: .verbose)
        networkClient = URLSessionNetworkClient(logger: logger)
        
        // 테스트 환경으로 API 환경 변경
        // APIEnvironment.current = .development
    }
    
    func testRealAPICall() async throws {
        // 실제 API 호출 테스트 (테스트 서버 대상)
        // 주의: CI/CD에서는 Mock 서버나 실제 테스트 서버 필요
        
        // Given
        let endpoint = ProductAPI.getAllProducts
        
        // When
        let products = try await networkClient.request(endpoint, responseType: [Product].self)
        
        // Then
        XCTAssertFalse(products.isEmpty)
        
        // 각 상품이 유효한지 검증
        for product in products {
            XCTAssertFalse(product.id.isEmpty)
            XCTAssertFalse(product.name.isEmpty)
            XCTAssertGreaterThan(product.price, 0)
        }
    }
    
    func testNetworkTimeout() async throws {
        // 타임아웃 테스트
        let customClient = URLSessionNetworkClient(
            session: {
                let config = URLSessionConfiguration.default
                config.timeoutIntervalForRequest = 0.1 // 매우 짧은 타임아웃
                return URLSession(configuration: config)
            }(),
            logger: NetworkLogger(logLevel: .error)
        )
        
        do {
            _ = try await customClient.request(ProductAPI.getAllProducts, responseType: [Product].self)
            XCTFail("Should timeout")
        } catch NetworkError.timeout {
            // 예상된 타임아웃 에러
        } catch {
            XCTFail("Unexpected error: \(error)")
        }
    }
}

🔧 DI 컨테이너 설정

네트워킹 관련 의존성 등록

extension DependencyContainer {
    static func bootstrapNetworking() async {
        await DependencyContainer.bootstrap { container in
            // ============ Core Networking ============
            // 로거 등록
            container.register(NetworkLoggerProtocol.self, scope: .singleton) {
                NetworkLogger(logLevel: APIEnvironment.current.logLevel)
            }
            
            // URLSession 기반 네트워크 클라이언트
            container.register(NetworkClientProtocol.self, scope: .singleton) {
                URLSessionNetworkClient(logger: UnifiedDI.resolveRequired(NetworkLoggerProtocol.self))
            }
            
            // 또는 Alamofire 기반 (선택적)
            // container.register(NetworkClientProtocol.self, scope: .singleton) {
            //     AlamofireNetworkClient(logger: UnifiedDI.resolveRequired(NetworkLoggerProtocol.self))
            // }
            
            // ============ Cache Service ============
            container.register(CacheServiceProtocol.self, scope: .singleton) {
                InMemoryCacheService()
            }
            
            // ============ Token Management ============
            container.register(TokenManagerProtocol.self, scope: .singleton) {
                KeychainTokenManager()
            }
            
            // ============ Business Services ============
            container.register(ProductServiceProtocol.self, scope: .singleton) {
                ProductService()
            }
            
            container.register(UserServiceProtocol.self, scope: .singleton) {
                UserService()
            }
            
            container.register(CartServiceProtocol.self, scope: .scoped("user_session")) {
                CartService()
            }
            
            // ============ Analytics ============
            container.register(AnalyticsServiceProtocol.self, scope: .singleton) {
                if APIEnvironment.current == .production {
                    return FirebaseAnalyticsService()
                } else {
                    return ConsoleAnalyticsService()
                }
            }
        }
    }
}

// 앱 시작시 호출
@main
struct MyApp: App {
    init() {
        Task {
            await DependencyContainer.bootstrapNetworking()
        }
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

🎯 실전 활용 팁

1. API 모니터링 및 분석

class NetworkMonitor {
    @Inject private var analytics: AnalyticsServiceProtocol
    
    func trackAPICall(_ endpoint: APIEndpoint, duration: TimeInterval, success: Bool) async {
        await analytics.track("api_call", properties: [
            "endpoint": endpoint.path,
            "method": endpoint.method.rawValue,
            "duration_ms": Int(duration * 1000),
            "success": success,
            "environment": APIEnvironment.current.rawValue
        ])
    }
    
    func trackAPIError(_ endpoint: APIEndpoint, error: NetworkError) async {
        await analytics.track("api_error", properties: [
            "endpoint": endpoint.path,
            "method": endpoint.method.rawValue,
            "error_type": String(describing: type(of: error)),
            "error_message": error.localizedDescription,
            "environment": APIEnvironment.current.rawValue
        ])
    }
}

2. 자동 재시도 로직

extension NetworkClientProtocol {
    func requestWithRetry<T: Codable>(
        _ endpoint: APIEndpoint,
        responseType: T.Type,
        maxRetries: Int = 3,
        retryDelay: TimeInterval = 1.0
    ) async throws -> T {
        
        for attempt in 1...maxRetries {
            do {
                return try await request(endpoint, responseType: responseType)
            } catch NetworkError.timeout, NetworkError.networkError {
                if attempt == maxRetries {
                    throw NetworkError.networkError(URLError(.timedOut))
                }
                
                // 지수 백오프로 재시도 간격 증가
                let delay = retryDelay * pow(2.0, Double(attempt - 1))
                try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                
                continue
            } catch {
                // 서버 에러나 다른 에러는 재시도하지 않음
                throw error
            }
        }
        
        throw NetworkError.networkError(URLError(.timedOut))
    }
}

3. 오프라인 지원

class OfflineCapableProductService: ProductServiceProtocol {
    @Inject private var networkService: ProductServiceProtocol
    @Inject private var localDatabase: LocalDatabaseProtocol
    @Inject private var networkMonitor: NetworkReachabilityProtocol
    
    func getAllProducts() async throws -> [Product] {
        if await networkMonitor.isConnected {
            do {
                let products = try await networkService.getAllProducts()
                // 성공 시 로컬에 백업
                try await localDatabase.save(products)
                return products
            } catch {
                // 네트워크 실패 시 로컬 데이터 사용
                return try await localDatabase.loadProducts()
            }
        } else {
            // 오프라인 시 로컬 데이터 사용
            return try await localDatabase.loadProducts()
        }
    }
}

🎉 마무리

DiContainer를 활용한 네트워킹 레이어의 장점들:

  • 🔧 유연성: URLSession과 Alamofire 간 쉬운 전환
  • 🧪 테스트 용이성: Mock 객체로 완벽한 테스트 환경
  • 🌐 환경 관리: Dev/Staging/Prod 환경별 자동 설정
  • 📊 모니터링: 자동화된 API 성능 추적
  • 🛡️ 안정성: 에러 처리와 재시도 로직
  • 📱 오프라인 지원: 네트워크 상태에 따른 대응

네트워킹은 앱의 핵심입니다. DiContainer와 함께 안정적이고 확장 가능한 네트워킹 레이어를 구축해서 더 나은 사용자 경험을 제공해보세요! 🚀


시리즈 마무리: 지금까지 DiContainer의 고급 활용법부터 실전 적용까지 다뤘습니다.

질문이나 피드백은 언제든 GitHub에 남겨주세요!

profile
iOS 개발자 공부하는 Roy

0개의 댓글