Swift - API 요청 구조 설계

SDTCOW·2025년 3월 20일

Swift

목록 보기
3/7

안녕하세요.

오늘은 저번 게시글에 이어 Swift에서 API 호출 구조를 확장성 있게, 타입 안전성도 챙기면서 깔끔하게 설계할 수 있을지 고민하다가 두 가지 패턴으로 각각 구현해본 내용을 정리해보려고 합니다.

왜 이 주제를 적냐면 저번 게시글에서 Generic, associatedtype, typealias를 이용해 API 호출 구조를 한번 정리했었는데, 그때 ResponseData를 Any로 설정해서 사용하는 쪽에서 매번 타입 캐스팅을 해줘야 했던 게 마음에 안들었었거든요.

그래서 이번엔 타입 캐스팅 없이, 더 명확하게 타입을 지정할 수 있는 방법은 없을까 고민하면서 두 가지 방법으로 리팩토링을 해봤습니다.


만들고자 했던 구조
1. API 요청마다 반환 데이터 타입을 명확히 지정하고 싶다
2. 에러 처리는 공통 규칙으로 통일
3. 새로운 API 요청 추가 시 기존 코드 거의 건드리지 않고 확장
4. 요청 처리 로직은 최대한 깔끔하게

1. struct를 넘기는 방식

첫번째 방법으로는 요청마다 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)
}

느낀 점:

  • 요청마다 반환 타입이 명확하게 지정되어 타입 캐스팅 필요 없음
  • struct만 선언해주면 되니 유연하게 커스터마이즈하기 쉬움
  • 다만 호출부에서 GetUserRequest()처럼 직접 struct 생성해야 해서 사용하는 struct 따로 관리 필요

2. Factory 패턴으로 enum 통해 요청

두 번째로는 요청 종류를 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)
}

느낀 점:

  • 호출할 때 .user("1")처럼 enum 한 줄로 요청 가능해서 훨씬 깔끔
  • 요청 추가할 때 Factory에서 한 군데만 수정하면 되니까 유지보수도 편함
  • 단점은 Factory 내부에 switch문이 많아지면 코드가 길어질 수 있음
  • 사용하는 곳에서 타입 캐스팅 필요

결론

두 방식 다 써보면서 확실히 느낀 건,
타입 안전성과 확장성 둘 다 충분히 확보할 수 있다는 점이었어요.

  • 작은 프로젝트나 요청마다 세부 커스터마이징 필요할 땐 Request struct 직접 방식이 더 편했고,
  • API 요청 종류가 많고, 일관성 있게 관리하고 싶을 땐 Factory 패턴이 훨씬 깔끔하더라구요.

근데, 이렇게 API를 요청할 때 struct구조를 넘기는게 괜찮은 방법인가..? 라는 생각이 들었어요. 성능이나 CS 관점에서 괜찮은건지, 그래서 이 부분은 gpt에게 물어봤습니다.

GPT 답변

1. Swift에서 struct로 API 요청 정보를 관리하는 건 흔한 패턴인가?

결론: 굉장히 흔하고 자연스러운 패턴입니다.

  • 대부분의 네트워크 라이브러리(예: Moya, Alamofire 커스텀 구현 등)에서도 요청 정보를 struct or enum으로 캡슐화하는 방식 사용.
  • 특히 Swift에서는 struct가 value type → thread-safe, 복사 비용 가벼움 → API 요청 정보 같은 immutable data를 담기에 적합.
  • struct로 요청 정보를 만들면, 요청마다 필수로 담아야 할 속성(HTTPMethod, URL, headers 등)을 강제할 수 있어, 코드 일관성과 확장성도 좋아짐.

2. 성능 측면에서 struct로 요청 정보 넘기는 것의 비용은?

  • Swift의 struct는 스택 메모리에 할당되는 value type.
  • 대부분의 요청 정보는 단순한 값들(String, URL, Int 등)이기 때문에 메모리 복사 비용은 무시해도 될 정도.
  • 특히 네트워크 요청은 IO bound 작업 (네트워크 대기 시간 수십~수백 ms) → struct 복사 비용은 거의 0에 가까움.
  • struct 자체는 작은 데이터 덩어리라 ARC(참조 카운팅)도 필요 없고, Copy-on-Write도 걱정할 필요 없음.

3. OOP / 함수형 관점에서 이 구조의 장점

  • OOP 측면:
  • SRP(단일 책임 원칙): 요청 정보를 담는 struct는 오직 요청 정보만 담고, 로직은 APIClient가 수행.
  • 확장성: 새로운 API 요청 추가 시 struct 하나 만들면 됨 → 다른 코드 수정 최소화 (Open/Closed Principle에도 적합).
  • 함수형 측면:
  • struct는 immutable value → Side Effect 줄이기 좋음.
  • 순수 함수처럼 사용할 수 있어 테스트도 쉽고 예측 가능.

4. 객체 대신 struct로 쓸 때의 주의점

사실 struct를 사용할 때 단 하나만 주의하면 됩니다
“내부에 reference type (예: class, closure 등)이 있을 경우, 복사 비용 주의”
근데 API 요청 정보 struct는 대부분

struct GetUserRequest {
let url: URL
let headers: [String: String]
}
이 정도 값들이라서 무거운 reference type이 들어가는 경우가 거의 없음 → 문제 X.

다음에는 이 구조를 기반으로 Async/Await 활용, Combine으로 리팩토링하는 버전도 한 번 정리해보려 합니다!

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

0개의 댓글