한 줄 요약: 네트워킹을 깔끔하게 추상화하고, 환경별 설정을 손쉽게 관리하며, 테스트까지 완벽하게 자동화하는 방법을 알려드립니다.
안녕하세요! 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()
}
}
문제점들:
// ✅ 깔끔한 네트워킹 아키텍처
/*
┌─────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────┘
*/
// 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"
}
// 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 }
}
// 네트워크 결과 타입
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
}
}
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
}
}
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)
}
}
}
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")
}
}
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")
}
}
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
}
}
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)
}
}
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)")
}
}
}
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()
}
}
}
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
])
}
}
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))
}
}
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를 활용한 네트워킹 레이어의 장점들:
네트워킹은 앱의 핵심입니다. DiContainer와 함께 안정적이고 확장 가능한 네트워킹 레이어를 구축해서 더 나은 사용자 경험을 제공해보세요! 🚀
시리즈 마무리: 지금까지 DiContainer의 고급 활용법부터 실전 적용까지 다뤘습니다.
질문이나 피드백은 언제든 GitHub에 남겨주세요!