iOS

combine - 2. Network 예제 살펴보기

728x90

API call 예제

api을 통해 데이터를 요청하고, 테이블 뷰를 새로고침 하는 간단한 예제를 만들어보자.

우선, NetworkManager.swift를 생성해주자.

import Foundation
import Combine

class NetworkManager {
    // single 톤 패턴
    static let shared = NetworkManager()
    
    // subscriber들의 메모리를 참조 할 수 있는 저장소
    private var cancellables = Set()
    private let baseURL = "<https://jsonplaceholder.typicode.com/>"
}
  • cancellables는 publisher을 구독하는 subscriber들의 메모리를 참조 할 수 있는 저장소이다. 나중에 나오겠지만 store 메서드를 통해 저장하는 로직이 나온다.
  • api 통신은 https://jsonplaceholder.typicode.com/ 을 이용할 예정이다.

본격적으로 api 통신을 만들기 전에 몇가지 enum 타입을 만들어주자.

나는 NetworkManager Class 외부에 생성했다.

// baseURL 뒤에 붙게 될 주소 값
enum Endpoint: String {
    case posts
}

// network 에러 처리 종류
enum NetworkError: Error {
    case invalidURL
    case responseError
    case unknown
}

// network 에러 처리 description
extension NetworkError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return NSLocalizedString("Invalid URL", comment: "Invalid URL")
        case .responseError:
            return NSLocalizedString("Unexpected status code", comment: "Invalid response")
        case .unknown:
            return NSLocalizedString("Unknown error", comment: "Unknown error")
        }
    }
}

다시 NetworkManager Class안으로 돌아와 Future를 반환하는 getData메서드를 만들어주자.

// 실패 할 수 있는 Future publisher을 반환합니다.
func getData<T: Decodable>(endPoint: Endpoint, type: T.Type) -> Future<[T], Error> {
    
    return Future<[T], Error> { [weak self] promise in
        // promise : (Result<[T], Error>) -> Void
        // 여기서 promise는 Future의 결과를 하나의 클로저로 받는 argument임
        // 이 내부에서, 비동기작업을 수행 할 수 있습니다.
        // 비동기 작업이 끝났을 때, 작업의 결과(failure or success)를 promise에 넣어줘야합니다.
        
        // URL 검증
        guard let self = self, let url = URL(string: self.baseURL.appending(endPoint.rawValue)) else {
            return promise(.failure(NetworkError.invalidURL))
        }
        
        print("URL is \\(url.absoluteString)")
        
    }
}

주석으로 설명을 써놓았는데, Future의 경우 Apple에서 제공해주는 Publisher라고 생각하면 된다.

봐야할 부분은 Future에서 비동기 작업을 완료하고 promise라는 closure에 failure, success를 태워 보낸다는 점이다.

URLSession을 사용하는 다음 코드를 살펴보자.

// 실패 할 수 있는 Future publisher을 반환합니다.
func getData<T: Decodable>(endPoint: Endpoint, type: T.Type) -> Future<[T], Error> {
    
    return Future<[T], Error> { [weak self] promise in

				/*
				...에러처리 부분
				*/
        // Apple에서 만들어놓은 URLSession의 publisher를 통해 URL 요청
        URLSession.shared.dataTaskPublisher(for: url)
						// api 통신 에러 처리
            .tryMap { (data, response) -> Data in
                guard let httpResponse = response as? HTTPURLResponse, 200...299 ~= httpResponse.statusCode else {
                    throw NetworkError.responseError
                }
                return data
            }
            // response받은 JSON 데이터를 Object로 매핑하는 작업
            .decode(type: [T].self, decoder: JSONDecoder())
            // Returns the run loop of the main thread.
            .receive(on: RunLoop.main)
            // subscriber 생성
            // 실패 할일이 없는 Publisher라면, receiverCompletion이 생략가능하지만,
            // 네트워크 요청이 실패가 없을리가 없기 때문에 receiveCompletion을 작성해줌.
            .sink(receiveCompletion: { (completion) in
                // completion: Subscribers.Completion<Error>
                // completion가 .failure(error)에 해당 할 경우 실행됨.
                // publisher와 subscriber 둘 모두 failure가 있어 사용 할 수 있는 코드로 보임.
                if case let .failure(error) = completion {
                    switch error {
                    case let decodingError as DecodingError:
                        promise(.failure(decodingError))
                    case let apiError as NetworkError:
                        promise(.failure(apiError))
                    default:
                        promise(.failure(NetworkError.unknown))
                    }
                }
            }, receiveValue: {
                // data 요청,변환 성공과 데이터를 전달함.
                promise(.success($0))
            })
            // subscriber들을 참조하기위해 cancellables에 저장해놓음
            .store(in: &self.cancellables)
        
    }
}

