[새싹 iOS] 10주차_네트워크 코드 추상화

임승섭·2023년 9월 20일
1

새싹 iOS

목록 보기
24/45

Unsplash API 3개를 이용하는 네트워크 통신 코드를 추상화한다 (Alamofire 활용)


1. Basic

  • 싱글톤 패턴을 이용한다

  • API마다 함수를 각각 구현한다

  • 통신을 위해 필요한 값들은 함수 내에서 상수로 선언한다

    final class NetworkBasic {
    	
        // 싱글톤 패턴
        static let shared = NetworkBasic()
        private init() { }
    
        // 1. 사진 검색
        func request(query: String, completionHandler: @escaping (Photo?, Error?) -> Void) {
    
            let key = "tHisIsunsPlaShKey"
    
            let url = "https://api.unsplash.com/search/photos"
    
            let headers: HTTPHeaders = ["Authorization" : "Client-ID \(key)"]
    
            let query = ["query": query]
    	
            AF.request(url, method: .get, parameters: query, encoding: URLEncoding(destination: .queryString), headers: headers)
                .responseDecodable(of: Photo.self) { response in
                    switch response.result {
                    case .success(let data):
                        completionHandler(data, nil)
                    case .failure(let error):
                        completionHandler(nil, error)
                    }
                }
        }
    	
        // 2. 랜덤 사진
        func random(completionHandler: @escaping (PhotoResult?, Error?) -> Void) {
    
            let key = "tHisIsunsPlaShKey"
    
            let url = "https://api.unsplash.com/photos/random"
    
            let headers: HTTPHeaders = ["Authorization" : "Client-ID \(key)"]
    
            AF.request(url, method: .get, headers: headers)
                .responseDecodable(of: PhotoResult.self) { response in
                    switch response.result {
                    case .success(let data):
                        completionHandler(data, nil)
                    case .failure(let error):
                        completionHandler(nil, error)
                    }
                }
        }
    	
        // 3. 사진 상세
        func detailPhoto(id: String, completionHandler: @escaping (PhotoResult?, Error?) -> Void) {
    
            let key = "tHisIsunsPlaShKey"
    
            let url = "https://api.unsplash.com/photos/\(id)"
    
            let headers: HTTPHeaders = ["Authorization" : "Client-ID \(key)"]
    
            AF.request(url, method: .get, headers: headers)
                .responseDecodable(of: PhotoResult.self) { response in
                    switch response.result {
                    case .success(let data):
                        completionHandler(data, nil)
                    case .failure(let error):
                        completionHandler(nil, error)
                    }
                }
    
        }
    }

2. Enum + 연산 프로퍼티

  • 1 코드는 모든 함수에 같은 key, headers, method 값이 중복되어 있다

  • url도 앞부분은 거의 비슷하고, / 뒷부분만 차이가 있다

  • 따라서 각 api를 열거형 case로 나누고, 해당 값들은 연산 프로퍼티로 가져온다

    enum SeSACAPI {
        // key는 항상 동일하다
        private static let key = "tHisIsunsPlaShKey"
    	
        // 1, 2, 3 케이스를 나눈다
        // 필요한 추가 파라미터는 열거형의 연관값을 이용한다
        case search(query: String)
        case random
        case photo(id: String)  
    	
        // url
        var baseURL: String {   
            return  "https://api.unsplash.com/"
        }
        var endpoint: URL {
            switch self {
            case .search:
                return URL(string: baseURL + "search/photos")!
            case .random:
                return URL(string: baseURL + "photos/random")!
            case .photo(let id):
                return URL(string: baseURL + "photos/\(id)")!
            }
        }
    	
        // header
        var header: HTTPHeaders {
            return ["Authorization" : "Client-ID \(SeSACAPI.key)"]
        }
    	
        // method
        var method: HTTPMethod {
            return .get
        }
    	
        // query
        var query: [String: String] {
            switch self {
            case .search(let query):
                return ["query": query]
            case .random, .photo:
                return ["": ""]
            }
        }
    }
  • 결과적으로 함수 내부를 간소화할 수 있다 (사진 검색)

    func request(query: String, completionHandler: @escaping (Photo?, Error?) -> Void) {
    
            let api = SeSACAPI.search(query: query)
    
            AF.request(api.endpoint, method: api.method, parameters: api.query, encoding: URLEncoding(destination: .queryString), headers: api.header)
                .responseDecodable(of: Photo.self) { response in
                    switch response.result {
                    case .success(let data):
                        completionHandler(data, nil)
                    case .failure(let error):
                        completionHandler(nil, error)
                    }
                }
    }

