diff --git a/Projects/DataSource/Sources/NetworkService/NetworkError.swift b/Projects/DataSource/Sources/NetworkService/NetworkError.swift index 73b823ef..da63d40c 100644 --- a/Projects/DataSource/Sources/NetworkService/NetworkError.swift +++ b/Projects/DataSource/Sources/NetworkService/NetworkError.swift @@ -5,12 +5,15 @@ // Created by 최정인 on 6/23/25. // -enum NetworkError: Error, CustomStringConvertible { +enum NetworkError: Error, CustomStringConvertible, Comparable { case invalidURL case invalidResponse case invalidStatusCode(statusCode: Int) case emptyData case decodingError + case needRetry + case noRetry + case unknown(description: String) var description: String { switch self { @@ -24,6 +27,12 @@ enum NetworkError: Error, CustomStringConvertible { return "emptyData" case .decodingError: return "decodingError" + case .needRetry: + return "request need retry" + case .noRetry: + return "request don't need retry" + case .unknown(let description): + return "unknown error: \(description)" } } } diff --git a/Projects/DataSource/Sources/NetworkService/NetworkService.swift b/Projects/DataSource/Sources/NetworkService/NetworkService.swift index 55d846c5..bbb11144 100644 --- a/Projects/DataSource/Sources/NetworkService/NetworkService.swift +++ b/Projects/DataSource/Sources/NetworkService/NetworkService.swift @@ -11,17 +11,70 @@ import Shared final class NetworkService { static let shared = NetworkService() private let decoder = JSONDecoder() + private let plugins: [NetworkPlugin] + private let maxRetryCount = 1 - private init() { } + private init() { + plugins = [ + TokenInjectionPlugin(), + RefreshTokenPlugin(), + RetryPlugin()] + } + + func request( + endpoint: Endpoint, + type: T.Type, + withPlugins: Bool = true + ) async throws -> T? { + var retryCount = 0 - func request(endpoint: Endpoint, type: T.Type) async throws -> T? { + while true { + do { + return try await performRequest( + endpoint: endpoint, + type: type, + withPlugins: withPlugins) + } catch let error as NetworkError { + guard + error == .needRetry, + retryCount < maxRetryCount + else { throw error } + + retryCount += 1 + continue + } + } + } + + private func performRequest( + endpoint: Endpoint, + type: T.Type, + withPlugins: Bool = true + ) async throws -> T? { var request = try endpoint.makeURLRequest() - if endpoint.isAuthorized { - let accessToken = try TokenManager.shared.loadToken(tokenType: .accessToken) - request.headers["Authorization"] = "Bearer \(accessToken)" + + if withPlugins { + for plugin in plugins { + request = try await plugin.willSend(request: request, endpoint: endpoint) + } } + let (data, response) = try await URLSession.shared.data(for: request) + // TODO: - 로깅 로직 수정 + if let httpResponse = response as? HTTPURLResponse { + BitnagilLogger.log(logType: .info, message: "응답 코드: \(httpResponse.statusCode)") + } + + if withPlugins { + for plugin in plugins { + try await plugin.didReceive( + response: response, + data: data, + endpoint: endpoint) + } + } + guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.invalidResponse } @@ -39,7 +92,7 @@ final class NetworkService { guard let responseDTO = baseResponse.data else { return nil } - + return responseDTO } catch { throw NetworkError.decodingError diff --git a/Projects/DataSource/Sources/NetworkService/Plugin/RefreshTokenPlugin.swift b/Projects/DataSource/Sources/NetworkService/Plugin/RefreshTokenPlugin.swift new file mode 100644 index 00000000..41f3fe01 --- /dev/null +++ b/Projects/DataSource/Sources/NetworkService/Plugin/RefreshTokenPlugin.swift @@ -0,0 +1,49 @@ +// +// RefreshTokenPlugin.swift +// DataSource +// +// Created by 이동현 on 7/29/25. +// + +import Foundation +import Shared + +struct RefreshTokenPlugin: NetworkPlugin { + func willSend(request: URLRequest, endpoint: Endpoint) async throws -> URLRequest { + return request + } + + func didReceive(response: URLResponse, data: Data?, endpoint: Endpoint) async throws { + guard + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 401 + else { return } + + + try await refreshAccessToken() + } + + private func refreshAccessToken() async throws { + do { + let tokenManager = TokenManager.shared + + let refreshToken = try tokenManager.loadToken(tokenType: .refreshToken) + let endpoint = AuthEndpoint.reissue(refreshToken: refreshToken) + + guard let tokenResponse = try await NetworkService.shared.request( + endpoint: endpoint, + type: TokenResponseDTO.self, + withPlugins: false) + else { throw NetworkError.unknown(description: "토큰 갱신 실패") } + + try tokenManager.saveToken(token: tokenResponse.accessToken, tokenType: .accessToken) + try tokenManager.saveToken(token: tokenResponse.refreshToken, tokenType: .refreshToken) + + BitnagilLogger.log(logType: .debug, message: "AccessToken Saved: \(tokenResponse.accessToken)") + BitnagilLogger.log(logType: .debug, message: "RefreshToken Saved: \(tokenResponse.refreshToken)") + } catch { + BitnagilLogger.log(logType: .error, message: "\(error.localizedDescription)") + throw error + } + } +} diff --git a/Projects/DataSource/Sources/NetworkService/Plugin/RetryPlugin.swift b/Projects/DataSource/Sources/NetworkService/Plugin/RetryPlugin.swift new file mode 100644 index 00000000..0f7d7a1e --- /dev/null +++ b/Projects/DataSource/Sources/NetworkService/Plugin/RetryPlugin.swift @@ -0,0 +1,31 @@ +// +// RetryPlugin.swift +// DataSource +// +// Created by 이동현 on 7/29/25. +// + +import Foundation + +struct RetryPlugin: NetworkPlugin { + func willSend(request: URLRequest, endpoint: Endpoint) async throws -> URLRequest { + return request + } + + func didReceive( + response: URLResponse, + data: Data?, + endpoint: Endpoint + ) async throws { + guard let httpResponse = response as? HTTPURLResponse else { return } + + switch httpResponse.statusCode { + case 200..<300: + return + case 401: // TODO: - 재전송 정책 필요시 조정하기!! + throw NetworkError.needRetry + default: + throw NetworkError.noRetry + } + } +} diff --git a/Projects/DataSource/Sources/NetworkService/Plugin/TokenInjectionPlugin.swift b/Projects/DataSource/Sources/NetworkService/Plugin/TokenInjectionPlugin.swift new file mode 100644 index 00000000..6f7c6388 --- /dev/null +++ b/Projects/DataSource/Sources/NetworkService/Plugin/TokenInjectionPlugin.swift @@ -0,0 +1,21 @@ +// +// TokenInjectionPlugin.swift +// DataSource +// +// Created by 이동현 on 7/29/25. +// + +import Foundation + +struct TokenInjectionPlugin: NetworkPlugin { + func willSend(request: URLRequest, endpoint: any Endpoint) async throws -> URLRequest { + guard endpoint.isAuthorized else { return request } + + var newRequest = request + let accessToken = try TokenManager.shared.loadToken(tokenType: .accessToken) + newRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + return newRequest + } + + func didReceive(response: URLResponse, data: Data?, endpoint: any Endpoint) async throws {} +} diff --git a/Projects/DataSource/Sources/NetworkService/Protocol/NetworkPlugin.swift b/Projects/DataSource/Sources/NetworkService/Protocol/NetworkPlugin.swift new file mode 100644 index 00000000..dfe756c4 --- /dev/null +++ b/Projects/DataSource/Sources/NetworkService/Protocol/NetworkPlugin.swift @@ -0,0 +1,27 @@ +// +// NetworkPlugin.swift +// DataSource +// +// Created by 이동현 on 7/29/25. +// + +import Foundation + +protocol NetworkPlugin { + /// 네트워크 요청 전처리를 진행합니다. + /// - Parameters: + /// - request: 요청할 request 객체 + /// - endpoint: 요청할 엔드포인트 + /// - Returns: URLRequest 객체 + func willSend(request: URLRequest, endpoint: Endpoint) async throws -> URLRequest + + /// 네트워크 응답 후처리를 진행합니다. + /// - Parameters: + /// - response: URLResponse 객체 + /// - data: 통신 후 받은 데이터 + /// - endpoint: 요청한 endpoint + func didReceive( + response: URLResponse, + data: Data?, + endpoint: Endpoint) async throws +}