안녕하세요.
오늘은 Swift에서 API 호출 구조를 Generic associatedtype typealias를 활용해 설계하는 방법에 대해 적어보려고 합니다.
왜 이 주제를 적냐면 저번 게시글에 작성했던 객체가 함수를 만났을 때 영상에서 몰랐던게 Generic인 줄 알았는데 associatedtype과 typealias를 잘 몰랐던 거더라구요.
그래서 이번 기회에 정리하고 이해한 내용을 정리해두려고 작성하게 되었습니다.
저는 이전에는 Swift에서 API를 호출할 때, 대부분 URLSession으로 요청을 보내고 → JSONDecoder로 디코딩한 뒤 → 필요한 데이터만 뽑아서 처리하는 방식을 사용해왔습니다.
그런데 이런 방식으로 작성하다 보니 몇 가지 불편함이 계속 쌓이더라구요.
요청마다 비슷한 코드가 반복되고, 에러 처리도 여기저기 흩어져서
나중에 “어디서 처리했더라?” 하고 헷갈리는 경우도 많았습니다.
그리고 새로운 API 요청이 추가될 때마다 이전 코드를 복사해서 또 비슷한 코드를 만드는 일이 반복됐구요.
그래서 예전부터
“API 호출하는 클래스를 하나로 만들어서 한 함수에서 여러 타입이 반환될 수 있게 하면 좋겠다” 생각만 하고 있었는데, 이번에 Generic associatedtype typealias 개념을 정리하면서 비슷하게? 구현을 해본 내용을 적어보았습니다.
먼저 여기저기에 흩어져있던 에러 타입들을 공통 프로토콜로 정리해봤습니다.
protocol APIErrorProtocol: Error {
var message: String { get }
}
enum DefaultAPIError: APIErrorProtocol {
case urlError
case serverError
var message: String {
switch self {
case .urlError: return "URL ERROR"
case .serverError: return "SERVER ERROR"
}
}
}
보통 API요청은 request에 따라 결과로 받는 데이터가 다르죠?
그래서 요청은 이렇게 associatedtype을 사용해서 요청마다 다른 데이터 타입을 처리할 수 있게 했습니다.
protocol APIRequestProtocol {
associatedtype ResponseData
var requestType: RequestType { get }
}
enum RequestType {
case userData
case imageData
}
여기서 associatedtype ResponseData는 요청마다 반환할 데이터 타입은 외부에서 자유롭게 지정 가능하게 만들었습니다.
그리고 어떤 요청인지 구분할 수 있게 requestType도 추가해줬어요.
struct GetUserRequest: APIRequestProtocol {
typealias ResponseData = Any // 데이터 타입을 Any로 두고, 내부에서 나눔
let requestType: RequestType
init(requestType: RequestType) {
self.requestType = requestType
}
}
일단 ResponseData는 Any 타입으로 둬서 User 데이터를 반환할지 Image를 반환할지는 나중에 내부에서 분기처리해주려고 합니다.
typealias APICompletion<Response, APIError: APIErrorProtocol> = (Result<Response, APIError>) -> Void
API 호출 결과를 처리하는 클로저는 비슷하게 적었던 적이 많아서 typealias로 미리 정의해둡니다. 매번 Result<…> → Void 클로저를 적기 귀찮고, 재사용도 가능하게 만들어줬어요.
protocol APIClientProtocol {
associatedtype Request: APIRequestProtocol
associatedtype APIError: APIErrorProtocol
func performRequest(request: Request, completion: @escaping APICompletion<Request.ResponseData, APIError>)
}
API 호출을 담당하는 Client 쪽도 프로토콜로 통일시켜서 새로운 데이터 타입이 필요하더라도 이 규칙을 따라 추가하면 되게 만들었습니다.
struct DefaultAPIClient: APIClientProtocol {
typealias Request = GetUserRequest
typealias APIError = DefaultAPIError
func performRequest(request: Request, completion: @escaping APICompletion<Request.ResponseData, APIError>) {
// 네트워크 요청 예시
switch request.requestType {
case .userData:
completion(.success(User(name: "madcow", age: 99)))
case .imageData:
completion(.success(Image(systemName: "sun.max.fill")))
}
// 에러가 필요하면
// completion(.failure(.serverError))
}
}
struct User: Decodable {
let name: String
let age: Int
}
• request.requestType에 따라 다른 데이터를 반환
• 클로저에서 반환하는 타입도 Request.ResponseData로 선언했기 때문에 유연하게 데이터 타입 변경 가능
struct TestView: View {
let apiService = DefaultAPIClient()
@State var img = Image(systemName: "moon")
var body: some View {
VStack {
img
Button {
apiService.performRequest(request: .init(requestType: .imageData)) { res in
switch res {
case .success(let data):
if let user = data as? User {
// User 처리
print(user.name)
} else if let image = data as? Image {
img = image
}
case .failure(let error):
print(error.message)
}
}
} label: {
Text("change!")
}
}
}
}
ResponseData를 Any로 두었기 때문에 실제로 사용할 때 if let으로 타입 캐스팅해서 분기처리를 해줘야 한다는 점이었습니다.Generic과 Protocol 조합으로 해결할 수 있을 것 같아요)특히, 에러 처리, 요청, 클로저 타입 모두 규칙화할 수 있어서 관리가 편해지고 새로운 API 요청이 추가되어도 기존 코드를 거의 건드리지 않고 확장할 수 있다는 점을 배워서 에러 처리나 클로저 타입도 깔끔하게 관리할 수 있을거 같은 자신감이 생긴 느낌?(근데 이번 글에서 에러처리는 자세하게 다루지 못한거 같아서 다음 게시글에서 좀 더 다뤄볼게요)
다만 TestView 예시처럼 ResponseData를 Any로 설정한 부분에서 매번 타입 캐스팅해주는 게 번거롭다는 느낌도 있어서 다음 글에서는 이 부분을 좀 더 깔끔하게 개선해보려 합니다.
그리고 이번 글에서는 네트워크 요청 자체는 시뮬레이션으로 처리했는데,
다음에는 실제 API와 함께 Async/Await, 혹은 Combine을 적용한 APIClient도 한 번 적용해볼 생각입니다!