Swift - associatedtype, typealias

SDTCOW·2025년 3월 17일

Swift

목록 보기
2/7

안녕하세요.

오늘은 Swift에서 API 호출 구조를 Generic associatedtype typealias를 활용해 설계하는 방법에 대해 적어보려고 합니다.

왜 이 주제를 적냐면 저번 게시글에 작성했던 객체가 함수를 만났을 때 영상에서 몰랐던게 Generic인 줄 알았는데 associatedtypetypealias를 잘 몰랐던 거더라구요.

그래서 이번 기회에 정리하고 이해한 내용을 정리해두려고 작성하게 되었습니다.

저는 이전에는 Swift에서 API를 호출할 때, 대부분 URLSession으로 요청을 보내고 → JSONDecoder로 디코딩한 뒤 → 필요한 데이터만 뽑아서 처리하는 방식을 사용해왔습니다.

그런데 이런 방식으로 작성하다 보니 몇 가지 불편함이 계속 쌓이더라구요.
요청마다 비슷한 코드가 반복되고, 에러 처리도 여기저기 흩어져서
나중에 “어디서 처리했더라?” 하고 헷갈리는 경우도 많았습니다.
그리고 새로운 API 요청이 추가될 때마다 이전 코드를 복사해서 또 비슷한 코드를 만드는 일이 반복됐구요.

그래서 예전부터
“API 호출하는 클래스를 하나로 만들어서 한 함수에서 여러 타입이 반환될 수 있게 하면 좋겠다” 생각만 하고 있었는데, 이번에 Generic associatedtype typealias 개념을 정리하면서 비슷하게? 구현을 해본 내용을 적어보았습니다.

만들고자 하는 구조

  1. API 요청마다 다른 데이터 타입을 반환할 수 있다.
  2. 에러 처리도 하나의 공통 규칙으로 묶어서 관리한다.
  3. 중복되는 클로저 타입을 typealias로 정리해 깔끔하게 만든다.
  4. 새로운 API 요청이 추가되어도 기존 구조에 쉽게 확장할 수 있다.

1. 에러 구조

먼저 여기저기에 흩어져있던 에러 타입들을 공통 프로토콜로 정리해봤습니다.

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"
        }
    }
}
  • APIErrorProtocol을 만들고 API 요청에 대한 에러는 이 규칙을 따르게 했습니다.
  • 에러가 발생하면 message 속성으로 사용자에게 보여줄 메시지를 가져올 수 있도록 했습니다.

2. API 요청도 프로토콜화

보통 API요청은 request에 따라 결과로 받는 데이터가 다르죠?
그래서 요청은 이렇게 associatedtype을 사용해서 요청마다 다른 데이터 타입을 처리할 수 있게 했습니다.

protocol APIRequestProtocol {
    associatedtype ResponseData
    
    var requestType: RequestType { get }
}

enum RequestType {
    case userData
    case imageData
}

여기서 associatedtype ResponseData는 요청마다 반환할 데이터 타입은 외부에서 자유롭게 지정 가능하게 만들었습니다.
그리고 어떤 요청인지 구분할 수 있게 requestType도 추가해줬어요.

3. 실제 요청 만들기

struct GetUserRequest: APIRequestProtocol {
    typealias ResponseData = Any // 데이터 타입을 Any로 두고, 내부에서 나눔

    let requestType: RequestType
    
    init(requestType: RequestType) {
        self.requestType = requestType
    }
}

일단 ResponseData는 Any 타입으로 둬서 User 데이터를 반환할지 Image를 반환할지는 나중에 내부에서 분기처리해주려고 합니다.

4. 클로저 타입은 typealias로

typealias APICompletion<Response, APIError: APIErrorProtocol> = (Result<Response, APIError>) -> Void

API 호출 결과를 처리하는 클로저는 비슷하게 적었던 적이 많아서 typealias로 미리 정의해둡니다. 매번 Result<…> → Void 클로저를 적기 귀찮고, 재사용도 가능하게 만들어줬어요.

5. API Client의 프로토콜

protocol APIClientProtocol {
    associatedtype Request: APIRequestProtocol
    associatedtype APIError: APIErrorProtocol
    
    func performRequest(request: Request, completion: @escaping APICompletion<Request.ResponseData, APIError>)
}

API 호출을 담당하는 Client 쪽도 프로토콜로 통일시켜서 새로운 데이터 타입이 필요하더라도 이 규칙을 따라 추가하면 되게 만들었습니다.

6. 실제 APIClient 구현

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!")
            }
        }
    }
}
  • 이렇게 테스트 해보며 한 가지 아쉬웠던 점은 지금 방식에서는 ResponseDataAny로 두었기 때문에 실제로 사용할 때 if let으로 타입 캐스팅해서 분기처리를 해줘야 한다는 점이었습니다.
    User 타입인지, Image 타입인지를 사용하는 곳에서 항상 확인해야 해서 코드가 깔끔하다고는 할 수 없겠더라구요.
    이 부분도 다음에는 요청마다 명확하게 타입을 지정해서, 사용하는 쪽에서 타입 캐스팅 없이 바로 사용할 수 있도록 개선해볼까 생각 중입니다. (아마 GenericProtocol 조합으로 해결할 수 있을 것 같아요)

이렇게 공부를 해보고 예시를 구현해보면서 확실히 느꼈던 건 `associatedtype` `Generic` `typealias` 개념만 잘 잡아두면 코드 재사용성과 확장성이 훨씬 좋아진다는 점이었습니다.

특히, 에러 처리, 요청, 클로저 타입 모두 규칙화할 수 있어서 관리가 편해지고 새로운 API 요청이 추가되어도 기존 코드를 거의 건드리지 않고 확장할 수 있다는 점을 배워서 에러 처리나 클로저 타입도 깔끔하게 관리할 수 있을거 같은 자신감이 생긴 느낌?(근데 이번 글에서 에러처리는 자세하게 다루지 못한거 같아서 다음 게시글에서 좀 더 다뤄볼게요)

다만 TestView 예시처럼 ResponseData를 Any로 설정한 부분에서 매번 타입 캐스팅해주는 게 번거롭다는 느낌도 있어서 다음 글에서는 이 부분을 좀 더 깔끔하게 개선해보려 합니다.

그리고 이번 글에서는 네트워크 요청 자체는 시뮬레이션으로 처리했는데,
다음에는 실제 API와 함께 Async/Await, 혹은 Combine을 적용한 APIClient도 한 번 적용해볼 생각입니다!

profile
iOS 개발자가 되고싶은 사람

0개의 댓글