자주 쓰던 Moya 없이, URLSession만 가지고 네트워크 시스템을 개발해보았다.
기본적인 구조는 Moya를 따라했지만, 차이점이 있다.
//
// HTTPMethod.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
enum HTTPMethod: String {
case get, post, put, delete, patch
}
enum RequestTask {
case plain
case parameters([String: Any])
case encodable(Encodable)
}
protocol TargetType {
var baseURL: URL { get }
var path: String { get }
var method: HTTPMethod { get }
var task: RequestTask { get }
var headers: [String: String]? { get }
}
//
// NetworkError.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
enum NetworkError: Error, LocalizedError {
case invalidURL
case requestFailed(statusCode: Int)
case decodingFailed
case unknown(Error)
var errorDescription: String? {
switch self {
case .invalidURL:
return "잘못된 URL입니다."
case .requestFailed(let statusCode):
return "요청 실패: 상태 코드 \(statusCode)"
case .decodingFailed:
return "데이터 파싱에 실패했습니다."
case .unknown(let error):
return error.localizedDescription
}
}
}
//
// APIRequest.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
struct APIRequest: TargetType {
var baseURL: URL
var path: String
var method: HTTPMethod
var task: RequestTask
var headers: [String: String]?
init(
baseURL: URL = URL(string: "https://jsonplaceholder.typicode.com")!,
path: String,
method: HTTPMethod = .get,
task: RequestTask = .plain,
headers: [String: String]? = nil
) {
self.baseURL = baseURL
self.path = path
self.method = method
self.task = task
self.headers = headers
}
}
//
// NetworkService.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
import Combine
protocol NetworkService {
func request<T: Decodable>(_ target: TargetType, responseType: T.Type) async throws -> T
func requestPublisher<T: Decodable>(_ target: TargetType, responseType: T.Type) -> AnyPublisher<T, NetworkError>
}
//
// NetworkService+.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
import Combine
extension NetworkService {
func request<T: Decodable>(_ target: TargetType, responseType: T.Type) async throws -> T {
let request = try buildRequest(from: target)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.requestFailed(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
}
if T.self == Void.self {
return () as! T
}
return try JSONDecoder().decode(T.self, from: data)
} catch let error as NetworkError {
throw error
} catch {
throw NetworkError.unknown(error)
}
}
func requestPublisher<T: Decodable>(
_ target: TargetType,
responseType: T.Type
) -> AnyPublisher<T, NetworkError> {
do {
let request = try buildRequest(from: target)
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap { output in
guard let httpResponse = output.response as? HTTPURLResponse else {
throw NetworkError.requestFailed(statusCode: -1)
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw NetworkError.requestFailed(statusCode: httpResponse.statusCode)
}
if T.self == Void.self {
return () as! T
}
return try JSONDecoder().decode(T.self, from: output.data)
}
.mapError { error in
if let decodingError = error as? DecodingError {
return .decodingFailed
} else if let networkError = error as? NetworkError {
return networkError
} else {
return .unknown(error)
}
}
.eraseToAnyPublisher()
} catch {
return Fail(error: NetworkError.unknown(error))
.eraseToAnyPublisher()
}
}
private func buildRequest(from target: TargetType) throws -> URLRequest {
guard let url = URL(string: target.baseURL.absoluteString + target.path) else {
throw NetworkError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = target.method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
target.headers?.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
switch target.task {
case .plain:
break
case .parameters(let params):
request.httpBody = try JSONSerialization.data(withJSONObject: params, options: .prettyPrinted)
case .encodable(let encodable):
request.httpBody = try JSONEncoder().encode(encodable)
}
return request
}
}
//
// Publisher+.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
import Combine
extension Publisher {
func retryIf(
maxRetries: Int,
delay: UInt64,
shouldRetry: @escaping (Failure) -> Bool
) -> AnyPublisher<Output, Failure> {
self.catch { error -> AnyPublisher<Output, Failure> in
guard maxRetries > 0, shouldRetry(error) else {
return Fail(error: error).eraseToAnyPublisher()
}
return Just(())
.delay(for: .seconds(Double(delay)), scheduler: DispatchQueue.global())
.flatMap { _ in
self.retryIf(maxRetries: maxRetries - 1, delay: delay, shouldRetry: shouldRetry)
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
//
// LoggingNetworkService.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
import Combine
import os
final class LoggingNetworkService: NetworkService {
private let wrapped: NetworkService
private let logger = Logger(subsystem: "com.toto.combine", category: "Network")
private let enableFileLogging: Bool
init(wrapped: NetworkService, enableFileLogging: Bool = true) {
self.wrapped = wrapped
self.enableFileLogging = enableFileLogging
}
private func logToFile(_ message: String) {
if enableFileLogging {
FileLogger.log(message)
}
}
func request<T: Decodable>(
_ target: TargetType,
responseType: T.Type
) async throws -> T {
let url = target.baseURL.appendingPathComponent(target.path)
let requestLog = "🌐 [REQUEST] \(target.method.rawValue) \(url)"
logger.info("\(requestLog, privacy: .public)")
logToFile(requestLog)
if let headers = target.headers {
let headerLog = "🔐 [HEADERS] \(headers)"
logger.debug("\(headerLog, privacy: .public)")
logToFile(headerLog)
}
switch target.task {
case .plain:
logToFile("📦 [TASK] plain")
case .parameters(let params):
logToFile("📦 [PARAMETERS] \(params)")
case .encodable:
logToFile("📦 [ENCODABLE] (not printed)")
}
let start = Date()
do {
let result = try await wrapped.request(target, responseType: T.self)
let duration = Date().timeIntervalSince(start) * 1000
let successLog = "✅ [RESPONSE] \(T.self) decoded in \(String(format: "%.1f", duration))ms"
logger.info("\(successLog)")
logToFile(successLog)
return result
} catch {
let duration = Date().timeIntervalSince(start) * 1000
let errorLog = "❌ [ERROR] \(error.localizedDescription) (\(String(format: "%.1f", duration))ms)"
logger.error("\(errorLog, privacy: .public)")
logToFile(errorLog)
throw error
}
}
func requestPublisher<T: Decodable>(
_ target: TargetType,
responseType: T.Type
) -> AnyPublisher<T, NetworkError> {
let url = target.baseURL.appendingPathComponent(target.path)
let logMessage = "🌐 [REQUEST (Publisher)] \(target.method.rawValue) \(url)"
logger.info("\(logMessage, privacy: .public)")
logToFile(logMessage)
return wrapped.requestPublisher(target, responseType: responseType)
}
}
//
// RetryNetworkService.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
import Combine
final class RetryNetworkService: NetworkService {
private let wrapped: NetworkService
private let maxRetries: Int
private let retryDelay: UInt64 // nanoseconds (e.g., 0.5초 = 500_000_000)
init(
wrapped: NetworkService,
maxRetries: Int = 3,
retryDelay: UInt64 = 500_000_000
) {
self.wrapped = wrapped
self.maxRetries = maxRetries
self.retryDelay = retryDelay
}
// MARK: - async/await 버전
func request<T: Decodable>(
_ target: TargetType,
responseType: T.Type
) async throws -> T {
var attempt = 0
var lastError: Error?
while attempt < maxRetries {
do {
return try await wrapped.request(target, responseType: responseType)
} catch let error as NetworkError {
switch error {
case .invalidURL,
.decodingFailed:
throw error
case .requestFailed(let code) where (400..<500).contains(code):
throw error
default:
lastError = error
attempt += 1
print("🔁 Retry \(attempt)/\(maxRetries) for \(target.path)")
try await Task.sleep(nanoseconds: retryDelay)
}
} catch {
throw error
}
}
throw lastError ?? NetworkError.unknown(NSError(domain: "", code: -1))
}
// MARK: - Combine 버전
func requestPublisher<T: Decodable>(
_ target: TargetType,
responseType: T.Type
) -> AnyPublisher<T, NetworkError> {
wrapped.requestPublisher(target, responseType: responseType)
.retryIf(maxRetries: maxRetries, delay: retryDelay / 1_000_000_000) { error in
// 재시도 여부 분기 로직
switch error {
case .invalidURL, .decodingFailed:
return false
case .requestFailed(let code) where (400..<500).contains(code):
return false
default:
return true
}
}
.eraseToAnyPublisher()
}
}
//
// Post.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
struct Post: Codable, Identifiable, Equatable {
let id: Int
let userId: Int
let title: String
let body: String
}
struct EmptyResponse: Decodable, Equatable {}
//
// PostAPI.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
enum PostAPI {
case getPosts
case getPost(id: Int)
case createPost(title: String, body: String, userId: Int)
case updatePost(id: Int, title: String, body: String, userId: Int)
case patchPost(id: Int, title: String?)
case deletePost(id: Int)
}
extension PostAPI: TargetType {
var baseURL: URL {
URL(string: "https://jsonplaceholder.typicode.com")!
}
var path: String {
switch self {
case .getPosts:
return "/posts"
case .getPost(let id),
.updatePost(let id, _, _, _),
.patchPost(let id, _),
.deletePost(let id):
return "/posts/\(id)"
case .createPost:
return "/posts"
}
}
var method: HTTPMethod {
switch self {
case .getPosts, .getPost: return .get
case .createPost: return .post
case .updatePost: return .put
case .patchPost: return .patch
case .deletePost: return .delete
}
}
var task: RequestTask {
switch self {
case .getPosts, .getPost, .deletePost:
return .plain
case .createPost(let title, let body, let userId),
.updatePost(_, let title, let body, let userId):
return .parameters([
"title": title,
"body": body,
"userId": userId
])
case .patchPost(_, let title):
var body: [String: Any] = [:]
if let title = title {
body["title"] = title
}
return .parameters(body)
}
}
var headers: [String : String]? {
["Content-Type": "application/json"]
}
}
//
// toto_combineTests.swift
// toto-combineTests
//
// Created by 김도연 on 4/4/25.
//
import Testing
@testable import toto_combine
struct toto_combineTests {
struct DefaultNetworkService: NetworkService {}
var service: NetworkService {
LoggingNetworkService(wrapped: DefaultNetworkService(), enableFileLogging: true)
}
func logStart(of name: String = #function) {
FileLogger.log("----- 🧪 [\(name)] 시작 -----")
}
func logEnd(of name: String = #function) {
FileLogger.log("----- ✅ [\(name)] 종료 -----")
}
@Test
func testGetPosts() async throws {
logStart()
print("📁 로그 파일 위치: \(FileLogger.logFileURL.path)")
let posts: [Post] = try await service.request(PostAPI.getPosts, responseType: [Post].self)
#expect(!posts.isEmpty, "❌ 게시글이 비어 있습니다. 서버가 꺼져있거나, 파싱 실패 가능성")
logEnd()
}
@Test
func testCreatePost() async throws {
logStart()
do {
let newPost: Post = try await service.request(
PostAPI.createPost(title: "Hello", body: "world", userId: 1),
responseType: Post.self
)
#expect(newPost.id == 101, "❌ 예상한 ID(101)가 아닙니다. 응답: \(newPost)")
} catch {
#expect(false, "❌ POST 요청 실패: \(error.localizedDescription)")
}
logEnd()
}
@Test
func testUpdatePost() async throws {
logStart()
do {
let updated = try await service.request(
PostAPI.updatePost(id: 1, title: "Updated Title", body: "Updated body", userId: 1),
responseType: Post.self
)
#expect(updated.title == "Updated Title", "❌ 제목이 업데이트되지 않았습니다. 응답: \(updated)")
#expect(updated.id == 1, "❌ 업데이트된 ID가 1이 아닙니다. 응답: \(updated.id)")
} catch {
#expect(false, "❌ PUT 요청 실패: \(error.localizedDescription)")
}
logEnd()
}
@Test
func testPatchPost() async throws {
logStart()
do {
let patched = try await service.request(
PostAPI.patchPost(id: 1, title: "Patched Title"),
responseType: Post.self
)
#expect(patched.title == "Patched Title", "❌ 제목이 패치되지 않았습니다. 응답: \(patched)")
#expect(patched.id == 1, "❌ 패치된 ID가 1이 아닙니다. 응답: \(patched.id)")
} catch {
#expect(false, "❌ PATCH 요청 실패: \(error.localizedDescription)")
}
logEnd()
}
@Test
func testDeletePost() async throws {
logStart()
do {
let _: EmptyResponse = try await service.request(
PostAPI.deletePost(id: 1),
responseType: EmptyResponse.self
)
#expect(true, "✅ DELETE 요청 성공 (응답 없음)")
} catch {
#expect(false, "❌ DELETE 요청 실패: \(error.localizedDescription)")
}
logEnd()
}
}
테스트 실행 결과
1차 실패

2차 성공

로그 파일로 추출
테스트 함수의 시작점과 끝점을 더 편하게 확인하기 위해서 테스트 코드를 수정하고, create, delete테스트 함수도 수정하였다.


파일로 저장하는 함수
//
// FileLogger.swift
// toto-combine
//
// Created by 김도연 on 4/4/25.
//
import Foundation
enum FileLogger {
static let logFileURL: URL = {
let baseDir = FileManager.default.temporaryDirectory
let logsDirectory = baseDir.appendingPathComponent("Logs/toto-combine", isDirectory: true)
try? FileManager.default.createDirectory(at: logsDirectory, withIntermediateDirectories: true)
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
let timestamp = formatter.string(from: Date())
return logsDirectory.appendingPathComponent("test-log-\(timestamp).txt")
}()
static func log(_ message: String) {
let timestamp = DateFormatter.localizedString(
from: Date(),
dateStyle: .none,
timeStyle: .medium
)
let fullMessage = "[\(timestamp)] \(message)\n"
guard let data = fullMessage.data(using: .utf8) else { return }
if FileManager.default.fileExists(atPath: logFileURL.path) {
if let handle = try? FileHandle(forWritingTo: logFileURL) {
try? handle.seekToEnd()
handle.write(data)
try? handle.close()
}
} else {
try? data.write(to: logFileURL)
}
}
}