3. Result type

  • 현재 completionHandler의 매개변수는 (Photo?, Error?)이다
  • 통신에 성공하면 Photo?에 값이 들어가고 Error?는 nil,
    실패하면 Error?에 값이 들어가고 Photo?는 nil이다
  • 하지만 이건 나만 아는 거고, 코드만 봤을 때는 경우의 수가 4개가 나온다
  • 만약 매개변수 개수가 3개면 경우의 수는 8개가 나올 것이다
  • 불필요한 경우의 수를 줄이고 성공 or 실패 두 가지로만 나눠지도록 Result 열거형을 사용한다

  • 추가적인 장점은, 매개변수에 대한 옵셔널 바인딩을 할 필요가 없어진다

  • 함수 선언 (랜덤 사진)

    func random(completionHandler: @escaping (Result<PhotoResult, Error>) -> Void) {
    
            let api = SeSACAPI.random
    
            AF.request(api.endpoint, method: api.method, headers: api.header)
                .responseDecodable(of: PhotoResult.self) { response in
                    switch response.result {
                    case .success(let data):
                        completionHandler(.success(data))
                    case .failure(let error):
                        completionHandler(.failure(error))
                    }
                }
    }
  • 함수 실행 (비교)
    • (Photo?, Error?)
      NetworkBasic.shared.random { photo, error in
                if let photo { print("성공 : ", photo)}
                if let error { print("실패 : ", error)}
       }
    • Result<Photo, Error>
      NetworkBasic.shared.random { response in
                switch response {
                case .success(let success):
                    dump(success)
                case .failure(let failure):
                    print(failure)
                }
       }

4. Error enum

  • 네트워크 통신에 실패했을 때, 상태 코드(statusCode)로 뭐가 문제인지 파악할 수 있다

  • 대표적인 상태 코드(401, 403, ...)를 rawValue로 갖는 열거형을 만든다

  • 각 케이스별로 내가 원하는 description을 연산 프로퍼티로 받는다

    enum SeSACError: Int, /*Error,*/ LocalizedError {
        case unauthorized = 401
        case permissionDenied = 403
        case invalidServer = 500
        case missingParameter = 400
    
        var errorDescription: String {
            switch self {
            case .unauthorized:
                return "인증 정보가 없어유"
            case .permissionDenied:
                return "권한이 없어요"
            case .invalidServer:
                return "서버에 문제있어요"
            case .missingParameter:
                return "파라미터가 없어요"
            }
        }
    }
    • Localized Error
      • 에러에 대한 메세지를 반환하는 역할
      • 기본값이 없기 때문에 그냥 접근하면 nil이 리턴되고, errorDescription을 선언한 것처럼 연산 프로퍼티 형식으로 활용할 수 있을 것 같다
      • 이미 Error 프로토콜을 채택하고 있기 때문에 SeSACError에서는 얘만 채택해도 괜찮다

  • 함수 선언

    func random(completionHandler: @escaping (Result<PhotoResult, SeSACError>) -> Void) {
    
            let api = SeSACAPI.random
    
            AF.request(api.endpoint, method: api.method, headers: api.header)
                .responseDecodable(of: PhotoResult.self) { response in
                    switch response.result {
                    case .success(let data):
                        completionHandler(.success(data))
                    case .failure(_):
                        let statusCode = response.response?.statusCode ?? 500
                        guard let error = SeSACError(rawValue: statusCode) else { return }
    
                        completionHandler(.failure(error))
                    }
                }
    }
    • Result에 들어가는 제네릭 타입도 SeSACError로 바꿔준다
    • 통신 실패 시 상태 코드로 어떤 에러인지 판단하기 때문에,
      기존에 failure(let error)에서 error값을 사용하지 않는다.
      따라서 와일드카드로 바꿔준다
  • 함수 실행
    NetworkBasic.shared.random { response in
            switch response {
            case .success(let success):
                dump(success)
            case .failure(let failure):
                print(failure.errorDescription)		// 연산 프로퍼티
                print(failure.localizedDescription)	// Error 프로토콜의 프로퍼티
                print(failure.failureReason)	    // LocalizedError 프로토콜의 프로퍼티
            }
    }
  • 출력 로그 (401 에러)

