Combine + Network + URLSession

김도연·2025년 4월 4일

iOS

목록 보기
3/8

자주 쓰던 Moya 없이, URLSession만 가지고 네트워크 시스템을 개발해보았다.

기본적인 구조는 Moya를 따라했지만, 차이점이 있다.

  • 가장 기본이 되는 부분이다.
  • HTTPMethod : 요청할 메소드를 미리 enum으로 정의
  • RequestTask : request에서 해야하는 Task 미리 정의. 일단 자주 사용하는 3가지만 제작했다. 이 부분은 나중에 더 공부해서 multipart같은 케이스를 추가하면 좋을 것 같다!
//
//  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()
    }
}

데코레이터 패턴

  • 기존 네트워크 서비스에 로그 기능과 재시도 기능을 추가하려고 했는데, Moya에서는 Plugin을 통해 처리해줬던걸 직접 개발하기 위해서 고민해본 결과 데코레이터 패턴을 통해 구현하면, 필요한 곳에서 로그나 재시도를 추가할 수 있을 것 같았다.
  • 프린트 문에 들어가는 내용은 지피티를 활용했다. 지피티식 디버그문이 이모지를 적절하게 사용해서 가독성이 높았기 때문에..!
//
//  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"]
    }
}
  • XCTest 코드 작성
//
//  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차 실패

    • create, delete가 생각과 다른 response data가 와서 문서를 확인하고, EmptyResponse도 처리할 수 있게 네트워크 서비스 extension 코드를 수정하였다.
  • 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)
        }
    }

}

하면서 느꼈던 점...

  • 이 로그 파일을 github action에서 활용하는 것까지 하고 싶었는데 xcode 프로젝트 문제인지 암튼 하다가 실패했다. Ipa가 잘못됐다고 에러가 떠서 그냥 냅뒀다. 아마 googleService-info.plist 문제일 가능성이 높은 것 같아서, 다음 프로젝트 때 세팅을 다시 해보려고 한다.
  • URLSession으로 직접 구현하는 건 생각보다 어렵지 않았다. 처음에는 무척이나 어려워서 AlamoFire, Moya까지 쓰게된건데, 하다보니까 내가 실력이 늘었는지 네트워크 코드에 익숙해진 건지 URLSession이랑 다른 라이브러리랑 큰 차이를 느끼지 못했다. 그리고 구현하면서 moya가 얼마나 편한 라이브러리였는지 또 한 번 체감했다.
  • (개선점!) 다양한 리퀘스트 태스크를 처리할 수 있도록 코드 확장이 필요하다.
  • 지금까지 작성한 네트워크 시스템 코드를 라이브러리화해서 코드 정리를 잘 해두면 다른 프로젝트할 때 유용하게 쓰일 것 같다.
  • 다음은 Keychain 관련해서 직접 구현해보고 싶다. 라이브러리로 쓰던 걸 직접 코드로 구현하는 단계에 오니, 라이브러리 레포지토리에서 코드 분석도 해보고, 어떤 흐름으로 구조를 짜야하는지 더 감이 잘 오는 것 같다.
  • 깃허브 액션즈를 많이 활용할수록 깃허브에서 자동화할 수 있는게 굉장히 많고, 이를 통해서 코드 퀄리티 높이는데 팀원들의 시간을 아낄 수 있을 것 같다.
  • 기본적으로 프로젝트 빌드 문제는 없는지, 리뷰 알림, 리뷰어 자동할당, 이슈 자동할당 등 다양한 워크플로우를 찾아보고 이를 협업 문화와 연결시키면 더 폭발적으로 협업 능률이 올라갈 수 있을 것 같다.
profile
Kirby-like iOS developer

0개의 댓글