주석으로 설명을 작성해놨는데, 아래 순서대로 작업이 일어난다.

  • Apple에서 만들어놓은 URLSession의 publisher를 통해 URL 요청
  • 네트워크 에러 처리
  • decode 작업
  • subscriber 연결
    • 성공, 실패 처리
    • 성공 데이터 반환
  • subscriber의 메모리를 참조 할 수 있게 cancellables에 저장

처리작업을 여러개 묶어서 할 수 있는 점도 재밌는데,

새로운 URLSession Publisher안에서 promise 클로저를 사용해 Future에게 알려준다는 점이 재밌다.

여기까지 하면 Decode를 위한 post struct만 만들고, 이제 테스트만 하면 된다.

Decode를 위한 Post Struct

import Foundation

//[{
//    "userId": 1,
//    "id": 1,
//    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
//    "body": "quia et suscipit\\nsuscipit recusandae consequuntur expedita et cum\\nreprehenderit molestiae ut ut quas totam\\nnostrum rerum est autem sunt rem eveniet architecto"
//  }
//]

// JSON data에 맞는 struct 구성
public struct Post: Codable {
    var userId: Int
    var id: Int
    var title: String
    var body: String
}

ViewController 테스트

아마, storyBoard로 뷰를 구성하였을 경우 아래 코드가 동작하지 않는다.

코드를 통해 구성했음으로, 아래 내용을 참고해서 코드로 바꿔주자.

//
//  ViewController.swift
//  CombineDemo
//
//  Created by Taehoon Kim on 2022/03/22.
//

import UIKit
import Combine
class ViewController: UIViewController {

    // 사용하기
    // NetworkManager.shared.getData에 대한 subscriber을 위한 저장소.
    private var cancellables = Set<AnyCancellable>()
    // publisher 간단하게 생성
    // 추가되거나 제거될때마다 변경사항 알림을 보내고자하는 경우 @Published를 붙임
    // SwiftUI의 경우 @Publisehd가 붙은 변수가 있는 뷰를 새로고침함.
    @Published var posts = [Post]() {
        didSet {
            tableView.reloadData()
        }
    }
    private var tableView = UITableView()
    
    // Published
    //    private extension Published {
    //        func valueDidChange() {
    //            for closure in observations.value {
    //                closure(wrappedValue)
    //            }
    //        }
    //    }
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        fetchData()
        view.backgroundColor = .white
        
        // 마지막으로 자동으로 변경되는지 확인하기 위해 만들어봄.
        configureTableView()
        
    }
    
    func fetchData() {
        NetworkManager.shared.getData(endPoint: .posts, type: Post.self).sink(receiveCompletion: {
            completion in
            switch completion {
            case .finished:
                print("Finished")
            case .failure(let err):
                print("Error is \\(err.localizedDescription)")
            }
        }, receiveValue: { [weak self] data in
            // networkManager의 promiss(.success($0))에서
            // $0에 해당하는 데이터를 전달받음.
            print("receiveValue")
            self?.posts = data
        })
        .store(in: &cancellables)
    }
    
    // tableView 추가 
    func configureTableView() {
        self.view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 0).isActive = true
        tableView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 0).isActive = true
        tableView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 0).isActive = true
        tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: 0).isActive = true
    
        tableView.dataSource = self
    }
    
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return posts.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        cell.textLabel?.text = posts[indexPath.row].title
        return cell;
    }
}

위 코드를 보면 또 cancellables를 만들어주고 @Published를 posts 변수에 붙여주는 걸 볼 수 있다.

  • cancellables의 경우 NetworkManager.shared.getData가 Future(Publisher)을 반환 하기 때문에 이 녀석을 구독하는 subscriber을 저장 할 수 있는 저장소를 만들어주는 것이다.
  • @Publisher는 SwiftUI를 사용할 때, 해당 데이터가 변경되면 뷰를 새로고침 하나 보다.

실행해보기

위의 코드를 돌려보면, 아무것도 없는 빈 테이블 뷰가 나온 후에 post 데이터 요청이 끝난 후 tableView가 업데이트 됨을 볼 수 있다.

문제점

코드를 보면, tableView.reload() 이 부분이 없었으면 좋겠다는 생각이 든다.

그건 그렇고 publisher을 사용하는 이유 중 하나가 subscriber 들에게 변경됨을 알리는 용도아닌가?

위의 예제는 publisher을 설명 할 수 없는 예제 아닌가 싶다

2가지를 더 해보자.

  1. Publisher 값들 ViewModel로 나누기
  2. 다른 class에서 동일한 Publisher 호출 되는지 확인해보기