5. Generic

  • 여태까지 정리한 코드를 보면,

    • request
    func request(query: String, completionHandler: @escaping (Result<Photo, SeSACError>) -> Void) {
    
        let api = SeSACAPI.search(query: query)
    
        AF.request(api.endpoint, method: api.method, parameters: api.query, encoding: URLEncoding(destination: .queryString), headers: api.header)
            .responseDecodable(of: Photo.self) { response in
                switch response.result {
                case .success(let data):
                    completionHandler(.success(data))
                case .failure(_):
                    let statusCode = response.response?.statusCode ?? 500
                    guard let error = SeSACError(rawValue: statusCode) else { return }
    
                    completionHandler(.failure(error))
                }
            }
    }
    • random
    func random(completionHandler: @escaping (Result<PhotoResult, SeSACError>) -> Void) {
    
        let api = SeSACAPI.random
    
        AF.request(api.endpoint, method: api.method, headers: api.header)
            .responseDecodable(of: PhotoResult.self) { response in
                switch response.result {
                case .success(let data):
                    completionHandler(.success(data))
                case .failure(_):
                    let statusCode = response.response?.statusCode ?? 500
                    guard let error = SeSACError(rawValue: statusCode) else { return }
    
                    completionHandler(.failure(error))
                }
            }
    }
    • detail
    func detailPhoto(id: String, completionHandler: @escaping (Result<PhotoResult, SeSACError>) -> Void) {
    
        let api = SeSACAPI.photo(id: id)
    
        AF.request(api.endpoint, method: api.method, headers: api.header)
            .responseDecodable(of: PhotoResult.self) { response in
                switch response.result {
                case .success(let data):
                    completionHandler(.success(data))
                case .failure(_):
                    let statusCode = response.response?.statusCode ?? 500
                    guard let error = SeSACError(rawValue: statusCode) else { return }
    
                    completionHandler(.failure(error))
                }
            }
    }
  • 셋 다 거의 비슷하게 생겼다.

  • 그래서 제네릭 타입을 이용해서 위 세개의 함수를 하나의 함수로 합친다
  • 매개변수로 SeSACAPI 열거형을 받아서 어떤 요청인지 구분하고,
    사용할 구조체(Photo, PhotoResult)도 매개변수로 받아준다

func request<T: Decodable>(type: T.Type, api: SeSACAPI, completionHandler: @escaping (Result<T, SeSACError>) -> Void) {
        
        AF.request(api.endpoint, method: api.method, parameters: api.query, encoding: URLEncoding(destination: .queryString), headers: api.header)
            .responseDecodable(of: T.self) { response in
                switch response.result {
                case .success(let data):
                    completionHandler(.success(data))
                case .failure(_):
                    let statusCode = response.response?.statusCode ?? 500
                    guard let error = SeSACError(rawValue: statusCode) else { return }
                    
                    completionHandler(.failure(error))
                }
            }
}

Router Pattern


  • URLRequestConvertible 프로토콜을 채택해서 Router 패턴을 구현한다
    (기존의 SeSACAPI 열거형 대신 사용한다)
    • method, headers, parameter와 같은 초기 매개변수를 URLRequestConvertible로 캡슐화해서 값을 전달한다.

  • Router 열거형

    enum Router: URLRequestConvertible {
    
        private static let key = "tHisIsunsPlaShKey"
    
        case search(query: String)
        case random
        case photo(id: String)
    
        // url
        private var baseURL: URL {
            return  URL(string: "https://api.unsplash.com/")!
        }
        private var path: String {
            switch self {
            case .search:
                return "search/photos"
            case .random:
                return "photos/random"
            case .photo(let id):
                return "photos/\(id)"
            }
        }
    
        // header
        private var header: HTTPHeaders {
            return ["Authorization" : "Client-ID \(Router.key)"]
        }
    
        // method
        var method: HTTPMethod {
            return .get
        }
    
        // query
        var query: [String: String] {
            switch self {
            case .search(let query):
                return ["query": query]
            case .random, .photo:
                return ["": ""]
            }
        }
        
        func asURLRequest() throws -> URLRequest {
            let url = baseURL.appendingPathComponent(path)
    
            var request = URLRequest(url: url)
    
            request.headers = header
            request.method = method
    
            request = try URLEncodedFormParameterEncoder(destination: .methodDependent).encode(query, into: request)
    
            return request
        }
    }
    • 마지막에 필수 구현 메서드인 asURLRequest 안에서 헤더와 메서드를 request에 추가해준다

  • 함수 선언

    func requestConvertible<T: Decodable>(type: T.Type, api: Router, completionHandler: @escaping (Result<T, SeSACError>) -> Void ) {
    
            AF.request(api)
                .responseDecodable(of: T.self) { response in
                    switch response.result {
                    case .success(let data):
                        completionHandler(.success(data))
                    case .failure(_):
                        let statusCode = response.response?.statusCode ?? 500
                        guard let error = SeSACError(rawValue: statusCode) else { return }
    
                        completionHandler(.failure(error))
                    }
                }
    }
    • api 매개변수의 타입이 Router로 바뀌었다
    • 또한 request 함수 내에 들어가는 매개변수가 URL 타입이 아니고, Router 타입으로 바로 들어간다

