ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • RxSwift로 서버 통신하기
    RxSwift 2022. 12. 19. 10:51

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

    이번 포스팅에서는 RxSwift를 쓰는 환경에서 서버 통신을 하는 틀을 만들어보겠습니다🙋🏻

     

    우선 서버 통신이 필요한 환경이 정말 필수적이고 많은분들이 RxSwift를 사용하고 있을거에요.

    그렇기에 저도 나름의 방식으로 RxSwift를 사용하면서 잘짜여진 서버 통신 템플릿을 만들고 쉽게 사용해보려합니다!

     

    먼저 들어가기전 사용된 라이브러리는 두가지입니다.

    Alamofire와 RxSwift

    Alamofire를 통해 조금 더 간편하게 서버 통신을 시킵니다.

    Moya를 활용하지 않은 이유는 네트워크 레이어를 추상화한것뿐이라 이 부분은 직접 구현하면서 조금 더 장점을 가져갔습니다.

     

    그럼 한번 보시죠🥸

     

    우선 가장 먼저 만들어볼것인 서버 통신 규약을 정해보는 Router 프로토콜과 해당 프로토콜을 따르면서 실제 서버 통신하는 역할을 가진 RotuerManager를 만들겠습니다.

    이 두가지를 만들고 실제 API 통신 객체까지 만들어 사용해볼께요!

     

    Router 프로토콜

    import Alamofire
    import Foundation
    
    public protocol Router {
      var baseURL: URL { get }
      var path: String { get }
      var method: HTTPMethod { get }
      var headers: [String: String]? { get }
      var task: Task { get }
    }
    
    public enum Task {
      // 추가적인 데이터 없는 Request
      case requestPlain
      
      // Encodable 타입의 Body를 설정한 Request
      case requestJSONEncodable(Encodable)
      
      // Encodable 타입의 Body와 custom encoder를 설정한 Request
      case requestCustomJSONEncodable(Encodable, encoder: JSONEncoder)
      
      // encode 된 parameter를 설정한 Request
      case requestParameters(parameters: [String: Any], encoding: ParameterEncoding)
    }

    우선 통신을 할때 당연히 필요한 요소들인 URL과 path, 통신 방법, 헤더에 담길것 그리고 그 외 데이터 요청 업무인 task 총 5가지를 가져야 합니다.

    이를 프로토콜로 지정해줍니다.

     

    그 다음으로 해당 프로토콜을 채택해 실제 통신을 구현하는 RouterManager를 만들어보겠습니다.

     

    RouterManager

    쪼오금 길기에 접어두겠습니다!

    더보기
    import Alamofire
    import Foundation
    import RxSwift
    
    // MARK: - RouterManager 구현
    public struct RouterManager<T: Router> {
      private let alamorefireSession: Session
      private let interceptor: Interceptor?
      
      public init(
        alamofireSession: Session = .default,
        interceptor: Interceptor? = nil,
      ) {
        self.alamorefireSession = alamofireSession
        self.interceptor = interceptor
      }
      
      public func request(router: T) -> Single<Data> {
        return sendRequest(router: router)
        }
      }
    }
    
    // MARK: - 통신에 사용될 Error 객체
    public struct RouterManagerError: CustomError {
      public var userInfo: [String: Any]?
      public var code: Code
      public var underlying: Error?
      public var errorBody: "응답 DTO"?
    
      public enum Code: Int {
        case failedRequest = 0
        case isNotSuccessful = 1
      }
      
      public init(
        code: RouterManagerError.Code,
        userInfo: [String: Any]? = nil,
        underlying: Error? = nil,
        response: AFDataResponse<Data>? = nil
      ) {
        self.code = code
        self.userInfo = userInfo
        self.underlying = underlying
        self.userInfo?["response"] = response
    
        if let data = response?.data {
          self.errorBody = try? JSONDecoder().decode("응답 DTO".self, from: data)
        }
      }
    }
    
    // MARK: - Custom Error 프로토콜
    public protocol OPGGError: CustomNSError {
      var code: Code { get }
      var userInfo: [String: Any]? { get }
      var underlying: Error? { get }
      
      associatedtype Code: RawRepresentable where Code.RawValue == Int
    }
    
    extension OPGGError {
      var errorDomain: String { "\(Self.self)" }
      var errorCode: Int { self.code.rawValue }
      var errorUserInfo: [String: Any]? {
        
        var userInfo: [String: Any] = self.userInfo ?? [:]
        
        userInfo["identifier"] = String(reflecting: code)
        userInfo[NSUnderlyingErrorKey] = underlying
        
        return userInfo
      }
    }
    
    // MARK: - 실제 RouterManager의 통신 구현부
    public extension RouterManager {
      // Alamofire를 통한 요청 메서드
      func sendRequest(router: T) -> Single<Data> {
        return Single.create(subscribe: { single in
          let dataRequest: DataRequest = makeDataRequest(router: router)
          
          dataRequest
            .responseData { response in
              switch response.result {
              case .success:
                guard let statusCode = response.response?.statusCode else { return }
                let isSuccessful = (200..<300).contains(statusCode)
                
                if isSuccessful {
                  guard let data = response.data else { return }
                  single(.success(data))
                } else {
                  let error = RouterManagerError(
                    code: .isNotSuccessful,
                    userInfo: ["router": router],
                    response: response
                  )
                  single(.failure(error))
                }
                
              case let .failure(underlyingError):
                let error = RouterManagerError(
                  code: .failedRequest,
                  userInfo: ["router": router],
                  underlying: underlyingError,
                  response: response
                )
                single(.failure(error))
              }
            }
          return Disposables.create()
        })
      }
      
      // Router의 task에 따라 request 데이터 생성 메서드
      private func makeDataRequest(router: Router) -> DataRequest {
        switch router.task {
        case .requestPlain:
          return self.alamorefireSession.request(
            "\(router.baseURL)\(router.path)",
            method: router.method,
            headers: HTTPHeaders(headers: router.headers),
            interceptor: interceptor
          )
          
        case let .requestJSONEncodable(parameters):
          let encodable = AnyEncodable(parameters)
          
          return self.alamorefireSession.request(
            "\(router.baseURL)\(router.path)",
            method: router.method,
            parameters: encodable,
            encoder: JSONParameterEncoder.default,
            headers: HTTPHeaders(headers: router.headers),
            interceptor: interceptor
          )
          
        case let .requestCustomJSONEncodable(parameters, encoder):
          let encodable = AnyEncodable(parameters)
          
          return self.alamorefireSession.request(
            "\(router.baseURL)\(router.path)",
            method: router.method,
            parameters: encodable,
            encoder: .json(encoder: encoder),
            headers: HTTPHeaders(headers: router.headers),
            interceptor: interceptor
          )
          
        case let .requestParameters(parameters, encoding):
          return self.alamorefireSession.request(
            "\(router.baseURL)\(router.path)",
            method: router.method,
            parameters: parameters,
            encoding: encoding,
            headers: HTTPHeaders(headers: router.headers),
            interceptor: interceptor
          )
        }
      }
      
    // MARK: - Header 초기화
    fileprivate extension HTTPHeaders {
      init?(headers: [String: String]?) {
        guard let headers = headers else {
          return nil
        }
        
        self.init(headers)
      }
    }
    
    // MARK: - AnyEncodable 객체
    fileprivate struct AnyEncodable: Encodable {
      private let encodable: Encodable
      
      public init(_ encodable: Encodable) {
        self.encodable = encodable
      }
      
      func encode(to encoder: Encoder) throws {
        try encodable.encode(to: encoder)
      }
    }

    보시면 우선 RouterManager 정의부에는 session과 interceptor를 가집니다.

    request 메서드를 호출하게 되면 실제 통신을 위한 request를 보내고 이에 대한 내부 처리가 이뤄져있습니다.

    에러에 대해서도 커스텀한 에러 프로토콜을 만들고 이를 채택해 정형화되고 서비스에 맞는 에러 형태를 만듭니다.

     

    이제 실제로 통신을 시켜보죠!

    extension하여 조금 더 가독성을 높여줬어요.

     

    sendRequest를 통해 구현을 해주는데 여기서 인자로 만든 라우터 타입을 받아 Single<Data> 타입으로 전달합니다.

    즉, 통신을 시키고 이에 대한 데이터를 해당 Observable을 구독하는곳에 전달하는 셈이죠.

    Single.create로 스트림을 만듭니다.

    이에 또 데이터 리퀘스트 타입을 만들기 위해 별도 메서드인 makeDataRequest에 라우터를 전달해 생성합니다.

    그럼 Router의 task 타입에 따라 알맞은 형태의 DataRequest 객체를 만들어 반환해주죠.

    이 객체로 responseData를 하여 통신 받은 결과를 가지고 성공일 시 single의 success에 data를 담아 넘겨주면 되고 아니면 failure에 error를 담아 넘겨주게됩니다.

     

    이럼 우선 통신 구현은 끝이나요!
    이제 실제로 서비스에 맞게 Router를 지정하고 통신시킬 수 있는 서비스를 만들어보겠습니다🕺🏻

     

    실제 통신 API

    import Alamofire
    import Foundation
    
    public enum NetworkAPI {
      case getUser(name: String)
    }
    
    extension NetworkAPI: Router {
      public var baseURL: URL {
        return URL(string: "서버 URL Host 주소")!
      }
      
      public var path: String {
        switch self {
        case let .getUser(name):
          return "/\(name)"
        }
      }
      
      public var method: HTTPMethod {
        switch self {
        case .getUSer:
          return .get
        }
      }
      
      public var task: Task {
        switch self {
        case .getUSer:
          return .requestPlain
        }
      }
      
      public var headers: [String: String]? {
        return ["Content-type": "application/json"]
      }
    }

    이건 각자 프로젝트 서버 입맛에 맞게 적용하고 추가하면 됩니다.

    우선 가상의 API를 만들어봤는데요.

    여기서는 유저의 정보만 받아오는 단순한 API 요청만 가지고 있습니다.

    Router 프로토콜을 상속하여 baseURL을 host URL로 지정해줍니다.

    path에는 위 NetworkAPI 열거값에 따라 원하는 path를 지정하죠.

    method와 task도 마찬가지입니다.

    headers는 다른게 있으면 수정하여 분기처리해 사용해도 되지만 왠만하면 해당 형식이니 우선 저렇게 가져갔습니다!

     

    그 다음으로 실제 이 API를 가지고 통신을 시킬 Service를 만들어볼께요!

     

    서버 통신 Service 객체

    public struct UserService {
      public let fetchUser: (_ name: String) -> Observable<Summoner>
      
      private init(fetchUser: @escaping (_ name: String) -> Observable<User>) {
        self.fetchUser = fetchUser
      }
    }
    
    extension UserService {
      public static let live = Self(
        fetchUser: { name in
          return RouterManager<NerworkAPI>
            .init()
            .request(router: .fetchUser(name: name))
            .map({ data -> "통신할 DTO" in
              do {
                return try JSONDecoder()
                  .decode(
                    "통신할 DTO".self,
                    from: data
                  )
              } catch {
                throw UserServiceError(
                  code: .decodeFailed,
                  userInfo: ["data": data],
                  underlying: error
                )
              }
            })
            .map { return $0.user }
            .asObservable()
        }
      }
    )}
    
    public struct UserServiceError: OPGGError {
      public var code: Code
      public var userInfo: [String: Any]?
      public var underlying: Error?
      
      public enum Code: Int {
        case decodeFailed = 0
      }
      
      public init(
        code: OPGGServiceError.Code,
        userInfo: [String: Any]? = nil,
        underlying: Error? = nil
      ) {
        self.code = code
        self.userInfo = userInfo
        self.underlying = underlying
      }
    }

    class일 필요는 딱히 없어 struct로 만듭니다.

    해당 서비스를 통해 찔러줄 API 종류를 프로퍼티로 가져가요.

    즉 NetworkAPI를 통해 통신하면 Observable<Data>가 방출되고 이에 따라 map을 돌려 data를 사용할곳에 맞는 DTO의 타입으로 변환하여 보내주게됩니다.

    여기서 할 일은 API 찌르고 디코딩을 시켜 성공 시 디코딩된 데이터에서 원하는 값을 반환하고 실패 시 에러를 던져주게 됩니다.

     

    여기까지하면 다 왔어요!

    이제 실제로 이 UserService를 호출하는 구현부를 보겠습니다🙌

     

    실제 코드 로직에서 UserService 호출

    VC나 원하는 Core 로직에서 호출해주면 됩니다.

    ...
    let userService: UserService.live
    ...
    userService.fetchUser("GREEN")
      .map { userInfo -> "원하는 형태" in
        // 원하는 동작
        // 주로 user나 특정 data에 userInfo를 넣음
      }
    ...

    이제 이렇게 간단히 userService 객체를 만들어 정해진 API 호출 메서드를 찔러주고 반환된 user 데이터를 가지고 원하는 로직을 구성시켜주면 끝입니다😊

     

    마무리

    이렇게 Moya를 사용하지 않고 RxSwift와 Alamofire를 통해 커스텀한 네트워크 통신 객체를 만들고 사용해봤어요ㅎㅎ

    틀을 한번 정해두면 어느곳이든 통일되게 손쉽게 통신을 사용할 수 있어 편리합니다🙌

    'RxSwift' 카테고리의 다른 글

    RxSwift - Time Based Operator  (0) 2021.11.17
    RxSwift - Combining Operator  (0) 2021.11.12
    RxSwift - Transforming Operator  (0) 2021.11.10
    RxSwift - Filtering Operators  (0) 2021.11.08
    RxSwift - Subject  (0) 2021.11.03
Designed by Tistory.