Library

Get 라이브러리로 심플한 웹 API 클라이언트 구현하기

GREEN.1229 2023. 2. 13. 14:18

안녕하세요. 그린입니다🍏

오늘은 Get이라는 외부 라이브러리에 대해 학습해보겠습니다🙌

 

우리가 익숙하게 알고 있는 네트워크 통신

당연한 말이지만 클라이언트 개발을 하면서 API 네트워크 통신은 안할래야 안할수가 없습니다.

네트워크 통신을 위해 우리는 기본적으로 애플에서 제공해주는 URLSession을 사용하기도 하고 Alamofire나 Moya와 같은 외부 라이브러리의 도움을 받아 URLSession을 추상화하여 조금 더 쉽게 사용하기도 합니다.

자 그럼 여기다 하나를 더 추가해서 알아보려고 합니다.

그게 바로 오늘 해볼 Get이라는 라이브러리입니다.

 

구구절절 자세한 Get에 대해 파해쳐보시죠!

 

Get 라이브러리란?

Get이라는 라이브러리는 async/await를 사용하여 구축된 간결한 Swift 웹 API 클라이언트입니다.

즉 Get은 Request<Response>의 타입을 사용하여 네트워크 요청을 모델링하기 위한 명확하고 편리한 API를 제공합니다.

아래와 같이 간편하게 구성해서 네트워크 요청을 해줄 수 있습니다.

// Create a client
let client = APIClient(baseURL: URL(string: "https://api.github.com"))

// 요청 값 저장
let user: User = try await client.send(Request(path: "/user")).value