Q. request의 매개변수 타입이 뭐길래 URL 타입도 들어가고 Router 타입이 들어갈까

  • URLRequestConvertible
    • Router 를 선언할 때 채택한 프로토콜이다. 따라서 Router 타입이 들어갈 수 있다


  • URLConvertible
    • 찾아보니까 URL에서 채택하고 있다. 따라서 URL 타입이 들어갈 수 있다

MVVM 적용

  • ViewController와 ViewModel로 역할을 나눈다
  • API 통신으로 랜덤 사진을 받아와서 화면에 띄우는 기능을 구현한다

1. 메서드 실행


  • 실질적인 네트워크 통신을 하는 함수를 ViewModel에 선언하고,
    ViewController에서는 이걸 실행만 한다

ViewModel

class NetworkViewModel {
	func requestRandom(completionHandler: @escaping (URL) -> Void) {
        Network.shared.requestConvertible(type: PhotoResult.self, api: .random) { response in
            switch response {
            case .success(let success):
                dump(success)
                completionHandler(URL(string: success.urls.thumb)!)
            case .failure(let failure):
                print(failure.errorDescription)
            }
        }       
	}
}

ViewController

viewModel.requestRandom { url in
    self.imageView.kf.setImage(with: url)
}

2. Observable


  • 값이 자주 바뀌는 상황이면, 매번 completionHandler에 원하는 작업(이미지 로딩)을 넣어서 함수를 실행해야 한다
  • ViewModel의 url 값을 Observable로 만들고,
    해당 작업을 listener로 넣어주면,
    값이 바뀔 때마다 알아서 작업이 진행되게 할 수 있다

ViewModel

class NetworkViewModel {

    // Observable 타입의 url 프로퍼티
    var url = Observable(URL(string: ""))
    
    func requestRandom() {
        Network.shared.requestConvertible(type: PhotoResult.self, api: .random) { response in
            switch response {
            case .success(let success):
                dump(success)
                // 새로운 값으로 업데이트
                self.url.value = URL(string: success.urls.thumb)!
                
            case .failure(let failure):
                print(failure.errorDescription)
            }
        }
    }
}

ViewController

// listener에 원하는 작업 바인딩
viewModel.url.bind { url in
    self.imageView.kf.setImage(with: url)
}

// 값이 바뀔 때마다 imageView에 사진이 로딩된다
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    self.viewModel.requestRandom()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    self.viewModel.requestRandom()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
    self.viewModel.requestRandom()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
    self.viewModel.requestRandom()
}

3. enum


  • 위 방법들로 하면 결국은 ViewController에서 ViewModel의 메서드에 접근하고, 이를 실행시켜야 한다
  • VC 입장에서 어떤 메서드인지도 모르게 하기 위해 한 번 더 코드를 감싸준다

ViewModel

extension NetworkViewModel {
    enum Action {
        case requestRandom
    }
    
    func doAction(_ type: Action) {
        switch type {
        case .requestRandom:
            requestRandom();
        }
    }
}

ViewController

viewModel.url.bind { url in
    self.imageView.kf.setImage(with: url)
}

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    self.viewModel.doAction(.requestRandom)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    self.viewModel.doAction(.requestRandom)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
    self.viewModel.doAction(.requestRandom)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
    self.viewModel.doAction(.requestRandom)
}

0개의 댓글