-
[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 관리와 유연한 사용에는 아쉬운 부분이 있습니다. 이 코드에는 다음과 같은 문제가 있습니다.
- 중복코드: API에 중복적으로 사용하는 코드(https://jsonplaceholder.typicode.com/ 중복)가 존재합니다.
- 낮은 가독성: 이 코드로는 API를 호출할 때 API 응답이 어떤 값에 의존적인지 파악하기 어렵습니다.
- 유연성 부족: 만약 사용하는 API 서비스가 여러개인 경우 서비스마다 token(key) 존재 유무, query string 사용 여부 등 구조가 다른데, 그 구조를 모두 유연하게 처리할 수 있도록 설계 되지 않았습니다.
2. 목표
이번 Swift HTTP 네트워킹 시리즈에서는 이런 단점을 보완한 HTTP 네트워킹 코드를 만들어 볼 예정입니다. 이 코드의 목표는 다음과 같습니다.
- API 요청을 구조화
- Base URL, path, query parameter, response parsing, request 등을 구조화해서 관리할 수 있도록 합니다.
- 서비스 추가에 유연한 대응: 서비스별 통일된 관리
- 서비스가 추가되더라도 필요한 데이터만 넣으면 쉽게 사용할 수 있도록 합니다.
- 중복코드 제거
- 기반 코드를 한번만 작성하면 재사용할 수 있도록 하였습니다.
- 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 통신 과정에 대해 살펴보겠습니다.

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

각 단계에서 수행하는 역할은 다음과 같습니다.
- 요청 생성: 서버에 보낼 HTTP 요청을 생성
- 요청: 서버에 요청을 전달
- 서버 응답 생성: 클라에 내려줄 응답을 서버에서 생성
- 응답 전달: 서버에서 클라에 응답 전달
- 응답 처리
- 에러를 수신한 경우 에러 처리
- 올바른 값을 수신한 경우 응답을 적절히 디코딩(파싱)
너무나 직관적인 내용들이죠.
이제 단계별로 어떻게 코드로 작성할 수 있는지 알아보도록 하겠습니다.
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 JSONPlaceholderAPIConfig는APIConfig를 준수하는 프로토콜입니다.baseURL은 https://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 } }이 코드는
- makeURL()로 URL을 생성하고
- 생성한 URL로 urlRequest를 생성(makeURLRequest)하고
- 생성한 요청을 전달하고(perform)
- 요청의 응답을 검증하고(validate)
- 응답을 디코딩 합니다.(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 네트워킹 코드를 단단하고 유연하게 작성하고 싶은 분들에게 참고가 되리라 생각합니다.
전체 코드는 여기에 남겨두었습니다. 필요하신 분은 다운로드해서 확인해 보세요.'iOS > Swift' 카테고리의 다른 글
GCD와 Swift Concurrency의 차이 및 등장 배경 (0) 2025.10.25 iOS 클린 아키텍처 + MVVM 개념과 코드 적용 (0) 2025.10.24 [Swift] ABI stability란? (0) 2025.10.16