// 요청
var request = Request(path: "/user/emails", method: .post, body: ["alex@me.com"]
try await client.send(request)

보시면 아주 간단하게 APIClient 타입의 상수를 만들어 await로 요청을 넣으면 끝입니다.

 

그럼 여기서 하나씩 천천히 짚어보려해요.

우선 APIClient의 구성이 어떻게 되어 있고 어떤 파라미터들을 넣어줄 수 있는지 확인해보시죠!

(swift 5.7 기준으로 작성되었습니다.)

 

APIClient 만들기

public actor APIClient {
    /// The configuration with which the client was initialized with.
    public nonisolated let configuration: Configuration
    /// The underlying `URLSession` instance.
    public nonisolated let session: URLSession

    private let decoder: JSONDecoder
    private let encoder: JSONEncoder
    private let delegate: APIClientDelegate
    private let dataLoader = DataLoader()

    /// The configuration for ``APIClient``.
    public struct Configuration: @unchecked Sendable {
        /// A base URL. For example, `"https://api.github.com"`.
        public var baseURL: URL?
        /// The client delegate. The client holds a strong reference to it.
        public var delegate: APIClientDelegate?
        /// By default, `URLSessionConfiguration.default`.
        public var sessionConfiguration: URLSessionConfiguration = .default
        /// The (optional) URLSession delegate that allows you to monitor the underlying URLSession.
        public var sessionDelegate: URLSessionDelegate?
        /// Overrides the default delegate queue.
        public var sessionDelegateQueue: OperationQueue?
        /// By default, uses `.iso8601` date decoding strategy.
        public var decoder: JSONDecoder
        /// By default, uses `.iso8601` date encoding strategy.
        public var encoder: JSONEncoder

        /// Initializes the configuration.
        public init(
            baseURL: URL?,
            sessionConfiguration: URLSessionConfiguration = .default,
            delegate: APIClientDelegate? = nil
        ) {
            self.baseURL = baseURL
            self.sessionConfiguration = sessionConfiguration
            self.delegate = delegate
            self.decoder = JSONDecoder()
            self.decoder.dateDecodingStrategy = .iso8601
            self.encoder = JSONEncoder()
            self.encoder.dateEncodingStrategy = .iso8601
        }
    }

    // MARK: Initializers
    public init(baseURL: URL?, _ configure: @Sendable (inout APIClient.Configuration) -> Void = { _ in }) {
        var configuration = Configuration(baseURL: baseURL)
        configure(&configuration)
        self.init(configuration: configuration)
    }
}

보시면 Configuration에 네트워크 요청을 위한 baseURL과 APIClientDelegate 타입의 프로퍼티 등 네트워킹의 설정들을 담고 있습니다.

그리고 디코더/인코더도 기본적으로 정의되어 있기에 우리가 유심히 봐볼 부분은 baseURL과 Configuration 타입을 구성하는것에 초점을 맞추면 됩니다.

처음 클라이언트를 만들고 네트워크 요청을 하는 예제를 보면 baseURL은 필수로 넣어주고 Configuration은 디폴트 값으로 가져갔어요.

기본 HTTP method는 get으로 설정되어 있기에 저렇게만으로도 가능한것이죠.

우선 이렇게 APIClient를 만든다는것을 인지하면 됩니다.

 

그 다음으로 살짝 짚고 넘어가볼것이 Configuration에서 delegate나 sessionDelegate등과 같은 프로토콜 타입을 디폴트가 아닌 별도 커스텀하게 구현해준다면 아래와 같이 심어줄 수 있습니다.

let client = APIClient(baseURL: URL(string: "https://api.github.com")) {
    $0.delegate = NetworkManagerDelegate()
    $0.sessionDelegate = delegate
    ...
}

// Set APIClientDelegate
final class NetworkManagerDelegate: APIClientDelegate {
  let decoder = JSONDecoder()
  
  func client(_ client: APIClient, willSendRequest request: inout URLRequest) async throws {
    AuthManager.shouldRetry = false
    // AUTH
    if let urlString = request.url?.absoluteString,
       isAccessTokenNeedPath(from: urlString) // 액세스 토큰 필수 API인 경우
    {
      let accessToken = Defaults[.accessToken]
      let newAccessToken = accessToken.replacingOccurrences(of: "bearer", with: "")
        .trimmingCharacters(in: .whitespaces)
      
      let refreshToken = Defaults[.refreshToken]
      let newRefreshToken = refreshToken.replacingOccurrences(of: "bearer", with: "")
        .trimmingCharacters(in: .whitespaces)
      
      if !newAccessToken.isEmpty {
        request.setValue("bearer \(newAccessToken)", forHTTPHeaderField: "Authorization")
      }
      
      if !newRefreshToken.isEmpty {
        request.setValue("bearer \(newRefreshToken)", forHTTPHeaderField: "refreshToken")
      }
    }
  }
  ...
}

// Set URLSessionDataDelegate
let delegate: URLSessionDataDelegate = ...

이런식으로 프로토콜을 채택한 타입을 만들면서 내부 필수 구현들을 커스텀하게 구현하고 이걸 APIClient 설정 값으로 부여하여 클라이언트를 만들 수 있습니다.

 

그 다음으로 실제 만들어진 APIClient를 통해 네트워크 요청을 해보겠습니다.

 

send 메서드로 네트워크 요청하기

APIClient actor 구현체를 뜯어보면 다양하게 있는데 그 중 send라는 메서드가 내장되어 있어요.

public actor APIClient {
    @discardableResult public func send<T: Decodable>(
        _ request: Request<T>,
        delegate: URLSessionDataDelegate? = nil,
        configure: ((inout URLRequest) throws -> Void)? = nil
    ) async throws -> Response<T> {
        let response = try await data(for: request, delegate: delegate, configure: configure)
        let decoder = self.delegate.client(self, decoderForRequest: request) ?? self.decoder
        let value: T = try await decode(response.data, using: decoder)
        return response.map { _ in value }
    }
    ...
}

보시면 Request<T> 타입을 받는데 여기서 T 제네릭에는 디코딩 가능한 타입을 넣어줘야합니다.

그리고 APIClient와 비슷하게 delegate나 configure을 커스텀해줄 수 있어요.

즉 필수로 Request를 만들어 넣어준다! 라고 이해하고 가시죠🕺🏻

참고로 APIClient 구현체에는 send뿐 아니라 data, download, upload 등 다양한 메서드들이 구현되어 있으니 상황에 맞게 사용하면 됩니다!🙏🏻

 

여기서 Request 구현체를 뜯어볼께요.
public struct Request<Response>: @unchecked Sendable {
    public var method: HTTPMethod
    public var url: URL?
    public var query: [(String, String?)]?
    public var body: Encodable?
    public var headers: [String: String]?
    public var id: String?

    // Use URL
    public init(
        url: URL,
        method: HTTPMethod = .get,
        query: [(String, String?)]? = nil,
        body: Encodable? = nil,
        headers: [String: String]? = nil,
        id: String? = nil
    ) {
        self.method = method
        self.url = url
        self.query = query
        self.headers = headers
        self.body = body
        self.id = id
    }

    // Use Path
    public init(
        path: String,
        method: HTTPMethod = .get,
        query: [(String, String?)]? = nil,
        body: Encodable? = nil,
        headers: [String: String]? = nil,
        id: String? = nil
    ) {
        self.method = method
        self.url = URL(string: path.isEmpty ? "/" : path)
        self.query = query
        self.headers = headers
        self.body = body
        self.id = id
    }
}

보시면 기본적인 Request 생성 시 Response 타입을 제네릭하게 받습니다.

즉 요청한 결과 리스폰스에 대해 타입 매칭을 시켜주는것이죠.

필요한 값으로는 보시는것처럼 method, url, query, body, headers, id 등 통신에 필요한것이 있어요.

이니셜라이저에서는 보면 두가지 타입이 있는데 URL로 요청하느냐 아니면 기존 client의 baseURL에서의 path로 이용하느냐 입니다.

 

그럼 간략히 구현을 해보면서 보시죠!
// 네트워크 매니저 생성
public struct NetworkManager {
  public let client: APIClient
  
  public init(baseURL: String) {
    self.client = APIClient(baseURL: URL(string: baseURL)) {
      $0.delegate = NetworkManagerDelegate()
      ...
    }
  }
}

// 해당 API 생성
enum GreenAPI {
  private static let manager = NetworkManager(baseURL: "https://green.com")
}

// Endpoint 구현
enum GreenEndpoint {
  case getGreenInfo
  
  var path: String {
    switch self {
    case .getGreenInfo: return "/info"
    }
  }
}

// 네트워크 통신 종류별 메서드 구현
extension GreenAPI {
  /// 그린 정보 가져오기
  static func getGreenInfo() async throws -> Response<GreenModel> {
    let request: Request<GreenModel> = Request(
      path: GreenEndpoint.getGreenInfo.path,
      method: .get
    )
    return try await manager.client.send(request)
  }
  ...
}

우선 중복이긴 하지만 네트워크 매니저를 생성합니다.

그 다음 각 API 도메인에서 저는 GreenAPI를 만들어볼께요.

GreenAPI 열거 타입 내부 static하게 네트워크 매니저 프로퍼티를 baseURL과 함께 생성해줍니다.

그 후 각 네트워크 요청을 가질 Endpoint를 case 별로 정리하고 path를 지정해줍니다.

마지막으로 기존 GreenAPI를 확장시켜 통신 메서드 구현체를 구현해주면됩니다.

예를들어 저처럼 그린 정보 가져오기 기능을 해줄 수 있도록 만든다면 해당 통신 후 리스폰스 타입을 반환하도록 구성하고 내부에서는 request를 만드는 것이죠🙌

path를 지정하고 HTTP method를 지정한 후 해당 request를 send 메서드를 통해 담아 통신시켜주면 됩니다.

 

그럼 통신 시켜볼까요?

MVVM 느낌으로 가정하고 ViewModel에서 처리해볼께요.

final class GreenViewModel: ObservableObject {
  @Published var greenAge: Int = 7
  ...

  @MainActor
  func getGreenInfo() async throws {
    let response = try await GreenAPI.getGreenInfo
    
    // 데이터 바인딩 및 처리
    greenAge = response.value.age
    ...
  }
}

요런식으로간단히 통신시키고 해당 value에서 원하는 값을 매칭해줄 수 있습니다.

 

네트워크 요청 시 다양한 부가 기능 해주기

위에서 봤던 기본적인 빠른 요청 외에도 데이터를 파일로 다운로드/업로드 하거나 인증 및 재시도, 로깅 등을 지원해주는 역할도 할 수 있습니다.

이것들은 send 요청 시 클로저 인자로 담아 처리해줄 수 있습니다.

let delegate: URLSessionDataDelegate = ...
let response = try await client.send(Paths.user.get, delegate: delegate) {
  // 캐시 정책 설정
  $0.cachePolicy = .reloadIgnoringLocalCacheData
}

 

마무리

뭔가 코드를 좀 까보면서 봤기에 중구난방이 될 수 있었던 챕터 같아요🥲

잘못된 부분이나 빠진 부분이 있다면 조언 부탁드립니다🙏🏻

 

[참고 자료]

https://github.com/kean/Get

 

GitHub - kean/Get: Web API client built using async/await

Web API client built using async/await. Contribute to kean/Get development by creating an account on GitHub.

github.com