관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] MVVM 구조에서 안전하게 dataSource 관리하는 방법 (DispatchQueue, 스레드, Background, Memory Safety, 크래시) 본문

iOS 응용 (swift)

[iOS - swift] MVVM 구조에서 안전하게 dataSource 관리하는 방법 (DispatchQueue, 스레드, Background, Memory Safety, 크래시)

jake-kim 2023. 7. 29. 20:48

dataSource 관리

  • MVVM 구조에서 보통 dataSource를 사용할 때 아무런 큐 없이 구현하지만, 데이터 처리 최적화를 위해 background 시키고 싶은 경우 중간중간 DispatchQueue.global()를 사용하거나 커스텀 큐를 사용하는 경우가 존재
  • 개발자가 실수로 DispatchQueue.global()를 사용하게 되면, 이 큐는 serial이 아닌 concurrent이므로 value type인 dataSource 배열을 수정과 동시에 읽기를 하다가 크래시가 발생하는 경우가 존재
  • 이럴때는 커스텀 큐를 선언하여 관리하는것이 가장 좋은데, 먼저 queue의 종류를 이해가 필요

DispatchQueue의 종류 3가지

* 이전 포스팅 글에서 알아본 개념인 3가지의 DispatchQueue 개념

 

[iOS - swift] GCD, DispatchQueue, DispatchGroup(enter, leave, notify), DispatchWorkItem (Debounce 구현 방법)

DispatchQueue 개념 Thread pool을 thread safe하게 관리하는 객체 멀티 스레드에서도 어떤 함수나 변수, 혹은 객체가 여러 스레드로부터 동시에 접근이 이루어져도 프로그램의 실행에 문제가 발생하지 않

ios-development.tistory.com

1) main (serial)

  • main thread에서 처리되는 serial queue (모든 UI관련 작업은 해당 큐에서 main queue에서 실행)

2) global (concurrent)

  • 전체 시스템에서 공유되는 concurrent queue이고, concurrent이기 queue끼리의 우선순위를 위해서 queue를 사용할 때 QoS 설정 필요
    • userInteractive: 유저가 누르면 즉각 반응 (main queue)
    • userInitiated: 유저가 실행시킨 작업들을 즉각적이지는 않지만, async하도록 처리
    • default
    • utility: I/O, n/w API 호출
    • background: 유저가 인지하지 못할 정도의 뒷단에서 수행하는 작업

3) 커스텀

  • 개발자가 임의로 정의한 queue이고 serial / concurrent 모두 정의 가능
    • 기본적으로 주어진 것은 main은 serial, global은 concurrent이므로 main이 아닌 뒷단에서 serial로 수행하고 싶은 경우 사용

커스텀 큐를 사용해 DataSource 처리

  • Memory Safety를 위해서 serial queue에서 처리하여 value type가 업데이트 동시에 읽기가 안되도록 구현이 필요

ex) MVVM 구조 준비

(RxSwift, RxCocoa 의존성 사용)

  • ViewController
import UIKit
import RxCocoa
import RxSwift

enum Action {
    case viewDidLoad
}

protocol DataSourceable {
    var dataSource: [String] { get }
}

protocol ViewModelable: DataSourceable {
    var output: Observable<State> { get }
    
    func input(_ action: Action)
}

class ViewController: UIViewController {
    // MARK: UI
    private let tableView: UITableView = {
        let view = UITableView()
        view.allowsSelection = false
        view.backgroundColor = .clear
        view.separatorStyle = .none
        view.bounces = true
        view.showsVerticalScrollIndicator = true
        view.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        view.estimatedRowHeight = 34
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()
    
    // MARK: Properties
    let viewModel: ViewModelable = ViewModel()
    private var disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
        bind()
        viewModel.input(.viewDidLoad)
    }
    
    private func configureUI() {
        view.addSubview(tableView)
        
        NSLayoutConstraint.activate([
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
        ])
        
        tableView.dataSource = self
    }
    
    private func bind() {
        viewModel.output
            .observe(on: MainScheduler.instance)
            .bind { [weak self] state in
                guard let self else { return }
                
                switch state {
                case .updateUI:
                    tableView.reloadData()
                }
            }
            .disposed(by: disposeBag)
    }
}

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        viewModel.dataSource.count
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell")
        cell?.textLabel?.text = viewModel.dataSource[indexPath.row]
        return cell!
    }
}
  • ViewModel
import RxSwift
import RxCocoa

enum State {
    case updateUI
}

final class ViewModel: ViewModelable {
    // MARK: State
    var dataSource = [String]()
    
    // MARK: Output
    var output: RxSwift.Observable<State> {
        outputSubject
    }
    private var outputSubject = PublishSubject<State>()
    
    // MARK: Input
    func input(_ action: Action) {
        switch action {
        case .viewDidLoad:
            dataSource = (1...10).map(String.init)
            outputSubject.onNext(.updateUI)
        }
    }
}
  • 여기에는 dataSource가 동시에 변경될 일이 없겠지만, serial queue와 background thread에서 API의 응답에 맞추어서 dataSource를 변경해준다던지 async하게 업데이트가 발생하는 동시에 읽기가 발생한다면 crash 위험이 크게 존재
    • 크래시 케이스) background thread에서 업데이트하지만, 스레드 관리의 실수로 main thread에서 업데이트 되는 경우
  • 이런 위험을 막기 위해서 전역에 serial 커스텀 큐를 선언해놓고 이 큐 안에서만 dataSource를 업데이트 하도록 구현이 필요
// ViewModel.swift

let dataSourceUpdateQueue = DispatchQueue(label: "data_source_update_queue")

...


func input(_ action: Action) {
    switch action {
    case .viewDidLoad:
        dataSourceUpdateQueue.async { // <-
            self.dataSource = (1...10).map(String.init)
            self.outputSubject.onNext(.updateUI)
        }
    }
}
  • 뷰쪽에서의 updateUI 케이스에서 background thread로 동작할 것이므로 main으로 다시 스레드 전환하여 UI 업데이트
private func bind() {
    viewModel.output
        .observe(on: MainScheduler.instance)
        .bind { [weak self] state in
            guard let self else { return }
            
            switch state {
            case .updateUI:
                DispatchQueue.main.async { // <-
                    self.tableView.reloadData()
                }
            }
        }
        .disposed(by: disposeBag)
}

정리

  • 데이터 처리나 오래 걸리는 작업은 background에서 수행해야함
  • global()을 사용하면 concurrent하게 동작하므로 memory access conflict가 발생하므로 custom queue를 생성
  • custom queue를 사용한다고 하더라도 실수로 main queue에서 dataSource를 업데이트 치는 경우, 다른 스레드에서 동시에 값을 업데이트 할 수 있으므로 위험
  • custom queue를 사용하면서 dataSource에 대한 갱신 부분은 무조건 custom queue로 감싸서 memory safety하게 사용할 것

* 전체 코드: https://github.com/JK0369/ExMemorySafety

Comments