더 해보기) ViewModel에 나누기

ViewModel에서 일어나는 일

문제 발생

💡 @Publisher로 생성된 publisher을 sink로 받으면, 데이터가 세팅 되기 전에 동작하게 되는 문제가 발생했다. @Pulisher는 SwiftUI에서 사용되기 위해 만들어진 느낌이다. UIKit에서는 CurrentValueSubject를 사용하여 만들 수 있다.

  1. 아래와 같이CurrentValueSubject를 사용한 ViewModel을 하나 생성해주자.
//
//  ViewModel.swift
//  CombineDemo
//
//  Created by Taehoon Kim on 2022/03/22.
//

import UIKit
import Combine

class PostViewModel {
    // NetworkManager.shared.getData에 대한 subscriber을 위한 저장소.
    private var cancellables = Set<AnyCancellable>()
    // publisher 간단하게 생성
    // 추가되거나 제거될때마다 변경사항 알림을 보내고자하는 경우 @Published를 붙임
    // SwiftUI의 경우 @Publisehd가 붙은 변수가 있는 뷰를 새로고침함.
    // @Published var posts = [Post]()
    // UIKi이기 때문에, CurrentValueSubject를 사용함.
    
    var posts = CurrentValueSubject<[Post], Never>([Post]())
    
    func fetchData() {
        NetworkManager.shared.getData(endPoint: .posts, type: Post.self).sink(receiveCompletion: {
            completion in
            switch completion {
            case .finished:
                print("Finished")
            case .failure(let err):
                print("Error is \\(err.localizedDescription)")
            }
        }, receiveValue: { [weak self] data in
            // networkManager의 promiss(.success($0))에서
            // $0에 해당하는 데이터를 전달받음.
            print("receiveValue")
            // posts데이터 넣어주기
            self?.posts.send(data)
        })
        .store(in: &cancellables)
    }
    
    // 처음 생성 될 때, 데이터를 호출함.
    init() {
        fetchData()
    }
  
}
  • CurrentValueSubject:
    • CurrentValueSubject<[타입], Never>( 초기값) 으로 초기값을 세팅을 해줘야한다.
    • send를 통해 외부에서 데이터를 주입하고 Subscriber에게 전달하는 놈인데 , Publisher로 보면 된다. 즉, 외부에서 데이터를 주입가능한 Publisher이다.
  • PassthroughSubject와 CurrentValueSubject의 차이
    • 둘 모두 send메서드를 통해 외부에서 publisher의 데이터를 설정 할 수 있음
    • PassthroughSubject: 초기값을 가지고 있지않고, finished과 같은 publisher의 상태값을 value로 전달 하여 publisher를 종료할 수 있음.
    • CurrentValueSubject: 초기값을 가지고 있고, value로 들어가는 값은 진짜 값임 ㅇㅇ
  1. Controller에 ViewModel을 연결해주자.
  • 기존의 posts는 제거하고 cancellables을 아래처럼 바꿔주자.
    let viewModel = PostViewModel()
    private var cancellables: Set<AnyCancellable> = .init()
    
  • cancellables은 안바꿔줘도 무관하다.
  • table View의 데이터를 viewModel로 바꿔주자.
extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.posts.value.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell()
        cell.textLabel?.text = viewModel.posts.value[indexPath.row].title
        return cell;
    }
}
  • viewModel의 posts를 구독하자
    // 구독 함수
    private func subscribeToViewModel() {
        // posts의 값이 변경되면, tableView를 reload함.
        viewModel.posts
            .sink { [unowned self] _ in self.tableView.reloadData()}
            .store(in: &cancellables)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        view.backgroundColor = .white
        // 여기서 호출
        subscribeToViewModel()
        configureTableView()
    
    }
    
  • 아래 함수는 viewDidLoad에서 호출 해주면된다.

더해보기) 다른 Class에서 호출되는지 확인하지

이렇게 작성하고 실행하면, 1부의 예제처럼 잘나온다.

그러면 다른 class에서도 반응하는지 테스트 하기위해 class를 하나 추가해주자.

class AnotherListener {
    private var cancellables: Set<AnyCancellable> = .init()
    let viewModel = PostViewModel()
    init() {
        print("AnotherListener is call")
        viewModel.posts.sink { [unowned self] _ in
            print("Post Change!")
        }
        .store(in: &cancellables)
    }
}

class ViewController: UIViewController {

let anotherListener = AnotherListener()
// ....
}

위의 코드를 추가하고 실행하게 되면, 아래처럼 posts가 변경될 때, 같이 호출이된다.

참고

반응형