ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Swift) URLSession Network Layer
    Swift 2022. 8. 10. 19:37

    iOS를 공부하다 보면 만나는 고민의 벽이 몇 가지 있다.

    예를 들면 스토리보드냐 코드 베이스 레이아웃이냐..

    어떤 아키텍쳐를 사용할 것이냐...

    컨벤션(줄 바꿈, branch 전략)은 어떤 식이 좋을까...?

    지금 이 설계가 오버 엔지니어링은 아닐까?

     

    이와 마찬가지로 고민되는 것이 네트워크의 모듈화. 즉, 네트워크의 레이어를 나누는 것이다.

     

    왜 레이어를 나눠야 하나요?

    지금 당장 Network Layer 키워드를 통해서 검색한 결과만 봐도 수많은 방법으로 나눈 레이어들이 존재한다.

    프로토콜을 이용하거나, enum을 사용해서 EndPoint를 만든다거나, 심지어 모든 기능을 쪼개어 메서드 단위로 퍼사드 패턴들 적용한 사례도 있다.

    이렇게 나누어 놓은 레이어들을 보면 오히려 흐름을 파악하기 어려운 경우도 존재한다. 

    사실 한 class 안에 URLSession 설정과 통신, decoding 등 모든 과정을 집어넣어도 기능적으론 문제가 없다.

     

    자, 그럼 우리가 iOS 앱을 개발하는 데 있어서 실력이 늘수록 혹은 경험이 많을수록 네트워크 레이어를 나누는 이유는 무엇일까?

     

    협업? 가독성? 모듈화?

    고민 끝에 도달한 결론은 코드를 잘 알아보기 위해서이다.

     

    사실 레이어를 나누는 이유는 코드를 리팩토링 하는 이유와 결이 같다.

    우리가 보통 코드를 리팩토링 할때 중복된 코드를 지우고 로직을 더 깨끗하고 이해하기 쉽게 하려고 노력하는데 네트워크 또한 마찬가지이다. (코드 리팩토링 범주 안에 네트워크 레이어가 들어갈 수도 있다.)

     

    iOS에서의 네트워크 데이터를 가공하는 레이어 말고 진짜 Network OSI 7계층, TCP/IP 프로토콜 또한 계층이 존재한다.

    그 이유가 무엇일까?

    네트워크는 거리가 먼 데이터의 통신이다.

    통신에 문제가 생겼다... 뭐가 문제지?

    어플리케이션의 문제인가? 

    랜선이 끊어졌나?

     

    이 처럼 어디서 오류가 발생했는지 알기 위함.

    즉, 유지보수를 하기 위함이 레이어를 나누는 가장 큰 이유가 아닐까 싶다.

     

    iOS에서도 마찬가지이다.

    네트워크 통신의 레이어를 나누는 이유는 아니, 모든 모듈화를 하는 이유는 이러한 장점이 크기 때문이라고 생각한다.

     

    서비스 규모가 커질수록 우린 다양한 네트워크 서비스와 기능들을 다루게 되고, 이 과정에서 공통으로 사용 가능한 부분은 살리고 불필요하게 중복되는 부분을 제거하며 모듈화를 진행한다.

    이런 모듈화가 가능한 것은 주소의 형식이 일치하기 때문이다. baseUrl과 path. 그리고 그 뒤에 붙는 다양한 파라미터들.

    레이어를 나누어 모듈화 하기에 최적의 조건이다.

     

    우선 레이어를 나누지 않은 통신 기능을 구현해보자.

    구현할 기능은 사진 데이터를 받아 올 수 있는 공공 API를 활용하였다.

    사용자의 accessKey가 필요하고 path와 pagination을 위한 파라미터가 필요하다.

    func getImageData(path: String, params: [String: Any], page: Int, completion: @escaping (Result<[PictureData], NetworkError>) -> Void) {
            
            let baseUrl = "https://api.unsplash.com/photos"
            
            let queryParams = params.map { k, v in "\(k)=\(v)" }.joined(separator: "&")
            var fullPath = path.hasPrefix("http") ? path : baseUrl + path
            if !queryParams.isEmpty {
                fullPath += "?" + queryParams
            }
            
            guard let url = URL(string: fullPath) else { return }
            var request = URLRequest(url: url)
            request.httpMethod = "GET"
            
            let task = URLSession.shared.dataTask(with: request) { data, response, error in
                if let error = error {
                    completion(.failure(.requestFail(error)))
                    return
                }
                guard let httpResponse = response as? HTTPURLResponse else {
                    completion(.failure(.invalidResponse))
                    return
                }
                guard 200..<300 ~= httpResponse.statusCode else {
                    completion(.failure(.failedResponse(statusCode: httpResponse.statusCode)))
                    return
                }
                guard let data = data else {
                    completion(.failure(.emptyData))
                    return
                }
                
                do {
                    let decodedData = try JSONDecoder().decode([PictureData].self, from: data)
                    completion(.success(decodedData))
                } catch {
                    completion(.failure(.decodeError))
                }
                
            }
            task.resume()
        }

    baseUrl과 path 설정, prameter 설정, method 방식, 에러 처리와 디코딩까지 모두 한 메서드 안에서 실행된다.

    뭐 누군가는 알아보기 쉽다고 할 수도 있고, 기능 수행이 되면 문제가 없다고 할 수도 있겠다.

     

    그럼 해당 HttpClient를 사용하는 곳을 보자.

    	let path = URLRepository.photo
            let params: [String:Any] = ["client_id": "AccessKey1235412345", "page": page, "per_page": 15]
            
            HttpClient().getImageData(path: path, params: params, page: page) { result in
                switch result {
                case .success(let imageList):
                    ...
                    completion()
                case .failure(let error):
                    print(error)
                }
            }

     

     

    path와 parameter를 전부 직접 입력해주고 있다.

    물론 이렇게도 할 수 있다.

    하지만 해당 코드를 보는 다른 사람들 입장에선 네트워크에서 요구하는 parameter과 path 등을 한눈에 파악하기 힘들다.

    또한 하드 코딩을 통해 parameter를 입력 해주고 있기 때문에 언제나 실수를 야기할 가능성이 있다.

     

    위의 코드를 DataTask, Decoding, EndPoint 세 파트로 나누어 보자.

    DataTask

    // HttpClient
    
    func getImageData(endpoint: ImageEndPoint, completion: @escaping (Result<Data, NetworkError>) -> Void) {
            guard let request = endpoint.asUrlRequest() else { completion(.failure(.invalidURL))
                return
            }
            let session = URLSession(configuration: .ephemeral)
            
            let task = session.dataTask(with: request) { data, response, error in
                if let error = error {
                    completion(.failure(.requestFail(error)))
                    return
                }
                guard let httpResponse = response as? HTTPURLResponse else {
                    completion(.failure(.invalidResponse))
                    return
                }
                guard 200..<300 ~= httpResponse.statusCode else {
                    completion(.failure(.failedResponse(statusCode: httpResponse.statusCode)))
                    return
                }
                guard let data = data else {
                    completion(.failure(.emptyData))
                    return
                }
                completion(.success(data))
                
            }
            task.resume()
        }

    dataTask 부분이다. endPoint 파라미터를 하나 받아 모든 request를 처리한다.

    코드가 매우 간결 해졌다.

     

    Decoding

    final class Repository {
        
        private let httpClient = HttpClient()
        
        func fetchImageData(_ endPoint: ImageEndPoint, completion: @escaping (Result<[PictureData], NetworkError>) -> Void) {
            httpClient.getImageData(endpoint: endPoint) { result in
                switch result {
                case .success(let data):
                    do {
                        let decodedData = try JSONDecoder().decode([PictureData].self, from: data)
                        completion(.success(decodedData))
                    } catch {
                        completion(.failure(.decodeError))
                    }
                    
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        }
        
    }

    사실 디코딩 레이어는 왜 만드냐는 의문이 들 수도 있다.

    디코딩 레이어는 서버에서 내려오는 response의 형식에 따라 추가할 수도 있고 안 할 수도 있다.

    다시 말하지만 코드엔 정답이 없다.

     

    endpoint를 받아 httpClient의 getImageData에 전달해주고 decdoing 성공 시 PictureData의 배열을 성공 값으로 넘겨준다.

     

    EndPoint

    protocol ImageEndPointType {
        var baseUrl: String { get }
        var accessKey: String { get }
        var method: HttpMethod { get }
        var parameters: [String: Any] { get }
    }
    
    enum ImageEndPoint: ImageEndPointType {
        
        case getImage(page: Int)
        
        var baseUrl: String {
            switch self {
            case .getImage:
                return URLRepository.baseUrl
            }
        }
        
        var accessKey: String {
            switch self {
            case .getImage:
                return "acceessKey12341234"
            }
        }
        
        var method: HttpMethod {
            switch self {
            case .getImage:
                return .get
            }
        }
        
        var path: String {
            switch self {
            case .getImage:
                return URLRepository.photo
            }
        }
        
        var parameters: [String: Any] {
            switch self {
            case .getImage(let page):
                return ["client_id": self.accessKey, "page": page, "per_page": 15]
            }
        }
        
        func asUrlRequest() -> URLRequest? {
            let queryParams = parameters.map { k, v in "\(k)=\(v)" }.joined(separator: "&")
            var fullPath = path.hasPrefix("http") ? path : baseUrl + path
            if !queryParams.isEmpty {
                fullPath += "?" + queryParams
            }
            guard let url = URL(string: fullPath) else { return nil }
            
            return URLRequest(url: url)
        }
    }

    endPoint엔 request에 대한 모든 정보가 담겨있다.

    enum 구조체를 통해 해당 기능에 대한 case를 만들어 놓고 미리 할당해 놓은 데이터를 asUrlRequest() 함수로 반환해주기만 하는 것이다.

     

    이것도 다 개발자가 입력해주는 것 아니냐라고 물을 수도 있다.

     

    해당 레이어를 사용하는 곳으로 가보자.

    ViewModel에서의 네트워크 통신

    	let endPoint = ImageEndPoint.getImage(page: page)
            
            repository.fetchImageData(endPoint) { [self] result in
                switch result {
                case .success(let imageList):
                    ...
                    completion()
                case .failure(let error):
                    print(error)
                }
            }

     

     

    통신 시점에 path, parameters 등 모든 정보를 입력해줘야 했던 것에 비해 엄청나게 쉬워졌다!!

     

     

    결론

    협업에 있어서 네트워크 통신은 대부분의 모든 기능에서 공동으로 사용한다. 그렇기 때문에 더욱 가독성이 중요하고 협의가 필요한 부분이다.

    모든 코드엔 정답이 없다고 생각한다. 이미 수많은 예제의 네트워크 레이어가 존재하고 내 것으로 만드는 게 중요하다.

    그 기준을 가독성 그리고 협업에서의 편리성에 두고 자신에게 맞는 방법을 찾아야 한다고 생각한다.

     

     

     

     

     

     

     

     

     

     

     

     

    'Swift' 카테고리의 다른 글

    Opaque Type  (1) 2022.09.21
    Swift) 성능 최적화 - Dispatch와 메모리 할당  (0) 2022.08.30
    Data(contentsOf:)? URLSession?  (0) 2022.08.01
    Swift) json 다루기  (0) 2021.06.16
    Swift) API Design Guidelines  (0) 2021.06.10

    댓글

Designed by Tistory.