안녕하세요.
오늘은 저번 게시글에 이어 Swift에서 API 호출 구조를 확장성 있게, 타입 안전성도 챙기면서 깔끔하게 설계할 수 있을지 고민하다가 두 가지 패턴으로 각각 구현해본 내용을 정리해보려고 합니다.
왜 이 주제를 적냐면 저번 게시글에서 Generic, associatedtype, typealias를 이용해 API 호출 구조를 한번 정리했었는데, 그때 ResponseData를 Any로 설정해서 사용하는 쪽에서 매번 타입 캐스팅을 해줘야 했던 게 마음에 안들었었거든요.
그래서 이번엔 타입 캐스팅 없이, 더 명확하게 타입을 지정할 수 있는 방법은 없을까 고민하면서 두 가지 방법으로 리팩토링을 해봤습니다.
만들고자 했던 구조
1. API 요청마다 반환 데이터 타입을 명확히 지정하고 싶다
2. 에러 처리는 공통 규칙으로 통일
3. 새로운 API 요청 추가 시 기존 코드 거의 건드리지 않고 확장
4. 요청 처리 로직은 최대한 깔끔하게
첫번째 방법으로는 요청마다 struct를 만들고, 그 struct을 APIClient로 넘기는 방식입니다.
enum APIError: Error {
case invalidURL
case decodingError
}
protocol APIRequestProtocol {
associatedtype ResponseData: Decodable
var url: URL? { get }
}
// 실제 API 요청 정보를 담고 있는 struct
struct GetUserRequest: APIRequestProtocol {
typealias ResponseData = User
var index: String
var url: URL? {
URL(string: "https://jsonplaceholder.typicode.com/users/\(index)")
}
}
struct APIClient {
func performRequest<Request: APIRequestProtocol>(request: Request) async throws -> Request.ResponseData {
guard let url = request.url else { throw APIError.invalidURL }
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(Request.ResponseData.self, from: data)
}
}
// 사용 예시
Task {
let client = APIClient_3()
let user = try await client.performRequest(request: GetUserRequest(index: "1"))
print(user.name)
}
느낀 점:
두 번째로는 요청 종류를 enum으로 관리하고, Factory에서 요청 struct를 생성해주는 방식으로 해봤습니다.
enum APIError: Error {
case invalidURL
case decodingError
}
protocol APIRequestProtocol {
associatedtype ResponseData: Decodable
var url: URL? { get }
}
struct GetUserRequest: APIRequestProtocol {
typealias ResponseData = User
var id: String
var url: URL? {
URL(string: "https://jsonplaceholder.typicode.com/users/\(id)")
}
}
enum APIType {
case user(String)
}
protocol APIRequestFactory {
associatedtype Request: APIRequestProtocol
static func makeRequest(for type: APIType) -> Request
}
struct MyRequestFactory: APIRequestFactory {
static func makeRequest(for type: APIType) -> some APIRequestProtocol {
switch type {
case .user(let id):
return GetUserRequest(id: id)
}
}
}
struct FactoryAPIClient<Factory: APIRequestFactory> {
func performRequest(for type: APIType) async throws -> Factory.Request.ResponseData {
let request = Factory.makeRequest(for: type)
guard let url = request.url else { throw APIError.invalidURL }
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(Factory.Request.ResponseData.self, from: data)
}
}
// 사용 예시
Task {
let client = FactoryAPIClient<MyRequestFactory>()
let user = try await client.performRequest(for: .user("\(idx)")) as? User
print(user.name)
}
느낀 점:
결론
두 방식 다 써보면서 확실히 느낀 건,
타입 안전성과 확장성 둘 다 충분히 확보할 수 있다는 점이었어요.
근데, 이렇게 API를 요청할 때 struct구조를 넘기는게 괜찮은 방법인가..? 라는 생각이 들었어요. 성능이나 CS 관점에서 괜찮은건지, 그래서 이 부분은 gpt에게 물어봤습니다.
GPT 답변결론: 굉장히 흔하고 자연스러운 패턴입니다.
사실 struct를 사용할 때 단 하나만 주의하면 됩니다
“내부에 reference type (예: class, closure 등)이 있을 경우, 복사 비용 주의”
근데 API 요청 정보 struct는 대부분
struct GetUserRequest {
let url: URL
let headers: [String: String]
}
이 정도 값들이라서 무거운 reference type이 들어가는 경우가 거의 없음 → 문제 X.
다음에는 이 구조를 기반으로 Async/Await 활용, Combine으로 리팩토링하는 버전도 한 번 정리해보려 합니다!