ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Swift] 단단하고 유연한 HTTP 네트워킹 코드 짜기
    iOS/Swift 2025. 9. 26. 19:44
     

    1. 문제

    Swift에서 HTTP 네트워크 코드는 가장 흔하게 다루는 코드 중 하나입니다. 서버와 HTTP 통신 없이 동작하는 앱이 거의 없기 때문이죠.
    그만큼 흔하게 사용하는 코드인데 이 코드 어떻게 만들어서 사용 중이신가요?

    혹시 이런식으로 사용하고 계신가요?

    // 선언
    class HTTPClient {
        func fetch<T: Decodable>(_ urlString: String, as type: T.Type) async throws -> T {
            guard let url = URL(string: urlString) else {
                throw URLError(.badURL)
            }
    
            let (data, _) = try await URLSession.shared.data(from: url)
            return try JSONDecoder().decode(T.self, from: data)
        }
    }
    
    // 사용
    Task {
        let client = HTTPClient()
    
        do {
            async let post: Post = client.fetch("https://jsonplaceholder.typicode.com/posts/1", as: Post.self)
            async let user: User = client.fetch("https://jsonplaceholder.typicode.com/users/1", as: User.self)
            async let comments: [Comment] = client.fetch("https://jsonplaceholder.typicode.com/comments?postId=1", as: [Comment].self)
    
            // 동시에 실행 후 결과 한 번에 모으기
            let (fetchedPost, fetchedUser, fetchedComments) = try await (post, user, comments)     
        } catch { }
    }

    이렇게 사용하더라도 동작에는 문제가 없습니다. 하지만 API 관리와 유연한 사용에는 아쉬운 부분이 있습니다. 이 코드에는 다음과 같은 문제가 있습니다.

    1. 중복코드: API에 중복적으로 사용하는 코드(https://jsonplaceholder.typicode.com/ 중복)가 존재합니다.
    2. 낮은 가독성: 이 코드로는 API를 호출할 때 API 응답이 어떤 값에 의존적인지 파악하기 어렵습니다.
    3. 유연성 부족: 만약 사용하는 API 서비스가 여러개인 경우 서비스마다 token(key) 존재 유무, query string 사용 여부 등 구조가 다른데, 그 구조를 모두 유연하게 처리할 수 있도록 설계 되지 않았습니다.

    2. 목표

    이번 Swift HTTP 네트워킹 시리즈에서는 이런 단점을 보완한 HTTP 네트워킹 코드를 만들어 볼 예정입니다. 이 코드의 목표는 다음과 같습니다.

    1. API 요청을 구조화
      • Base URL, path, query parameter, response parsing, request 등을 구조화해서 관리할 수 있도록 합니다.
    2. 서비스 추가에 유연한 대응: 서비스별 통일된 관리
      • 서비스가 추가되더라도 필요한 데이터만 넣으면 쉽게 사용할 수 있도록 합니다.
    3. 중복코드 제거
      • 기반 코드를 한번만 작성하면 재사용할 수 있도록 하였습니다.
    4. API 호출을 계층적으로 표현하기 (좋은 가독성)
      • 호출시 어떤 서비스의 API가 호출되는지를 시각적으로 쉽게 알 수 있도록 합니다.

    이런 코드가 만들어질 예정입니다.

    // 선언
    import Foundation
    
    protocol FearGreedAPIConfig: APIConfig {}
    
    extension FearGreedAPIConfig {
        var baseURL: URL {
            guard let url = URL(string: "https://api.alternative.me/") else {
                fatalError("Invalid base URL")
            }
            return url
        }
    }
    
    extension API.FearGreedAPI {
        struct FetchData { }
    }
    
    extension API.FearGreedAPI.FetchData: FearGreedAPIConfig {
        typealias Response = FearGreedResponse
    
        var path: String { return "/fng" }
        var method: HTTPMethod { return .get }
    }
    
    // 사용
    do {
          async let fearGreedResult    = try API.FearGreedAPI.FetchData().request()
          async let fetchPostResult    = try API.JSONPlaceholderAPI.FetchPosts().request()
          let result = try await (fearGreedResult, fetchPostResult)
        print("result: \(result)")
        } catch { print("request failed:", error) }

    3. HTTP 통신 개요

    코드 작성에 앞어서 간단하게 클라이언트(앱)와 서버간의 HTTP 통신 과정에 대해 살펴보겠습니다.

    위 그림은 클라이언트(이하 클라)와 서버간 통신을 간략하게 나타낸 그림입니다. 클라에서 서버에 요청을 보내면 그 요청에 맞는 응답을 서버가 클라에 전해주게 됩니다. 이 흐름을 조금 더 자세히는 아래 그림처럼 나타낼 수 있습니다.

    각 단계에서 수행하는 역할은 다음과 같습니다.

    1. 요청 생성: 서버에 보낼 HTTP 요청을 생성
    2. 요청: 서버에 요청을 전달
    3. 서버 응답 생성: 클라에 내려줄 응답을 서버에서 생성
    4. 응답 전달: 서버에서 클라에 응답 전달
    5. 응답 처리
      1. 에러를 수신한 경우 에러 처리
      2. 올바른 값을 수신한 경우 응답을 적절히 디코딩(파싱)

    너무나 직관적인 내용들이죠.

    이제 단계별로 어떻게 코드로 작성할 수 있는지 알아보도록 하겠습니다.

    4. HTTP 요청 생성하기

    요청을 생성하기 위해서는 HTTP 통신과 관련해 몇 가지를 알아야 합니다. 알아야 할 내용을 먼저 살펴보고 요청에 필요한 코드도 만들어 보도록 하겠습니다.

    4.1 URL

    서버에 HTTP 요청을 하기 위해서는 사용할 API를 지정해야합니다. API는 URL 형태로 사용하게 됩니다.
    일반적으로 HTTP 요청을 위해 사용하는 URL은 다음과 같은 형태로 돼 있습니다.

    HTTP://example.com/path/to/myfile.html?key1=value&key2=value
    scheme, domain name, path&file, query string 등이 결합돼 있습니다.

    4.2 Method & Error 처리

    HTTP 요청 매소드는 요청 종류에 따라 여러가지가 있습니다. 비교적 자주 사용하는 매소드를 아래와 같이 enum으로 정의 합니다.

    enum HTTPMethod: String {
        case get
        case post
        case put
        case delete
    }
    
    extension HTTPMethod {
        var isSupportsHTTPBody: Bool {
            switch self {
            case .post, .put, .delete:
                return true
            case .get:
                return false
            }
        }
    }

    매소드에 따라 HTTP 요청시 body에 내용을 넣는 요청은 구분해서 따로 isSupportsHTTPBody라는 프로퍼티로 만들어 둡니다.
    HTTP 요청시 발생하는 오류를 구분하는데 사용할 RequestError도 enum으로 정의 합니다.

    import Foundation
    
    enum RequestError: Error {
        case requestFailed(Error)
        case invalidURL
        case invalidResponse(statusCode: Int)
        case noContent
        case dataError
        case decodingError(message: String?)
        case clientError(statusCode: Int, message: String?)
        case serverError(statusCode: Int, message: String?)
    
        var message: String {
            switch self {
            case .requestFailed(let error):
                return "Request failed with error: \(error.localizedDescription)"
            case .invalidURL:
                return "Invalid URL"
            case .invalidResponse:
                return "Invalid response from server."
            case .noContent:
                return "No Content"
            case .dataError:
                return "No data received."
            case .decodingError:
                return "Failed to decode response."
            case .clientError(let statusCode, let message):
                return "Client error with status code:(\(statusCode)): \(message ?? "Unknown error")"
            case .serverError(let statusCode, let message):
                return "Server error with status code:(\(statusCode)): \(message ?? "Unknown error")"
            }
        }
    }

    4.3 APIConfig

    이 글의 핵심 코드인 APIConfig 프로토콜입니다. 이 프로토콜을 통해 HTTP 요청과 응답을 구조화 합니다.

    import Foundation
    
    protocol APIConfig {
        associatedtype Response: Decodable
    
        var baseURL: URL { get }
        var path: String { get }
        var method: HTTPMethod { get }
    
        var queryParam: [String: Any?]? { get }
        var bodyParam: [String: Any?]? { get }
    
        var apiKey: URLQueryItem? { get }
    
        func decode(_ data: Data) throws -> Response
    }
    
    extension APIConfig {
        var queryParam: [String: Any?]? { nil }
        var bodyParam: [String: Any?]? { nil }
    
        var apiKey: URLQueryItem? { return nil }
    }

    각 코드의 역할은 아래 실제 이 프로토콜을 구현한 코드를 보면 쉽게 파악이 가능하실 겁니다.

    import Foundation
    
    protocol JSONPlaceholderAPIConfig: APIConfig {}
    
    extension JSONPlaceholderAPIConfig {
        var baseURL: URL {
            guard let url = URL(string: "https://jsonplaceholder.typicode.com/") else {
                fatalError("Invalid base URL")
            }
            return url
        }
    }

    protocol JSONPlaceholderAPIConfigAPIConfig를 준수하는 프로토콜입니다.

    baseURLhttps://jsonplaceholder.typicode.com/로 지정했습니다.

    import Foundation
    
    struct API { }
    
    extension API {
        struct JSONPlaceholderAPI { }
    }
    
    extension API.JSONPlaceholderAPI {
        struct FetchComments {
            let postID: Int
        }
    }

    API 호출시 계층 구조를 적절히 보여주기 위해 API 구조체를 만들고 사용할 API의 이름인 FetchComments과 API에서 사용할 파라미터를 프로퍼티로(let postID: Int) 선언합니다.

    이후 APIConfig를 준수하는 프로토콜인 JSONPlaceholderAPIConfig를 따르는 API 구조체의 구현을 다래와 같이 합니다.

    extension API.JSONPlaceholderAPI.FetchComments: JSONPlaceholderAPIConfig {
        typealias Response = [CommentResponse]
    
        var path: String { return "/comments" }
        var method: HTTPMethod { return .get }
    
        var queryParam: [String : Any?]? {
            return [
                "postId" : "\(postID)"
            ]
        }
    }

    이 코드의 의미는 다음과 같습니다.

    • typealias Response = \[CommentResponse\]: 응답값 디코딩은 CommentResponse 배열로 한다.
    • var path: String { return "/comments" } : path는 comment다. API의 full path는 baseURL + path 인 https://jsonplaceholder.typicode.com/comments 입니다.
    • var method: HTTPMethod { return .get }: method는 get을 사용한다.
    • queryParam: postId에 값을 넣는다.

    이 코드를 사용하면 FetchComments 호출시 https://jsonplaceholder.typicode.com/comments?postId=1와 같은 API URL을 사용할 수 있습니다.

    5. URLRequest 생성 및 요청 전달

    요청과 관련한 코드는 다음과 같습니다.

    extension APIConfig {
        func request() async throws -> Response {
            let url = try makeURL() // #1
            let urlRequest = try makeURLRequest(url: url) // ##2
    
            let (data, response) = try await perform(urlRequest) // ##3
    
            try validate(response: response, data: data) // #4
    
            return try decode(data) // #5
        }
    }

    이 코드는

    1. makeURL()로 URL을 생성하고
    2. 생성한 URL로 urlRequest를 생성(makeURLRequest)하고
    3. 생성한 요청을 전달하고(perform)
    4. 요청의 응답을 검증하고(validate)
    5. 응답을 디코딩 합니다.(decode)

    각 코드는 상세 구현은 다음과 같습니다.

    5.1 makeURL()

    • 역할: API 요청을 위한 URL을 생성합니다.
    func makeURL() throws -> URL {
            let base = path.isEmpty ? baseURL : baseURL.appendingPathComponent(path)
    
            guard var urlComponents = URLComponents(url: base, resolvingAgainstBaseURL: false) else {
                throw RequestError.invalidURL
            }
    
            var queryItems = makeQueryItems(from: queryParam)
            if let apiKey { queryItems.append(apiKey) }
            urlComponents.queryItems = queryItems.isEmpty ? nil : queryItems
    
            guard let url = urlComponents.url else { throw RequestError.invalidURL }
    
            print("[HTTP Request] : \(url.absoluteString)")
    
            return url
        }
    
    func makeQueryItems(from params: [String: Any?]?) -> [URLQueryItem] {
            guard let params else { return [] }
    
            return params.compactMap { key, value in
                guard let value else { return nil }
                return URLQueryItem(name: key, value: String(describing: value))
            }
        }

    base + path + query string을 조합해 URL을 생성합니다.

    5.2 makeURLRequest()

    func makeURLRequest(url: URL) throws -> URLRequest {
            var request = URLRequest(url: url)
            request.httpMethod = method.rawValue.uppercased()
    
            if let bodyParam, method.isSupportsHTTPBody {
                // GET은 보통 Content-Type 헤더를 보내지 않음. + body 없음
                request.httpBody = try JSONSerialization.data(withJSONObject: bodyParam.compactMapValues { $0 })
                request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            }
    
            request.setValue("application/json", forHTTPHeaderField: "Accept")
    
            return request
        }

    url로 URLRequest를 생성하고 body가 있는 매소드는 body를 추가해 줍니다.

    5.3 perform()

    func perform(_ request: URLRequest) async throws -> (Data, URLResponse) {
            do {
                return try await URLSession.shared.data(for: request)
            } catch {
                throw RequestError.requestFailed(error)
            }
        }

    urlRequest를 URLSession.shared.data() 함수에 인자로 넣어 서버에 요청을 전달 합니다.

    5.4 validate()

    func validate(response: URLResponse, data: Data) throws {
            guard let httpResponse = response as? HTTPURLResponse else {
                throw RequestError.invalidResponse(statusCode: 0)
            }
    
            switch httpResponse.statusCode {
            case 200...299: return
            case 400...499:
                throw RequestError.clientError(statusCode: httpResponse.statusCode, message: data.errorMessage)
            case 500...599:
                throw RequestError.serverError(statusCode: httpResponse.statusCode, message: data.errorMessage)
            default:
                throw RequestError.invalidResponse(statusCode: httpResponse.statusCode)
            }
        }
    
    private extension Data {
        /// 서버 응답에서 에러 메시지를 추출 (JSON이면 "message" or "error" 키 뒤짐, 없으면 원문 문자열)
        var errorMessage: String? {
            if let json = try? JSONSerialization.jsonObject(with: self) as? [String: Any] {
                return json["message"] as? String ?? json["error"] as? String
            }
            return String(data: self, encoding: .utf8)
        }
    }

    서버로 받은 응답의 statusCode를 보고 정상적인 응답을 수신했는지 확인합니다.

    5.5 decode()

    extension APIConfig where Response: Decodable {
        func decode(_ data: Data) throws -> Response {
            print("[Raw Data]: \(String(describing: String(data: data, encoding: .utf8)))")
            do {
                return try JSONDecoder().decode(Response.self, from: data)
            } catch {
                throw RequestError.decodingError(message: error.localizedDescription)
            }
        }
    }

    응답을 디코딩해 객체로 변환합니다.

    6. 요청 코드 사용하기

    선언한 코드는 아래 처럼 사용 할 수 있습니다.

    import SwiftUI
    
    struct ContentView: View {
        var body: some View {
            VStack {
                Text("HTTP Client Example by 토미")
            }
            .task {
                do {
                    async let fearGreedResult    = try API.FearGreedAPI.FetchData().request()
    
                    async let fetchPostResult    = try API.JSONPlaceholderAPI.FetchPosts().request()
                    async let postCommentsResult = try API.JSONPlaceholderAPI.FetchComments(postID: 1).request()
                    async let postResult         = try API.JSONPlaceholderAPI.Post(title: "hey", body: "I'm body", userID: 3).request()
    
                    async let weatherResult      = try API.WeatherAPI.FetchData(place: "Seoul").request()
    
                    let result = try await (fearGreedResult, fetchPostResult, postCommentsResult, postResult, weatherResult)
                    print("result: \(result)")
                } catch {
                    print("request failed:", error)
                }
            }
            .padding()
        }
    }
    
    #Preview {
        ContentView()
    }

    6.1 파라미터가 없는 API

    • API.FearGreedAPI.FetchData().request()
    • API.JSONPlaceholderAPI.FetchPosts().request()

    6.2 파라미터가 있는 API

    • API.JSONPlaceholderAPI.FetchComments(postID: 1).request()
    • API.JSONPlaceholderAPI.Post(title: "hey", body: "I'm body", userID: 3).request()
    • API.WeatherAPI.FetchData(place: "Seoul").request()

    6.3 서비스별 API 구분

    API 호출 네이임스페이스만으로 어떤 서비스의 어떤 API를 호출하는지 확인 가능합니다.

    a. FearGreedAPI

    • API.FearGreedAPI.FetchData().request()

    b. JSONPlaceholderAPI

    • API.JSONPlaceholderAPI.FetchPosts().request()
    • API.JSONPlaceholderAPI.FetchComments(postID: 1).request()
    • API.JSONPlaceholderAPI.Post(title: "hey", body: "I'm body", userID: 3).request()

    c. WeatherAPI

    • API.WeatherAPI.FetchData(place: "Seoul").request()

    7. 정리

    지금까지 구현한 내용 중 핵심인 APIConfig 프로토콜을 정의하고 프로토콜을 따르는 서비스별 API 호출 코드를 생성하는 구조는 다음과 같습니다.

    Swift에서 HTTP 네트워킹 코드를 단단하고 유연하게 작성하고 싶은 분들에게 참고가 되리라 생각합니다.
    전체 코드는 여기에 남겨두었습니다. 필요하신 분은 다운로드해서 확인해 보세요.

Designed by kyejusung.com