diff --git a/AppleStoreProductKiosk.xcodeproj/project.pbxproj b/AppleStoreProductKiosk.xcodeproj/project.pbxproj index 42f0bbb..aaaba52 100644 --- a/AppleStoreProductKiosk.xcodeproj/project.pbxproj +++ b/AppleStoreProductKiosk.xcodeproj/project.pbxproj @@ -34,9 +34,22 @@ C8F725F52E790FA100C2A1DE /* AppleStoreProductKioskUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AppleStoreProductKioskUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7F41C5542E798756007C4017 /* Exceptions for "AppleStoreProductKiosk" folder in "AppleStoreProductKiosk" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resources/Info.plist, + ); + target = C8F725DD2E790F9F00C2A1DE /* AppleStoreProductKiosk */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ C8F725E02E790F9F00C2A1DE /* AppleStoreProductKiosk */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7F41C5542E798756007C4017 /* Exceptions for "AppleStoreProductKiosk" folder in "AppleStoreProductKiosk" target */, + ); path = AppleStoreProductKiosk; sourceTree = ""; }; @@ -415,6 +428,7 @@ DEVELOPMENT_TEAM = N94CS4N6VR; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AppleStoreProductKiosk/Resources/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -448,6 +462,7 @@ DEVELOPMENT_TEAM = N94CS4N6VR; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = AppleStoreProductKiosk/Resources/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/AppleStoreProductKiosk/Network/API/APIDomain.swift b/AppleStoreProductKiosk/Network/API/APIDomain.swift new file mode 100644 index 0000000..e95fd86 --- /dev/null +++ b/AppleStoreProductKiosk/Network/API/APIDomain.swift @@ -0,0 +1,24 @@ +// +// APIDomain.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +// MARK: - API Domain Types + +/// API 도메인 타입 정의 +/// +/// 각 도메인별 basePath를 관리하는 단일 책임을 가집니다. +enum APIDomain { + case kiosk + + var basePath: String { + switch self { + case .kiosk: + return "/api/apple-store-kiosk" + } + } +} diff --git a/AppleStoreProductKiosk/Network/API/APIEndpoint.swift b/AppleStoreProductKiosk/Network/API/APIEndpoint.swift new file mode 100644 index 0000000..825591b --- /dev/null +++ b/AppleStoreProductKiosk/Network/API/APIEndpoint.swift @@ -0,0 +1,35 @@ +// +// APIEndpoint.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +// MARK: - APIEndpoint Protocol + +/// API 엔드포인트의 공통 속성을 정의하는 프로토콜 +protocol APIEndpoint { + var baseURL: String { get } + var basePath: String { get } + var path: String { get } + var fullPath: String { get } + var fullURL: String { get } +} + +// MARK: - APIEndpoint Default Implementation + +extension APIEndpoint { + var baseURL: String { + "https://applestoreproductkiosk.free.beeceptor.com" + } + + var fullPath: String { + basePath + path + } + + var fullURL: String { + baseURL + fullPath + } +} diff --git a/AppleStoreProductKiosk/Network/API/KioskProductAPI.swift b/AppleStoreProductKiosk/Network/API/KioskProductAPI.swift new file mode 100644 index 0000000..ad044cd --- /dev/null +++ b/AppleStoreProductKiosk/Network/API/KioskProductAPI.swift @@ -0,0 +1,34 @@ +// +// KioskProductAPI.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +// MARK: - KioskAPI Namespace + +/// Kiosk API의 네임스페이스 +/// +/// Kiosk 관련 API 엔드포인트만을 관리하는 단일 책임을 가집니다. +enum KioskAPI { + + // MARK: - Product API + + /// 상품 관련 API 엔드포인트 + enum Product: APIEndpoint { + case list + + var basePath: String { + return APIDomain.kiosk.basePath + } + + var path: String { + switch self { + case .list: + return "/products" + } + } + } +} diff --git a/AppleStoreProductKiosk/Network/Core/Data/Extension+Data.swift b/AppleStoreProductKiosk/Network/Core/Data/Extension+Data.swift new file mode 100644 index 0000000..0b3d1ab --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/Data/Extension+Data.swift @@ -0,0 +1,21 @@ +// +// Extension+Data.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +/// Data 타입에 대한 JSON 디코딩 확장입니다. +extension Data { + /// Data를 지정한 Decodable 타입으로 디코딩합니다. + /// + /// - Parameter type: 디코딩할 타입 + /// - Returns: 디코딩된 객체 + /// - Throws: 디코딩 실패 시 에러 발생 + func decoded(as type: T.Type) throws -> T { + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: self) + } +} diff --git a/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift b/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift new file mode 100644 index 0000000..37f8ea5 --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/Errors/DataError.swift @@ -0,0 +1,43 @@ +// +// DataError.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +/// 네트워크 및 데이터 처리 과정에서 발생할 수 있는 오류를 정의한 enum입니다. +// NOTE: Moved to Network/Core/Errors to avoid Infra -> Data reverse dependency. +enum DataError: Error, LocalizedError, Sendable { + /// 데이터가 없음 + case noData + + /// 커스텀 에러 메시지 + case customError(String) + + /// 처리하지 않은 HTTP 상태 코드 + case unhandledStatusCode(Int) + + /// HTTP 응답 오류 (응답 객체, 에러 메시지) + case httpResponseError(HTTPURLResponse, String) + + /// 디코딩 에러 + case decodingError(Error) + + /// 사용자에게 표시할 에러 메시지 + var errorDescription: String? { + switch self { + case .noData: + return "데이터를 받지 못했습니다" + case .customError(let message): + return message + case .unhandledStatusCode(let code): + return "처리되지 않은 상태 코드: \(code)" + case .httpResponseError(let response, let message): + return "HTTP \(response.statusCode): \(message)" + case .decodingError(let error): + return "디코딩 실패: \(error.localizedDescription)" + } + } +} diff --git a/AppleStoreProductKiosk/Network/Core/Logger/URLSessionLogger.swift b/AppleStoreProductKiosk/Network/Core/Logger/URLSessionLogger.swift new file mode 100644 index 0000000..f1f8614 --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/Logger/URLSessionLogger.swift @@ -0,0 +1,113 @@ +// +// URLSessionLogger.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation +import LogMacro + +/// URLSession 네트워크 요청/응답을 콘솔에 로그로 출력하는 싱글턴 유틸리티입니다. +/// +/// - DEBUG 모드에서만 동작하며, 요청/응답의 메서드, URL, 헤더, 바디, 에러 등을 보기 좋게 출력합니다. +final class URLSessionLogger { + /// 싱글턴 인스턴스 + static let shared = URLSessionLogger() + + /// 외부에서 직접 생성하지 못하도록 private init + private init() {} + + // MARK: - 요청 로그 + + /// 네트워크 요청 정보를 콘솔에 출력합니다. + /// - Parameter request: 로깅할 URLRequest + @MainActor + func logRequest(_ request: URLRequest) { +#if DEBUG + Log.debug("⎡--------------------- REQUEST ---------------------⎤") + + // HTTP 메서드 + if let method = request.httpMethod { + Log.network("[Method]", method) + } + + // 요청 URL + if let url = request.url?.absoluteString { + Log.network("[URL]", url) + } + + // 헤더 + if let headers = request.allHTTPHeaderFields { + Log.network("[Headers]") + for (key, value) in headers { + Log.network(" \(key): \(value)") + } + } + + // 바디 + if let body = request.httpBody, + let bodyString = String(data: body, encoding: .utf8) { + Log.network("[Body]") + Log.network(" \(bodyString)") + } + + Log.network("⎣------------------ END REQUEST --------------------⎦") +#endif + } + + // MARK: - 응답 로그 + + /// 네트워크 응답 정보를 콘솔에 출력합니다. + /// - Parameters: + /// - data: 응답 데이터 + /// - response: URLResponse 객체 + /// - error: 네트워크 에러 + @MainActor + func logResponse(data: Data?, response: URLResponse?, error: Error?) { +#if DEBUG + Log.network("⎡--------------------- RESPONSE --------------------⎤") + + // 응답 URL + if let url = response?.url?.absoluteString { + Log.network("[URL]", url) + } + + // HTTP 상태 코드 및 헤더 + if let httpResponse = response as? HTTPURLResponse { + Log.network("[Status Code] \(httpResponse.statusCode)") + Log.network("[Response Headers]") + for (key, value) in httpResponse.allHeaderFields { + Log.network(" \(key): \(value)") + } + } + + // 바디 (JSON pretty print 시도, 실패 시 raw 출력) + if let data = data { + do { + let json = try JSONSerialization.jsonObject(with: data, options: []) + let prettyData = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted]) + if let prettyString = String(data: prettyData, encoding: .utf8) { + Log.network("[Body (Map)]") + Log.network(prettyString) + } + } catch { + // JSON 디코딩 실패 시 원본 문자열 출력 + if let rawString = String(data: data, encoding: .utf8) { + Log.network("[Body (Raw)]") + Log.network(rawString) + } else { + Log.network("[Body] Cannot decode data") + } + } + } + + // 에러 + if let error = error { + Log.network("[Error] \(error.localizedDescription)") + } + + Log.network("⎣------------------ END RESPONSE -------------------⎦") +#endif + } +} diff --git a/AppleStoreProductKiosk/Network/Core/Method/HTTPMethod.swift b/AppleStoreProductKiosk/Network/Core/Method/HTTPMethod.swift new file mode 100644 index 0000000..e023d44 --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/Method/HTTPMethod.swift @@ -0,0 +1,21 @@ +// +// HTTPMethod.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +/// HTTP 요청에서 사용되는 메서드 타입을 정의한 enum입니다. +enum HTTPMethod: String { + /// GET: 데이터 조회 + case get = "GET" + /// POST: 데이터 생성 + case post = "POST" + /// PUT: 데이터 전체 수정 + case put = "PUT" + /// DELETE: 데이터 삭제 + case delete = "DELETE" +} + diff --git a/AppleStoreProductKiosk/Network/Core/Provider/AsyncProvider.swift b/AppleStoreProductKiosk/Network/Core/Provider/AsyncProvider.swift new file mode 100644 index 0000000..318e66e --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/Provider/AsyncProvider.swift @@ -0,0 +1,253 @@ +// +// AsyncProvider.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation +import LogMacro + +/// 개선된 비동기 네트워크 Provider +/// +/// SOLID 원칙을 준수하며 책임을 분리한 네트워크 요청 처리 클래스입니다. +/// - 설정 분리: NetworkConfig +/// - 재시도 전략 분리: RetryStrategy +/// - 응답 처리 분리: ResponseHandler +/// - 재시도 설정 분리: RetryConfig +/// +/// ## 사용 예시: +/// ```swift +/// // 1. 기본 설정 (보수적 재시도 - 500 에러 제외) +/// let provider = AsyncProvider() +/// +/// // 2. 공격적 재시도 (500 에러 포함) +/// let aggressiveProvider = AsyncProvider.aggressive() +/// +/// // 3. 재시도 없음 +/// let noRetryProvider = AsyncProvider.noRetry() +/// +/// // 4. 커스텀 설정 +/// let customProvider = AsyncProvider( +/// retryConfig: .aggressive.addingStatusCodes(408) +/// ) +/// ``` +final class AsyncProvider { + + // MARK: - Properties + + /// 사용할 URLSession + private let session: URLSession + + /// 네트워크 설정 + private let config: NetworkConfig + + /// 재시도 전략 + private let retryStrategy: RetryStrategy + + /// 응답 처리기 + private let responseHandler: ResponseHandler + + // MARK: - Initialization + + /// Provider 초기화 + /// - Parameters: + /// - session: URLSession (기본값: .shared) + /// - config: 네트워크 설정 (기본값: .default) + /// - retryConfig: 재시도 설정 (기본값: .default) + /// - retryStrategy: 재시도 전략 (기본값: nil, retryConfig 기반으로 자동 생성) + /// - responseHandler: 응답 처리기 (기본값: DefaultResponseHandler) + init( + session: URLSession = .shared, + config: NetworkConfig = .default, + retryConfig: RetryConfig = .default, + retryStrategy: RetryStrategy? = nil, + responseHandler: ResponseHandler = DefaultResponseHandler() + ) { + self.session = session + self.config = config + self.retryStrategy = retryStrategy ?? DefaultRetryStrategy(retryConfig: retryConfig) + self.responseHandler = responseHandler + } + + // MARK: - Convenience Initializers + + /// 공격적 재시도 전략으로 Provider 생성 (500 에러도 재시도) + /// - Parameters: + /// - session: URLSession (기본값: .shared) + /// - config: 네트워크 설정 (기본값: .default) + /// - responseHandler: 응답 처리기 (기본값: DefaultResponseHandler) + static func aggressive( + session: URLSession = .shared, + config: NetworkConfig = .default, + responseHandler: ResponseHandler = DefaultResponseHandler() + ) -> AsyncProvider { + return AsyncProvider( + session: session, + config: config, + retryConfig: .aggressive, + responseHandler: responseHandler + ) + } + + /// 보수적 재시도 전략으로 Provider 생성 (500 에러 제외) + /// - Parameters: + /// - session: URLSession (기본값: .shared) + /// - config: 네트워크 설정 (기본값: .default) + /// - responseHandler: 응답 처리기 (기본값: DefaultResponseHandler) + static func conservative( + session: URLSession = .shared, + config: NetworkConfig = .default, + responseHandler: ResponseHandler = DefaultResponseHandler() + ) -> AsyncProvider { + return AsyncProvider( + session: session, + config: config, + retryConfig: .conservative, + responseHandler: responseHandler + ) + } + + /// 최소 재시도 전략으로 Provider 생성 + /// - Parameters: + /// - session: URLSession (기본값: .shared) + /// - config: 네트워크 설정 (기본값: .default) + /// - responseHandler: 응답 처리기 (기본값: DefaultResponseHandler) + static func minimal( + session: URLSession = .shared, + config: NetworkConfig = .default, + responseHandler: ResponseHandler = DefaultResponseHandler() + ) -> AsyncProvider { + return AsyncProvider( + session: session, + config: config, + retryConfig: .minimal, + responseHandler: responseHandler + ) + } + + /// 재시도 없는 Provider 생성 + /// - Parameters: + /// - session: URLSession (기본값: .shared) + /// - config: 네트워크 설정 (기본값: .default) + /// - responseHandler: 응답 처리기 (기본값: DefaultResponseHandler) + static func noRetry( + session: URLSession = .shared, + config: NetworkConfig = .default, + responseHandler: ResponseHandler = DefaultResponseHandler() + ) -> AsyncProvider { + return AsyncProvider( + session: session, + config: config, + retryConfig: .none, + responseHandler: responseHandler + ) + } + + // MARK: - Public Methods + + /// 비동기 네트워크 요청을 실행하고 응답을 디코딩합니다. + /// + /// - Parameters: + /// - target: API 요청 정보를 담은 TargetType + /// - type: 디코딩할 Decodable 타입 + /// - Returns: 디코딩된 객체 + /// - Throws: 네트워크/디코딩/상태코드 에러 등 + func requestAsync( + _ target: T, + decodeTo type: D.Type + ) async throws -> D { + let request = URLRequestBuilder.buildRequest(from: target) + + // 요청 로깅 + await URLSessionLogger.shared.logRequest(request) + + return try await executeWithRetry( + request: request, + decodeTo: type, + attemptCount: 0 + ) + } + + // MARK: - Private Methods + + /// 재시도 로직을 포함한 네트워크 요청 실행 + private func executeWithRetry( + request: URLRequest, + decodeTo type: D.Type, + attemptCount: Int + ) async throws -> D { + do { + // 네트워크 요청 실행 + let (data, response) = try await session.data(for: request) + + // 응답 로깅 + await URLSessionLogger.shared.logResponse( + data: data, + response: response, + error: nil + ) + + // 응답 처리 및 디코딩 + return try responseHandler.handle(data: data, response: response) + + } catch { + // 에러 로깅 + await URLSessionLogger.shared.logResponse( + data: nil, + response: nil, + error: error + ) + + Log.error("Network request failed (attempt \(attemptCount + 1)): \(error.localizedDescription)") + + // 재시도 여부 판단 + let statusCode = extractStatusCode(from: error) + let shouldRetry = retryStrategy.shouldRetry( + statusCode: statusCode, + error: error, + attemptCount: attemptCount, + maxRetryCount: config.maxRetryCount + ) + + if shouldRetry { + // 재시도 대기 + let delay = retryStrategy.delayForRetry( + attemptCount: attemptCount, + baseDelay: config.baseRetryDelay + ) + + Log.info("Retrying request after \(delay) seconds (attempt \(attemptCount + 2))") + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + + // 재귀 호출로 재시도 + return try await executeWithRetry( + request: request, + decodeTo: type, + attemptCount: attemptCount + 1 + ) + } else { + // 재시도 불가능한 경우 원래 에러 던지기 + Log.error("Request failed permanently after \(attemptCount + 1) attempts") + throw error + } + } + } + + /// 에러에서 상태 코드 추출 (있는 경우) + private func extractStatusCode(from error: Error) -> Int? { + if let dataError = error as? DataError { + switch dataError { + case .unhandledStatusCode(let code): + return code + default: + return nil + } + } + return nil + } +} + +// MARK: - Sendable Conformance + +extension AsyncProvider: @unchecked Sendable {} \ No newline at end of file diff --git a/AppleStoreProductKiosk/Network/Core/Provider/NetworkConfig.swift b/AppleStoreProductKiosk/Network/Core/Provider/NetworkConfig.swift new file mode 100644 index 0000000..f0f5c1a --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/Provider/NetworkConfig.swift @@ -0,0 +1,45 @@ +// +// NetworkConfig.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +// MARK: - Network Configuration + +/// 네트워크 관련 설정을 관리하는 구조체 +/// +/// 하드코딩된 설정값들을 분리하여 유연성과 테스트 용이성을 제공합니다. +struct NetworkConfig { + /// 최대 재시도 횟수 + let maxRetryCount: Int + + /// 기본 재시도 간격(초) - Exponential Backoff의 기본값 + let baseRetryDelay: TimeInterval + + /// 요청 타임아웃 시간(초) + let timeoutInterval: TimeInterval + + /// 기본 설정값으로 초기화 + static let `default` = NetworkConfig( + maxRetryCount: 3, + baseRetryDelay: 1.0, + timeoutInterval: 30.0 + ) + + /// 개발/테스트용 빠른 설정 + static let fastConfig = NetworkConfig( + maxRetryCount: 2, + baseRetryDelay: 0.5, + timeoutInterval: 10.0 + ) + + /// 프로덕션용 안정적 설정 + static let productionConfig = NetworkConfig( + maxRetryCount: 5, + baseRetryDelay: 2.0, + timeoutInterval: 60.0 + ) +} \ No newline at end of file diff --git a/AppleStoreProductKiosk/Network/Core/Provider/ResponseHandler.swift b/AppleStoreProductKiosk/Network/Core/Provider/ResponseHandler.swift new file mode 100644 index 0000000..ec3ce0c --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/Provider/ResponseHandler.swift @@ -0,0 +1,153 @@ +// +// ResponseHandler.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation +import LogMacro + +// MARK: - Response Handler Protocol + +/// HTTP 응답 처리를 담당하는 프로토콜 +/// +/// 상태 코드 검증, 데이터 디코딩 등의 응답 처리 책임을 분리합니다. +protocol ResponseHandler { + /// HTTP 응답을 처리하고 디코딩합니다 + /// - Parameters: + /// - data: 응답 데이터 + /// - response: URLResponse + /// - Returns: 디코딩된 객체 + /// - Throws: 상태 코드, 디코딩 에러 등 + func handle( + data: Data, + response: URLResponse + ) throws -> T +} + +// MARK: - Default Response Handler + +/// 기본 응답 처리 구현체 +struct DefaultResponseHandler: ResponseHandler { + + func handle( + data: Data, + response: URLResponse + ) throws -> T { + guard let httpResponse = response as? HTTPURLResponse else { + Log.error("No HTTP response received") + throw DataError.noData + } + + let statusCode = httpResponse.statusCode + Log.debug("Received HTTP status code: \(statusCode)") + + // 상태 코드별 처리 + switch statusCode { + case 200...299: + return try decodeSuccessResponse(data: data, type: T.self) + + case 400: + Log.error("Bad Request (400) for URL: \(response.url?.absoluteString ?? "No URL")") + throw DataError.customError("Bad Request (400)") + + case 401: + Log.error("Unauthorized (401) - Invalid credentials") + throw DataError.customError("Unauthorized (401)") + + case 403: + Log.error("Forbidden (403) - Access denied") + throw DataError.customError("Forbidden (403)") + + case 404: + Log.error("Not Found (404) for URL: \(response.url?.absoluteString ?? "No URL")") + throw DataError.customError("Not Found (404)") + + case 429: + Log.error("Too Many Requests (429) - Rate limited") + throw DataError.customError("Too Many Requests (429)") + + case 500...599: + Log.error("Server Error (\(statusCode))") + throw DataError.unhandledStatusCode(statusCode) + + default: + Log.error("Unhandled status code: \(statusCode)") + throw DataError.unhandledStatusCode(statusCode) + } + } + + // MARK: - Private Methods + + /// 성공 응답 디코딩 + private func decodeSuccessResponse( + data: Data, + type: T.Type + ) throws -> T { + do { + let decodedData = try data.decoded(as: T.self) + Log.debug("Successfully decoded response as \(T.self)") + return decodedData + } catch { + Log.error("Failed to decode response: \(error.localizedDescription)") + throw DataError.decodingError(error) + } + } +} + +// MARK: - Lenient Response Handler + +/// 관대한 응답 처리 구현체 (500 에러에서도 디코딩 시도) +struct LenientResponseHandler: ResponseHandler { + + func handle( + data: Data, + response: URLResponse + ) throws -> T { + guard let httpResponse = response as? HTTPURLResponse else { + Log.error("No HTTP response received") + throw DataError.noData + } + + let statusCode = httpResponse.statusCode + + switch statusCode { + case 200...299: + return try decodeResponse(data: data, type: T.self) + + case 400...499: + Log.error("Client Error (\(statusCode))") + throw DataError.unhandledStatusCode(statusCode) + + case 500...599: + Log.error("Server Error (\(statusCode)), attempting to decode response") + // 500 에러에서도 디코딩 시도 (서버가 에러와 함께 데이터를 보낼 수 있음) + if let decodedData = try? decodeResponse(data: data, type: T.self) { + Log.debug("Successfully decoded response despite server error") + return decodedData + } else { + Log.error("Failed to decode server error response") + throw DataError.unhandledStatusCode(statusCode) + } + + default: + Log.error("Unhandled status code: \(statusCode)") + throw DataError.unhandledStatusCode(statusCode) + } + } + + // MARK: - Private Methods + + private func decodeResponse( + data: Data, + type: T.Type + ) throws -> T { + do { + return try data.decoded(as: T.self) + } catch { + Log.error("Decoding failed: \(error.localizedDescription)") + throw DataError.decodingError(error) + } + } +} \ No newline at end of file diff --git a/AppleStoreProductKiosk/Network/Core/Provider/RetryConfig.swift b/AppleStoreProductKiosk/Network/Core/Provider/RetryConfig.swift new file mode 100644 index 0000000..9276ddf --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/Provider/RetryConfig.swift @@ -0,0 +1,142 @@ +// +// RetryConfig.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +// MARK: - Retry Configuration + +/// 재시도 관련 설정을 관리하는 구조체 +/// +/// 어떤 상태 코드와 에러에서 재시도할지 설정 가능합니다. +struct RetryConfig { + /// 재시도 가능한 HTTP 상태 코드들 + let retryableStatusCodes: Set + + /// 재시도 가능한 URLError 코드들 + let retryableURLErrorCodes: Set + + /// 커스텀 초기화 + /// - Parameters: + /// - retryableStatusCodes: 재시도할 HTTP 상태 코드 집합 + /// - retryableURLErrorCodes: 재시도할 URLError 코드 집합 + init( + retryableStatusCodes: Set = [], + retryableURLErrorCodes: Set = [] + ) { + self.retryableStatusCodes = retryableStatusCodes + self.retryableURLErrorCodes = retryableURLErrorCodes + } +} + +// MARK: - Predefined Configurations + +extension RetryConfig { + + /// 공격적 재시도 설정 (500 포함) + /// - 일시적 문제일 가능성이 있는 모든 에러에서 재시도 + /// - 개발/테스트 환경에 적합 + static let aggressive = RetryConfig( + retryableStatusCodes: [ + 500, // Internal Server Error - 서버 내부 오류 + 502, // Bad Gateway - 게이트웨이 오류 + 503, // Service Unavailable - 서비스 이용 불가 + 504, // Gateway Timeout - 게이트웨이 타임아웃 + 429 // Too Many Requests - 요청 과다 + ], + retryableURLErrorCodes: [ + .timedOut, // 타임아웃 + .networkConnectionLost, // 네트워크 연결 끊김 + .notConnectedToInternet, // 인터넷 연결 없음 + .cannotConnectToHost // 호스트 연결 불가 + ] + ) + + /// 보수적 재시도 설정 (500 제외) + /// - 명확히 일시적인 문제로 판단되는 경우만 재시도 + /// - 프로덕션 환경에 적합 + static let conservative = RetryConfig( + retryableStatusCodes: [ + 502, // Bad Gateway - 게이트웨이 문제 + 503, // Service Unavailable - 서버 과부하/점검 + 504, // Gateway Timeout - 게이트웨이 타임아웃 + 429 // Too Many Requests - Rate limiting + ], + retryableURLErrorCodes: [ + .timedOut, // 타임아웃만 + .networkConnectionLost // 네트워크 연결 끊김만 + ] + ) + + /// 최소 재시도 설정 + /// - 네트워크 타임아웃과 서비스 이용 불가만 재시도 + /// - 매우 안정적인 서비스에 적합 + static let minimal = RetryConfig( + retryableStatusCodes: [ + 503, // Service Unavailable만 + 429 // Rate limiting만 + ], + retryableURLErrorCodes: [ + .timedOut // 타임아웃만 + ] + ) + + /// 재시도 없음 + /// - 모든 에러에서 즉시 실패 + /// - 디버깅이나 특수한 경우에 사용 + static let none = RetryConfig( + retryableStatusCodes: [], + retryableURLErrorCodes: [] + ) + + /// 기본 설정 (보수적 설정 사용) + static let `default` = conservative +} + +// MARK: - Convenience Methods + +extension RetryConfig { + + /// 주어진 상태 코드가 재시도 가능한지 확인 + /// - Parameter statusCode: HTTP 상태 코드 + /// - Returns: 재시도 가능 여부 + func shouldRetryStatusCode(_ statusCode: Int) -> Bool { + return retryableStatusCodes.contains(statusCode) + } + + /// 주어진 URLError가 재시도 가능한지 확인 + /// - Parameter error: URLError + /// - Returns: 재시도 가능 여부 + func shouldRetryURLError(_ error: URLError) -> Bool { + return retryableURLErrorCodes.contains(error.code) + } + + /// 재시도 가능한 상태 코드를 추가한 새로운 설정 반환 + /// - Parameter statusCodes: 추가할 상태 코드들 + /// - Returns: 새로운 RetryConfig 인스턴스 + func addingStatusCodes(_ statusCodes: Int...) -> RetryConfig { + var newStatusCodes = retryableStatusCodes + statusCodes.forEach { newStatusCodes.insert($0) } + + return RetryConfig( + retryableStatusCodes: newStatusCodes, + retryableURLErrorCodes: retryableURLErrorCodes + ) + } + + /// 재시도 가능한 상태 코드를 제거한 새로운 설정 반환 + /// - Parameter statusCodes: 제거할 상태 코드들 + /// - Returns: 새로운 RetryConfig 인스턴스 + func removingStatusCodes(_ statusCodes: Int...) -> RetryConfig { + var newStatusCodes = retryableStatusCodes + statusCodes.forEach { newStatusCodes.remove($0) } + + return RetryConfig( + retryableStatusCodes: newStatusCodes, + retryableURLErrorCodes: retryableURLErrorCodes + ) + } +} \ No newline at end of file diff --git a/AppleStoreProductKiosk/Network/Core/Provider/RetryStrategy.swift b/AppleStoreProductKiosk/Network/Core/Provider/RetryStrategy.swift new file mode 100644 index 0000000..3750c46 --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/Provider/RetryStrategy.swift @@ -0,0 +1,132 @@ +// +// RetryStrategy.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +// MARK: - Retry Strategy Protocol + +/// 재시도 전략을 정의하는 프로토콜 +/// +/// 언제 재시도할지와 얼마나 대기할지를 결정하는 책임을 분리합니다. +protocol RetryStrategy { + /// 주어진 상황에서 재시도를 해야 하는지 판단 + /// - Parameters: + /// - statusCode: HTTP 상태 코드 + /// - error: 발생한 에러 (옵셔널) + /// - attemptCount: 현재 시도 횟수 + /// - maxRetryCount: 최대 재시도 횟수 + /// - Returns: 재시도 여부 + func shouldRetry( + statusCode: Int?, + error: Error?, + attemptCount: Int, + maxRetryCount: Int + ) -> Bool + + /// 재시도 전 대기 시간을 계산 + /// - Parameters: + /// - attemptCount: 현재 시도 횟수 + /// - baseDelay: 기본 대기 시간 + /// - Returns: 실제 대기 시간 + func delayForRetry(attemptCount: Int, baseDelay: TimeInterval) -> TimeInterval +} + +// MARK: - Default Retry Strategy + +/// 기본 재시도 전략 구현체 +struct DefaultRetryStrategy: RetryStrategy { + + /// 재시도 설정 + private let retryConfig: RetryConfig + + /// 초기화 + /// - Parameter retryConfig: 재시도 설정 (기본값: .default) + init(retryConfig: RetryConfig = .default) { + self.retryConfig = retryConfig + } + + func shouldRetry( + statusCode: Int?, + error: Error?, + attemptCount: Int, + maxRetryCount: Int + ) -> Bool { + // 최대 재시도 횟수 초과 체크 + guard attemptCount < maxRetryCount else { return false } + + // 상태 코드 기반 재시도 판단 + if let statusCode = statusCode { + return retryConfig.shouldRetryStatusCode(statusCode) + } + + // 네트워크 에러 기반 재시도 판단 + if let error = error { + return shouldRetryForError(error) + } + + return false + } + + func delayForRetry(attemptCount: Int, baseDelay: TimeInterval) -> TimeInterval { + // Exponential Backoff: 2^attemptCount * baseDelay + let exponentialDelay = pow(2.0, Double(attemptCount)) * baseDelay + + // 최대 30초로 제한 (무한 증가 방지) + return min(exponentialDelay, 30.0) + } + + // MARK: - Private Methods + + /// 에러 타입별 재시도 가능 여부 판단 + private func shouldRetryForError(_ error: Error) -> Bool { + if let urlError = error as? URLError { + return retryConfig.shouldRetryURLError(urlError) + } + + return false + } +} + +// MARK: - Conservative Retry Strategy + +/// 보수적 재시도 전략 (최소한의 재시도만) +struct ConservativeRetryStrategy: RetryStrategy { + + /// 재시도 설정 + private let retryConfig: RetryConfig + + /// 초기화 + /// - Parameter retryConfig: 재시도 설정 (기본값: .conservative) + init(retryConfig: RetryConfig = .conservative) { + self.retryConfig = retryConfig + } + + func shouldRetry( + statusCode: Int?, + error: Error?, + attemptCount: Int, + maxRetryCount: Int + ) -> Bool { + guard attemptCount < maxRetryCount else { return false } + + // RetryConfig 기반 재시도 판단 + if let statusCode = statusCode { + return retryConfig.shouldRetryStatusCode(statusCode) + } + + if let error = error, let urlError = error as? URLError { + return retryConfig.shouldRetryURLError(urlError) + } + + return false + } + + func delayForRetry(attemptCount: Int, baseDelay: TimeInterval) -> TimeInterval { + // 고정된 대기 시간 (Exponential 없음) + return baseDelay + } +} \ No newline at end of file diff --git a/AppleStoreProductKiosk/Network/Core/TargetType/BaseTargetType.swift b/AppleStoreProductKiosk/Network/Core/TargetType/BaseTargetType.swift new file mode 100644 index 0000000..29836af --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/TargetType/BaseTargetType.swift @@ -0,0 +1,58 @@ +// +// BaseTargetType.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +/// API 타겟의 공통 속성을 정의하는 프로토콜입니다. +/// +/// - 도메인, URL 경로, 파라미터 등 API 요청에 필요한 정보를 제공합니다. +protocol BaseTargetType: TargetType { + /// API 엔드포인트 + var endpoint: any APIEndpoint { get } + /// 요청 파라미터 + var parameters:[String: Any]? { get } +} + +// MARK: - BaseTargetType 기본 구현 + + +extension BaseTargetType { + /// API의 baseURL + var baseURL: URL { + return URL(string: endpoint.baseURL)! + } + + /// 전체 path + var path: String { + return endpoint.fullPath + } + + /// 기본 헤더 (인증 없는 헤더 사용) + var headers: [String : String]? { + return APIHeader.baseHeader + } + + /// 네트워크 요청 Task (파라미터 유무/메서드에 따라 인코딩 방식 분기) + var task: NetworkTask { + if let parameters = parameters { + if method == .get { + return .requestParameters( + parameters: parameters, + encoding: .url + ) + } else { + return .requestParameters( + parameters: parameters, + encoding: .json + ) + } + } else { + return .requestPlain + } + } +} + diff --git a/AppleStoreProductKiosk/Network/Core/TargetType/CustomParameterEncoding.swift b/AppleStoreProductKiosk/Network/Core/TargetType/CustomParameterEncoding.swift new file mode 100644 index 0000000..c4e6c58 --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/TargetType/CustomParameterEncoding.swift @@ -0,0 +1,49 @@ +// +// CustomParameterEncoding.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +/// URLRequest의 파라미터 인코딩 방식을 정의하는 enum입니다. +/// +/// - url: 쿼리 파라미터로 인코딩 (GET 등) +/// - json: HTTP Body에 JSON으로 인코딩 (POST, PUT 등) +enum CustomParameterEncoding { + case url + case json + + /// 파라미터를 주어진 URLRequest에 인코딩하여 반환합니다. + /// + /// - Parameters: + /// - request: 인코딩할 URLRequest + /// - parameters: 인코딩할 파라미터 딕셔너리 + /// - Returns: 파라미터가 인코딩된 URLRequest + /// - Throws: 인코딩 실패 시 에러(DataError) + func encode(_ request: URLRequest, with parameters: [String: Any]) throws -> URLRequest { + var request = request + switch self { + case .url: + // URL 쿼리 파라미터로 인코딩 + guard let url = request.url else { + throw DataError.customError("Invalid URL") + } + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.queryItems = parameters + .map { URLQueryItem(name: $0.key, value: "\($0.value)") } + request.url = components?.url + case .json: + // HTTP Body에 JSON으로 인코딩 + let jsonData = try JSONSerialization.data( + withJSONObject: parameters, + options: [] + ) + request.httpBody = jsonData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return request + } +} + diff --git a/AppleStoreProductKiosk/Network/Core/TargetType/NetworkTask.swift b/AppleStoreProductKiosk/Network/Core/TargetType/NetworkTask.swift new file mode 100644 index 0000000..3327169 --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/TargetType/NetworkTask.swift @@ -0,0 +1,29 @@ +// +// NetworkTask.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +/// 네트워크 요청 시 사용할 파라미터 인코딩 및 전송 방식을 정의하는 enum입니다. +enum NetworkTask { + /// 파라미터 없이 단순 요청 (GET, DELETE 등) + case requestPlain + + /// 파라미터와 인코딩 방식이 있는 요청 (주로 GET, POST 등) + case requestParameters( + parameters: [String: Any], + encoding: CustomParameterEncoding + ) + + /// URL 파라미터와 Body 파라미터를 모두 포함하는 복합 요청 + /// (예: 쿼리 + 바디 동시 필요 시) + case requestCompositeParameters( + bodyParameters: [String: Any], + bodyEncoding: CustomParameterEncoding, + urlParameters: [String: Any] + ) +} + diff --git a/AppleStoreProductKiosk/Network/Core/TargetType/TargetType.swift b/AppleStoreProductKiosk/Network/Core/TargetType/TargetType.swift new file mode 100644 index 0000000..83a9b95 --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/TargetType/TargetType.swift @@ -0,0 +1,31 @@ +// +// TargetType.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + + +/// 네트워크 API 요청을 추상화하는 프로토콜입니다. +/// +/// - 각 API 엔드포인트별로 이 프로토콜을 채택한 enum/struct를 만들면 +/// 공통적인 네트워크 요청 빌드가 가능합니다. +protocol TargetType { + /// 기본 도메인 URL (예: https://api.example.com) + var baseURL: URL { get } + + /// 엔드포인트 경로 (예: "/books/search") + var path: String { get } + + /// HTTP 메서드 (GET, POST 등) + var method: HTTPMethod { get } + + /// HTTP 헤더 (필요 시) + var headers: [String: String]? { get } + + /// 파라미터 및 인코딩 방식 등 요청 작업 + var task: NetworkTask { get } +} + diff --git a/AppleStoreProductKiosk/Network/Core/URLRequestBuilder/URLRequestBuilder.swift b/AppleStoreProductKiosk/Network/Core/URLRequestBuilder/URLRequestBuilder.swift new file mode 100644 index 0000000..5b1dc2b --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/URLRequestBuilder/URLRequestBuilder.swift @@ -0,0 +1,64 @@ +// +// URLRequestBuilder.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation +import LogMacro + +/// TargetType을 기반으로 URLRequest를 생성하는 빌더 클래스입니다. +/// +/// - 파라미터 인코딩, 헤더 설정, URL 조합 등 네트워크 요청 생성의 모든 과정을 담당합니다. +class URLRequestBuilder { + /// 기본 생성자 + public init() {} + + /// TargetType을 받아 URLRequest를 생성합니다. + /// + /// - Parameter target: 네트워크 요청 정보가 담긴 TargetType + /// - Returns: 완성된 URLRequest + static func buildRequest(from target: TargetType) -> URLRequest { + // Base URL과 path를 안전하게 결합 (슬래시 이스케이프 이슈 방지) + let base = target.baseURL + var components = URLComponents(url: base, resolvingAgainstBaseURL: false) + components?.path = target.path.trimmingCharacters(in: .whitespaces) + var request = URLRequest(url: components?.url ?? base) + request.httpMethod = target.method.rawValue + + // 헤더 추가 + if let headers = target.headers { + headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } + } + + // 파라미터 및 인코딩 처리 + switch target.task { + case .requestParameters(let parameters, let encoding): + do { + request = try encoding.encode(request, with: parameters) + } catch { + Log.error("Failed to encode parameters: %{public}@", error.localizedDescription) + } + case .requestCompositeParameters( + let bodyParameters, + let bodyEncoding, + let urlParameters + ): + do { + components?.queryItems = urlParameters + .map { URLQueryItem(name: $0.key, value: "\($0.value)") } + request.url = components?.url + request = try bodyEncoding.encode(request, with: bodyParameters) + } catch { + Log.error("Failed to encode composite parameters: %{public}@", error.localizedDescription) + } + case .requestPlain: + // 파라미터 없이 단순 요청 + components?.queryItems = nil + request.url = components?.url + } + + return request + } +} diff --git a/AppleStoreProductKiosk/Network/Core/header/APIHeader.swift b/AppleStoreProductKiosk/Network/Core/header/APIHeader.swift new file mode 100644 index 0000000..16239d8 --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/header/APIHeader.swift @@ -0,0 +1,27 @@ +// +// APIHeader.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +// API 요청에 사용되는 공통 헤더 키를 정의하는 구조체입니다. +enum APIHeader { + /// Content-Type 헤더 키 + static let contentType = "Content-Type" + +} + + +extension APIHeader { + + /// 인증 토큰이 필요 없는 기본 헤더 + public static var baseHeader: [String: String] { + [ + contentType: APIHeaderManager.contentType + ] + } + +} diff --git a/AppleStoreProductKiosk/Network/Core/header/APIHeaderManager.swift b/AppleStoreProductKiosk/Network/Core/header/APIHeaderManager.swift new file mode 100644 index 0000000..613c875 --- /dev/null +++ b/AppleStoreProductKiosk/Network/Core/header/APIHeaderManager.swift @@ -0,0 +1,16 @@ +// +// APIHeaderManager.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + +import Foundation + +/// API 헤더의 값(컨텐츠 타입 등)을 관리하는 enum입니다. +enum APIHeaderManager { + /// 앱 패키지명(필요 시 사용, 기본값 "-") + static let appPackageName: String = "-" + /// Content-Type 값 (기본: application/json) + static let contentType: String = "application/json" +} diff --git a/AppleStoreProductKiosk/Network/Service/KioskProductService.swift b/AppleStoreProductKiosk/Network/Service/KioskProductService.swift new file mode 100644 index 0000000..8a15ef4 --- /dev/null +++ b/AppleStoreProductKiosk/Network/Service/KioskProductService.swift @@ -0,0 +1,37 @@ +// +// KioskProductService.swift +// AppleStoreProductKiosk +// +// Created by Wonji Suh on 9/16/25. +// + + +import Foundation + + +enum KioskProductService { + case getAllProducts +} + +extension KioskProductService: BaseTargetType { + var endpoint: any APIEndpoint { + switch self { + case .getAllProducts: + return KioskAPI.Product.list + } + } + + var parameters: [String : Any]? { + switch self { + case .getAllProducts: + return nil + } + } + + var method: HTTPMethod { + switch self { + case .getAllProducts: + return .get + } + } +} diff --git a/AppleStoreProductKiosk/Resources/Info.plist b/AppleStoreProductKiosk/Resources/Info.plist new file mode 100644 index 0000000..6a6654d --- /dev/null +++ b/AppleStoreProductKiosk/Resources/Info.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + +