Swift 동시성 프로그래밍 - 2 (DispatchQueue)

백상휘·2022년 3월 15일
2

iOS_Programming

목록 보기
5/10
post-thumbnail

이번 게시물에서는 OperationQueue를 제외한다.


'CleanCode' 라는 책을 보면 동시성에 대해 이렇게 설명한다.

다음은 동시성과 관련한 일반적인 미신과 오해다.

  • 동시성은 항상 성능을 높여준다.
  • 동시성을 구현해도 설계는 변하지 않는다.
  • 웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다.

반대로 다음은 동시성과 관련된 타당한 생각 몇 가지다.

  • 동시성은 다소 부하를 유발한다.
  • 동시성은 복잡하다.
  • 일반적으로 동시성 버그는 재현하기 어렵다.
  • 동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다.

내가 보기엔 "웬만하면 구현해야 될 상황은 피하라"고 얘기하는 것 같다.

동시성 코드는 언제 쓸까?

"동시성 코드가 필요하다.."라는 생각이 들었던 적은 아래 3가지 상황이었다.

  • Core Image 프레임워크를 이용한 이미지 필터를 연속적으로 수행할 때(슬라이더를 이용).
  • 연속적으로 네트워크 요청을 한 뒤 응답을 기다려야할 때.
  • 뷰를 연속적으로 업데이트해야할 때.

이런 상황에는 두 가지 옵션이 있는데 동시성을 사용하거나 놀랍게도 사용하지 않는 것이다.

동시성 없이 해결하는 방법도 분명 있다. 로버트 C. 마틴 님의 말처럼 꼭 사용할 필요는 없다.

  • 화면을 잠근다.
  • 한번에 많은 네트워크 작업을 진행하고 화면을 호출한다.

동시성 프로그래밍을 배워야 할까? (안 읽어도 됨)

모바일 앱에서 처리하는 다양한 일 중에는 전력 혹은 시간을 많이 사용하는 일들도 있다. 전력/시간/복잡도 등이 상승할 수록 동시성 API의 필요성이 점점 더 커진다.

개인적으로 IT 사업도 결국은 서비스를 제공하는 업체라고 생각한다. 고객은 빠른 서비스를 원한다. 더 빠르면서 부드러운 화면을 만들 수 있는 개발자를 시장에서 원하는 것은 어찌 보면 당연하다.

iOS 개발자들의 업무는 주로 프론트엔드로 구분되는 것 같다. 사용자가 기대한 동작을 하는 화면을 만들기 위해 고민할 필요가 있고, 동시성 프로그래밍은 그 때 사용해야 할 솔루션이 될 수 있다.


Grand Central Dispatch

Grand Central Dispatch 는 운영체제에게 모든 스레드 관리 및 실행권한을 부여한다. 이미 C언어 라이브러리 중 이와 비슷한 것이 있는데, 애플이 Swift와 Objective-C 에서 사용할 수 있게 만들었다.

정확히 표현하자면 Dispatch(-) 클래스들은 Operation(-) 클래스에 비해 Grand Central Dispatch API에 비교적 가깝게 접근할 수 있는 API 중 하나인 것이다.

그래서 Dispatch 군의 클래스를 Low-Level API 라고 하는 것이다.

용어 정리

본 얘기에 들어가기 전에 마지막으로 용어 정리를 하고자 한다. 혼동하기 쉽다고 판단되서 한번 언급할 필요성을 느꼈다..

  1. Asynchronous = 작업들이 비동기적으로 처리될 때 사용하는 단어이다.
    DispatchQueue.main 혹은 DispatchQueue 객체의 async 메소드로 많이 호출하게 되는데 수행해야 할 작업의 return 을 기다리지 않고 바로 다음 작업을 수행하는 것을 뜻한다.
  2. Synchronous = 일련의 작업들의 동기화를 뜻할 때 사용하는 단어이다.
    DispatchQueue.main 혹은 DispatchQueue 객체의 sync 메소드로 많이 호출하게 되는데 수행해야 할 작업의 return 을 기다리고 순차적으로 다음 작업을 수행한다.
  3. Concurrency = 동시성 이라고 불린다. 몇 개의 작업을 다수의 스레드를 두어 우선순위에 따라 동시 혹은 순차적으로 처리하도록 하는 것을 말한다. 멀티 스레딩 등의 개념에 사용되는 용어이다.

DispatchQueue

GCD API를 통해 기능구현을 할 때 많이 쓰이는 클래스 중 하나이다.

iOS 앱은 기본적으로 몇 가지 동시성 Queue와 직렬 Queue를 만들어내기 때문에 클래스 프로퍼티를 이용하면 바로 사용할 수 있다.

DispatchQueue.main
DispatchQueue.global(qos: .utility)

이 게시물은 동시성 프로그래밍을 다루려 하고 있지만, 가장 중요한 것은 Serial(직렬) 처리방식을 가진 DispatchQueue.main 이라고 하고 싶다. 이것만 잘 다뤄도 앱의 종료는 막을 수 있다.

  • DispatchQueue.main
    애플리케이션이 보유한 프로세스의 main Thread에 코드 블록을 전달하기 위한 queue 자료구조이다.
    작업을 직렬로 하나하나 처리하게 된다.
  • DispatchQueue.global(qos:)
    애플리케이션이 보유한 프로세스의 multi Thread에 코드 블록을 전달하기 위한 queue 자료구조이다.
    작업을 병렬로 여러 스레드와 큐에 나눠서 처리하게 된다.

DispatchQueue.main이 중요한 이유는 뷰의 업데이트를 담당한다는 것이다. iOS의 화면은 변화가 생겼을 때 다시 그리기 때문에 앱을 구동하는 프로세스에 단 하나 존재하는 main thread에서 처리되어야 한다.

// main thread에서 self.textLabel의 text 프로퍼티에 문자열을 반영하는 소스를 실행하라.
// async 이므로 return을 기다리지 않고 바로 실행한다.
DispatchQueue.main.async {
	self.textLabel.text = "Let there be light"
} 

그러면 DispatchQueue.global(qos:)를 사용하기 전에 이전에 정리한 qos 표를 다시 한번 가져와보자.

우선
순위
이름사용예시
1userInteractiveUI, Event 관련 작업.
예시: UI 요소 계산, 에니메이션, 이벤트 핸들링
2userInitiated작업의 실행 및 결과로 사용자가 앱을 일시적으로 사용 못하게 함.
예시: 문서를 팝업 형태로 불러옴
3default일반적으로 사용하지 않는 것을 권장한다.
default는 unspecified -> default -> utility의 구조를 갖는다
4utility사용자가 의도하였지만, 지속적으로 관찰하진 않는 작업에 사용된다.
예시: I/O, networking, 지속적인 데이터 inout
5background사용자와의 상호작용이 전혀 없는 작업에 사용된다.
예시: Prefetching, backup, 외부 서버와의 동기화 작업
6unspecified이전 버전의 API와의 호환성을 위해 남겨놓은 값이다. 사용을 권장하지 않음.

여기서 오해가 발생할 수 있는 부분이 하나가 있는데, userInteractive는 main thread가 아니다. 단지 상당히 중요한 위치의 작업을 수행하고 에너지를 많이 소모하는 작업을 뜻하는 타입 프로퍼티일 뿐이다.

UI에 바로 적용해야 할 값을 계산만 하고 실제 뷰 업데이트는 main thread를 사용하는 DispatchQueue.main에서 진행해야 한다.

위의 두 설명을 종합하여 가장 많이 쓰일 코드를 아래와 같이 소개한다.

DispatchQueue.global(qos: .userInteractive).async {
	// UI 요소 계산...
    
    DispatchQueue.main.async {
    	// UI 요소 업데이트
    }
}

물론 직접 만든 Queue를 사용하기 위해 DispatchQueue 클래스는 init() 생성자를 제공한다.

let serialQueue = DispatchQueue(label: "com.sanghwiback.concurrencyNoThanks")
let concurrentQueue = DispatchQueue(label: "com.sanghwiback.concurrencyHelpYourSelf", qos: .userInteractive, attributes: .concurrent)

concurrentQueue.async {
	// UI 요소 계산...
    
    serialQueue.async {
    	// UI 요소 업데이트
    }
}

위와 같이 DispatchQueue 클래스의 초기화 메소드를 가지고 동기화된 혹은 비동기화된 Queue를 생성하여 원하는 대로 작업을 수행할 수 있다.

사실 여기까지만 알아도 많은 상황의 어려움을 극복할 수 있을 것이다. 하지만 testable code, modulation, dependency 등을 추가적으로 고려한다면 아래 내용을 읽기를 바란다. 아니라면, 바로 구현으로 넘어가도 무관하다고 생각한다.

DispatchWorkItem

DispatchWorkItem은 DispatchQueue에 전달할 수 있는 작업의 단위이다.

GCD를 사용한 소스 내에서 동시성 작업을 모듈화 하기 위한 클래스이다. 특히, 작업하는 프로젝트의 환경에 따라 Test를 진행할 수 있도록 도와주기 때문에 많이 언급되곤 한다.

사용 요령을 설명하기 전에 참고한 책(Clean Code)의 일부 내용을 소개하고자 한다.

<동시성 방어 원칙 중 일부 발췌>

따름 정리: 스레드는 가능한 독립적으로 구현하라
자신만의 세상에 존재하는 스레드를 구현한다. 즉, 다른 스레드와 자료를 공유하지 않는다. 각 스레드는 클라이언트 요청 하나를 처리한다. 모든 정보는 비공유 출처에서 가져오며 로컬 변수에 저장한다. 그러면 각 스레드는 세상에 자신만 있는 듯이 돌아갈 수 있다. 다른 스레드와 동기화할 필요가 없으므로.
(이하 중략...)

<스레드 코드 테스트하기 중 일부 발췌>

권장사항: 문제를 노출하는 테스트 케이스를 작성하라. 프로그램 설정과 시스템 설정과 부하를 바꿔가며 자주 돌려라. 테스트가 실패하면 원인을 추적하라. 다시 돌렸더니 통과하더라는 이유로 그냥 넘어가면 절대로 안된다.
(이하 중략...)

즉, 아래와 같이 모듈화된 동시성 클래스를 작성하여 사용하는 경우도 있다.

  1. 스레드에 전달 될 하나의 작업 블록은 다른 스레드 혹은 호출부와 자료를 공유하지 않는다.
  2. 모든 작업을 testable하게 작성한다.

Test는 힘들지만 오류가 적은 프로그램을 위해서는 testable한 코드가 필요하다. DispatchWorkItem 은 Test를 위해 존재한다고도 할 수 있다. 없어도 동시성 작업은 가능하기 때문이다.

참고 : DispatchQueue, DispatchGroup 에 작업을 전달하는 방식은 DispatchWorkItem과 codeBlock(아무 파라미터, 리턴값을 갖지 않는 클로저)이 있다.

다음의 코드는 DispatchWorkItem을 조합하고 의존성을 부여하여 작업을 처리하는 코드의 예시이다.

class DispatchTest {

	var text = ""
    var textLabel = UILabel()
    
	let requestWorkItem = DispatchWorkItem {
		URLSession(configuration: .default).dataTask(with: url) { (data, response, error) in
        	guard error == nil, let data = data else {
            	return 
            }
            
    		self.text = String(data: data, encoding: .utf8)
	}
	let uiWorkItem = DispatchWorkItem {
		self.textLabel.text = text
	}
}

let test = DispatchTest()

test.requestWorkItem
	.notify(queue: .main, execute: test.uiWorkItem)
    
queue.async(execute: test.requestWorkItem)

DispatchGroup

다소 익숙하지 않은 개념이라서 아래와 같이 공식 문서의 내용을 그대로 발췌해 왔다.

DispatchGroup
작업자가 실시간으로 확인할 수 있는 작업의 집합.
DispatchGroup 은 작업들을 통합하고 실행환경을 동기화한다. 여러 개의 작업들을 붙여넣고 같은 큐 혹은 다른 큐에 비동기적으로 스케줄링하여 실행시킬 수 있다.
모든 작업들이 완료되면, DispatchGroup은 완료 핸들러를 실행한다. 작업자는 동기화된 완료를 기다릴 수도 있다.

쉽게 설명하자면 위에서 언급한 하나의 작업 단위인 DispatchWorkItem을 저장, 실행, 관리한다. 특히, 저장할 때는 의존성을 설정할 수 있어 순서도 나름 보장할 수 있다.

개인적으로 이 클래스를 사용하여 작업하면 다음의 이점이 있다.

  1. 여러 개의 Queue를 사용할 수 있다.
  2. 동시성 작업만을 위한 Model 객체를 따로 만들 수 있다.
  3. 작업의 완료 핸들러 실행을 보장할 수 있다.
  4. 작업의 의존성을 더욱 명확히 할 수 있다.

이 쯤에서 DispatchWorkItem과 DispatchGroup을 같이 설명하는 이유가 취소가능이라는 것을 설명해야할 것 같다.

DispatchWorkItem 을 DispatchGroup 에 넣고 의존성을 부여하여 작업을 실행한다. 이 와중에 특정 조건이 발생하면 작업을 종료하고 싶을 수 있는데 주의해야 할 점은 취소 시 현재 작업 이후의 작업들만 취소 된다는 점이다. 현재 작업은 취소되지 않는다.


역시 사용해보고 부딪혀 보는게 가장 좋지 않을까? 이번 예제를 준비하면서 느낀 점은 동시성이라고 엄청난 성능 향상을 기대할 수는 없다는 것이다.

단지 UI가 끊기지 않고 부드러워 지는 것을 느끼게 하기 위해서 애플리케이션이 스트레스를 받을 수 있을만한 요소들을 몇개 추가했다. 우리는 지금 보고 있는 기술블로그나 라이브러리, API 등에서 마법같은 성능을 내는 코드를 기대하게 된다. 작업방법, 전략 등에 따라 결과는 천차 만별이다.

동시성 프로그래밍을 하면서 오류만 발생하지 않았다면 훌륭하다고 생각한다.

확실히 기기의 성능에 많이 좌우되기도 한다. 코드를 실제 테스트할 때 이전 세대의 기기에서도 테스트해보길 권장한다.

예제 애플리케이션(URL_GitHub_Repo)

이 Application 은 서울 열린데이터 광장의 문화 공간 관련 정보를 가져오는 API를 스크롤이 내려감에 따라 자동으로 호출해준다. 애플리케이션에서 호출하는 정보는 많지만 그 중 Image의 URL과 설명란의 HTML만을 화면에 표시한다.

이 애플리케이션에는 성능 이슈가 있다(일부러 만들었기 때문이다). 바로 URL과 HTML을 어떻게 셀에 표현할지 고민해보아야 한다.

  1. 이미지를 변환하기 위해서는 URL 문자열을 URL 객체로 만들고, 이를 Data 객체로 만든 뒤, UIImage로 변경해야 한다.
  2. 변경된 이미지는 UIImageView에 반영되어야 한다.
  3. HTML은 웹 뷰에 Load되어야 한다.
  4. 웹 뷰 컨텐츠를 한 화면에 보여주기 위해 Javascript 함수를 실행해야 하고 결과 값을 적용해야 한다.

그리고 이 컨텐츠들은 UITableView 내에서 UITableViewCell을 사용하여 표현할 것이다. 테이블 뷰의 셀은 재사용성을 갖는다. 테이블 뷰가 일정 셀을 표현한 뒤 화면에서 벗어나게 되면 그 셀은 다시 아래 혹은 위로 위치시켜서 사용자가 스크롤 할 때 연속적으로 셀이 보이도록 내부 처리가 진행된다.

이 특성으로 인해 이전에 보여졌던 컨텐츠가 다른 셀에 그대로 보이는 현상이 있곤 하니 주의해야 한다.

성능을 한번에 비교하기 위해 최대한 같은 코드로 작성하려 노력한 세 개의 뷰 컨트롤러를 준비했다.

이 뷰 컨트롤러들을 UITabBarController에 넣어서 빠른 비교를 할 수 있게 테스트 앱을 만들었다.

  • NoneViewController(None탭) : 동시성 처리가 완전히 들어가지 않은 뷰 컨트롤러이다.
  • CurrencyFirstViewController(Currency1탭) : 미리 정의된 큐에만 작업을 추가하여 동시성 프로그래밍을 진행한 뷰 컨트롤러이다.
  • CurrencySecondViewController(Currency2탭) : Grand Central Dispatch를 이용한 Dispatch군의 동시성 API를 적극 활용하여 동시성 부분을 모듈화 한 뷰 컨트롤러이다.

개인적인 의견으로는 Currency1, Currency2의 성능 차이는 없다. 즉, DispatchQueue 클래스에 클래스 변수로 선언된 뷰에 작업을 전달하는 것만으로도 Dispatch군의 API 성능을 충분히 뽑아내는 것이 가능하다는 것이 개인적인 의견이다.

뷰 컨트롤러 공통부분

성능 체크의 변화를 기대할 수 있는 부분은 UITableViewDelegate, UITableViewDataSource 구현 메소드 구현부에 존재한다. 아래 코드들은 모두 동일하며, tableView의 변수명만 차이가 있을 뿐이다.

CustomTableViewCell은 모든 테이블 뷰에서 공통으로 사용할 커스텀 셀이다. UIImage가 생성되면 저장하고, WKWebView에 HTML이 로드되고 스크롤 높이를 계산하면 부모 뷰 컨트롤러에 구현된 델리게이트 메소드를 실행한다.

참고로 예제 결과를 확인할 때 사용한 모델은 iPhone 6s, iPhone 13 Pro Max 두 기종이다. 모두 개인이 사용하거나 사용하던 모델이다.

import UIKit

class NoneViewController: UIViewController {

    private var networkModel: APISessionModel = APISessionModel()
    
    private let refreshControl = UIRefreshControl()
    private var preDefinedRowHeights = [IndexPath: Float]()
    private var fetchDataResults = [SpaceInfo]() {
        didSet {
            DispatchQueue.main.async {
                self.noneTableView.reloadData()
                self.refreshControl.endRefreshing()
            }
        }
    }
    
    private let identifier = String(describing: CustomTableViewCell.self)

    @IBOutlet weak var noneTableView: UITableView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let nib = UINib(nibName: identifier, bundle: nil)
        noneTableView.register(nib, forCellReuseIdentifier: identifier)
        noneTableView.dataSource = self
        noneTableView.delegate = self
        
        refreshControl.addTarget(self, action: #selector(refreshTableView(_:)), for: .valueChanged)
        if #available(iOS 10.0, *) {
            noneTableView.refreshControl = refreshControl
        } else {
            noneTableView.addSubview(refreshControl)
        }
        
        fetchRequest()
    }
    
    // 테이블 뷰 refresh할 때 실행할 Selector 함수.
    @objc func refreshTableView(_ sender: Any) {
        fetchRequest(isReload: true)
    }
    
    func fetchRequest(isReload: Bool = false) {
        
        if isReload {
            fetchDataResults.removeAll()
        }
        
        networkModel.sendRequest(from: fetchDataResults.count+1, to: fetchDataResults.count+10) { [weak self] culturalData in
            guard let culturalData = culturalData else { 
            	return 
            }
            
            self?.fetchDataResults.append(contentsOf: culturalData)
        }
    }
}

// ------------------------------------------

import UIKit
import WebKit

protocol CustomCellSizeDelegate {
    func customCellDidFinishLoad(using height: CGFloat, at indexPath: IndexPath)
}

class CustomTableViewCell: UITableViewCell, WKUIDelegate, WKNavigationDelegate {

    @IBOutlet weak var numberLabel: UILabel!
    @IBOutlet var cellImageView: UIImageView!
    @IBOutlet var cellWebView: WKWebView!
    
    var delegate: CustomCellSizeDelegate?
    var indexPath: IndexPath?
    var htmlString: String?
    var cellImage: UIImage?
    
    override func awakeFromNib() {
        super.awakeFromNib()
        cellWebView.uiDelegate = self
        cellWebView.navigationDelegate = self
        cellWebView.scrollView.isScrollEnabled = false
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        guard webView.isLoading == false else { return }
        evaluateHeightUsingJavaScript(webView)
    }
    
    func evaluateHeightUsingJavaScript(_ webView: WKWebView) {
        webView.evaluateJavaScript("document.body.scrollHeight") { [weak self] (result, error) in
            guard let self = self else { return }
            guard let height = result as? CGFloat, let indexPath = self.indexPath else { return }
            
            self.delegate?.customCellDidFinishLoad(using: (height > 300 ? 300 : height), at: indexPath)
        }
    }
}

extension NoneViewController: CustomCellSizeDelegate {
	/// 각 커스텀 셀에 정의된 델리게이트 메소드입니다.
    ///
    /// 각 커스텀 셀에서 웹뷰에 HTML이 로드된 후 스크롤 높이를 가져왔을 경우 실행됩니다.
    func customCellDidFinishLoad(using height: CGFloat, at indexPath: IndexPath) {
        guard let cell = noneTableView.cellForRow(at: indexPath) as? CustomTableViewCell else { return }
        
        noneTableView.beginUpdates()
        preDefinedRowHeights.updateValue(Float(cell.cellImageView.frame.height+height/2), forKey: indexPath)
        noneTableView.endUpdates()
    }
}

NoneViewController
(UITableViewDataSource/UITableViewDelegate)

동시성 관련 코드가 하나도 들어가지 않은 코드다. tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) 함수를 보면 이미지를 바꿔서 지정하고, 셀에 HTML을 load하지만 이는 모두 셀을 초기화할 때 동기적으로 수행되도록 하고 있다.


extension NoneViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        fetchDataResults.count
    }
    
    // MARK: - Important Part Start
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: identifier) as? CustomTableViewCell else {
            return UITableViewCell()
        }
        
        let model = fetchDataResults[indexPath.row]
        
        cell.delegate = self
        cell.numberLabel.text = "\(indexPath.row)"
        
        cell.indexPath = indexPath
        
        if let url = URL(string: model.MAIN_IMG), let data = try? Data(contentsOf: url) {
            cell.cellImageView.image = UIImage(data: data)
        }
        
        if model.preDefinedRowHeight == nil {
            cell.cellWebView.loadHTMLString(model.FAC_DESC, baseURL: nil)
        }
        
        return cell
    }
    // MARK: - Important Part End
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        guard fetchDataResults.count-1 >= indexPath.row, let rowHeight = preDefinedRowHeights[indexPath] else {
            return UITableView.automaticDimension
        }
        return CGFloat(rowHeight)
    }
}

extension NoneViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
    	// 현재 위치가 맨 마지막일 경우 데이터를 fetch할 수 있도록 하였습니다.
        if indexPath.row == fetchDataResults.count-1 {
            fetchRequest()
        }
    }
}

결과

iPhone 6s

iPhone 13 Pro Max



스크린샷은 변환에 변환을 거친거라 화질이 좋지 못하다. 느낌만 확인할 수 있을 것 같다.

여기서 main Thread 즉, serial queue에서 실행되어 화면을 업데이트 하는 함수들의 실행이 동기화 되어있다. 이로 인해 화면의 멈춤 효과를 유발하고 있다.

  1. UIImageView에 이미지를 반영하는 작업
    cell.cellImageView.image = UIImage(data: data)
  2. WKWebView에 HTML을 로드하는 작업
    cell.cellWebView.loadHTMLString(model.FAC_DESC, baseURL: nil)
  3. UITableView의 변화를 감지하고 적용하는 함수 호출
    noneTableView.beginUpdates()
     preDefinedRowHeights.updateValue(Float(cell.cellImageView.frame.height+height/2), forKey: indexPath)
     noneTableView.endUpdates()

여기서 선택할 수 있는 선택지는 세 가지이다.

  1. 한번의 fetchData로 10 개의 데이터를 가져오고 있다. 100개를 가져오면 어떨까?
    데이터를 미리 많이 받아와서 실행하면 네트워크 작업을 최소화할 수 있다.
  2. 웹뷰의 변화를 감지하는 로직을 최소화한다.
    현재 웹뷰의 스크롤뷰는 스크롤을 풀고 높이를 고정하거나,
    자바스크립트 명령 실행결과 고정 높이보다 클 경우만 업데이트를 처리하도록 한다.
    (이 작업도 일부 화면 멈춤 현상을 발생시킬 수 있다.)
  3. 1번 얘기를 백엔드쪽에 얘기했지만 먹히지 않았거나 2번 얘기를 PM 혹은 팀장님이 허락하지 않으시면 아래의 동시성 코드를 적용해야 할 것이다.

CurrencyFirstViewController
(UITableViewDataSource, UITableViewDelegate)

extension CurrencyFirstViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        fetchDataResults.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: identifier) as? CustomTableViewCell else {
            LoggerUtil.faultLog(message: "DequereusableCell initialize failed from ConcurrencyFirstViewController")
            return UITableViewCell()
        }

        let model = self.fetchDataResults[indexPath.row]

        cell.delegate = self
        cell.numberLabel.text = "\(indexPath.row)"

        cell.indexPath = indexPath

        DispatchQueue.global(qos: .userInitiated).async {
            var image: UIImage?
            if let url = URL(string: model.MAIN_IMG), let data = try? Data(contentsOf: url) {
                image = UIImage(data: data)
            }

            DispatchQueue.main.async {
                if let image = image {
                    cell.cellImageView.image = image
                }
                cell.cellWebView.loadHTMLString(model.FAC_DESC, baseURL: nil)
            }
        }

        return cell
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        guard fetchDataResults.count-1 >= indexPath.row, let rowHeight = preDefinedRowHeights[indexPath] else {
            return UITableView.automaticDimension
        }
        return CGFloat(rowHeight)
    }
}

extension CurrencyFirstViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        if indexPath.row == fetchDataResults.count-1 {
            fetchRequest()
        }
    }
}

결과

iPhone 13 Pro Max

iPhone 6s


어떠한가? 다시한번 말하지만 본인의 선택이 많이 좌우되는 작업이다.


우선 맘에 드는 건 NoneViewController의 구현부와 크게 다르지 않다는 점이다. 단순히 DispatchQueue.main.async { }, DispatchQueue.global(qos:).async { }를 호출하면 그만이다.

async라는 의미는 프로그램이 절차적으로 실행되는 동안 return을 기다리지 않고 실행된다는 의미이다. NoneViewController는 스크롤 자체가 잠겨버리는 느낌이 많이 들었는데, 그런 느낌이 훨씬 적게 느껴진다.

하지만 return을 기다리지 않고 나중에 return이 오게 되면 전달받은 클로저를 실행하는 구조이기 때문에 이미지가 갑자기 바뀌는 듯한 현상을 경험할 수 있다.

이런식의 구현은 아래의 장점과 단점이 있다.

  • 장점
    1. 구현이 간단하다.
    2. 디버깅이 간단한 편이다(이 점은 정말 중요하다).
  • 단점
    1. 모듈화가 되지 않는다. 저런 코드가 많아진다면 뷰 컨트롤러는 엄청나게 비대해질 수도 있다.
    2. testable하지 않다. 동시성에서 처리하는 모델 클래스가 없기 때문이다.

그럼 마지막으로 DispatchGroup, DispatchWorkItem을 적극적으로 사용해보자.

CurrencySecondViewController
(UITableViewDataSource/UITableViewDelegate/GroupTaskModel)

이번 실습을 위해 GroupTaskModel이라는 클래스를 만들었다.

아래의 클래스는 작업을 전달받아 자신의 DispatchGroup에 저장하고 자신이 가진 동시성 queue에서 작업을 실행할 수 있게 인터페이스를 제공한다.

초기화 시 동시성 queue에서 사용할 qos를 지정할 수 있다.

import Foundation
import UIKit

class GroupTaskModel<T: UITableViewCell> {
    
    private var group = DispatchGroup()
    private var concurrentQueue: DispatchQueue
    
    init(qos: DispatchQoS) {
        self.concurrentQueue = DispatchQueue(label: "customCellQueue", qos: qos, attributes: .concurrent)
    }
    
    @discardableResult
    func processConcurrentImage(target: T, urlString: String, _ completionHandler: @escaping (T, Data)->Void) -> DispatchWorkItem {
        let item = DispatchWorkItem {
            if let url = URL(string: urlString), let data = try? Data(contentsOf: url) {
                completionHandler(target, data)
            }
        }
        
        concurrentQueue.async(group: group, execute: item)
        return item
    }
    
    @discardableResult
    func processConcurrent(target: T, _ completionHandler: @escaping (T)->Void) -> DispatchWorkItem {
        let item = DispatchWorkItem(flags: .barrier) {
            completionHandler(target)
        }
        
        concurrentQueue.async(group: group, execute: item)
        return item
    }
    
    @discardableResult
    func processSerial(target: T, _ completionHandler: @escaping (T)->Void) -> DispatchWorkItem {
        let item = DispatchWorkItem {
            DispatchQueue.main.async(group: self.group) {
                completionHandler(target)
            }
        }
        
        concurrentQueue.async(group: group, execute: item)
        return item
    }
    
    func notifyCustomCellGroup(target: T, _ completionHandler: @escaping (T)->Void) {
        group.notify(queue: .main) {
            completionHandler(target)
        }
    }
    
    func waitTimeoutCustomCellGroup(target: T, timeoutHandler: ((T)->Void)?, _ completionHandler: @escaping (T)->Void) {
        if group.wait(timeout: .now()+5) == .timedOut {
            if let timeoutHandler = timeoutHandler {
                timeoutHandler(target)
            }
            
            return
        }
        
        completionHandler(target)
    }
}

그리고 이를 이용한 뷰 컨트롤러이다.

extension CurrencySecondViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        fetchDataResults.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: identifier) as? CustomTableViewCell else {
            LoggerUtil.faultLog(message: "DequereusableCell initialize failed from ConcurrencySecondViewController")
            return UITableViewCell()
        }
        
        let model = fetchDataResults[indexPath.row]
        
        cell.delegate = self
        cell.numberLabel.text = "\(indexPath.row)"
        
        cell.htmlString = model.FAC_DESC
        cell.indexPath = indexPath
        
        let imageTask = groupCellTaskModel.processConcurrentImage(target: cell, urlString: model.MAIN_IMG) { target, data in // 1. 셀에 미리 작업할 이미지를 저장한다.
            guard let target = target as? CustomTableViewCell else { return }
            target.cellImage = UIImage(data: data)
        }
        groupCellTaskModel.processConcurrent(target: cell) { target in // 2. Serial Queue 작업 전 이상이 발견되면 이후의 Task들을 모두 종료 시킨다.
            guard let target = target as? CustomTableViewCell else {
                imageTask.cancel()
                return
            }
            
            if target.cellImage == nil {
                imageTask.cancel()
            }
        }
        let someSerialTask = groupCellTaskModel.processSerial(target: cell) { target in // 3. 실제 ImageView 에 이미지를 반영한다.
            guard let target = target as? CustomTableViewCell else { return }
            target.cellImageView.image = target.cellImage
        }
        groupCellTaskModel.notifyCustomCellGroup(target: cell) { target in // 4. WebView 에 HTML 을 반영하면서 의존성이 부여된 작업들을 순차적으로 실행한다.
            guard let target = target as? CustomTableViewCell, let htmlString = target.htmlString else {
                someSerialTask.cancel()
                return
            }
            target.cellWebView.loadHTMLString(htmlString, baseURL: nil)
        }
        
        return cell
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        guard fetchDataResults.count-1 >= indexPath.row, let rowHeight = preDefinedRowHeights[indexPath] else {
            return UITableView.automaticDimension
        }
        return CGFloat(rowHeight)
    }
}

extension CurrencySecondViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        if indexPath.row == fetchDataResults.count-1 {
            fetchRequest()
        }
    }
}

결과

iPhone 13 Pro Max

iPhone 6s



여기서 let groupCellTaskModel: GroupTaskModel에서 사용된 인터페이스는 아래와 같다.

  1. processConcurrentImage
    테이블 뷰 셀에서 많이 사용하는 이미지 변환을 동시성 큐에서 수행하도록 하고, 결과를 전달해주는 클로저를 실행하도록 합니다. 작업은 DispatchWorkItem 형태로 만들어지고, 클래스이기 때문에 반환할 수 있도록 하였다.
  2. processConcurrent
    클로저로 전달되는 작업을 DispatchWorkItem 형태로 만들어 DispatchGroup에 저장하는 기능만을 지원한다.
  3. processSerial
    만약 Serial 큐에서 실행할 작업이 있다면 자체 DispatchGroup에 저장한다. DispatchGroup에서 실행할 큐는 여러개가 될 수 있다.
  4. notifyCustomCellGroup
    자체 DispatchGroup의 작업이 모두 수행된 후 수행할 작업을 클로저로 받게 된다.

즉, 이미지를 변환하는 작업을 Concurrent 큐에서 실행하고 확인한 후, 이미지가 변환되었으면 Serial 큐에서 이미지를 반영한다. 모든 작업이 끝나면 HTML을 반영하고 group내 작업을 모두 종료한다.


성능을 향상시키기 위한 전략은 아래와 같이 두 가지이다.

  • 각 동시성 작업의 DispatchWorkItem을 받아와서 실제 작업에 적합한 상황이 아닐 경우라면 cancel()할 수 있도록 하였다. 작업량을 최소화 하는 것이 중요했다.
  • 또한, target을 따로 전달해주었다. target은 테이블 뷰 셀의 클래스 주소값이므로, 클로저에서 self를 참조하는 것보다 직접 클로저에서 참조하는 것이 오류를 줄여주는 것이라고 생각하였다.

DispatchWorkItem이 cancel하면 뒤의 작업을 모두 취소할 수 있다는 점. DispatchGroup에 의존성을 부여하여 모든 작업이 끝난 뒤 함수를 실행할 수 있다는 점을 이용하였다.

개인적으로 동시성을 이용한다면 3번째 결과가 가장 인상적이었다.

결론

역시나 동시성 프로그래밍은 쉽지 않은 작업이라고 생각한다.

동시성 작업을 한다고 기대만큼 변화가 일어나지 않을 수도 있고 앞으로 설명할 Operation 클래스 사용법을 모른다면 선택할 수 있는 범위가 많이 줄어든다.

개인적으로는 DispatchWorkItem, DispatchGroup 사용을 추천한다.
빌드 시간은 큰 프로젝트일수록 오래걸리기 때문에 테스트 코드를 통해 동시성 부분만 따로 검증하도록 하는 것은 아주 좋은 방법일 것 같다.
또한, 뷰 컨트롤러에서 관련 코드를 효과적으로 배제할 수 있어서 유지보수성도 확보할 수 있다.

실제로 Operation 관련 클래스들도 내부적으로 GCD를 사용하고 있기 때문에, GCD 관련 여러 클래스를 사용해보고 앱의 성능을 향상해보기 바란다.

부족한 글이나마 여기까지 읽어주셔서 무한한 감사를 드린다. 다음에는 잘 다듬어진 게시물로 찾아뵐 수 있으면 좋겠다.

profile
plug-compatible programming unit

0개의 댓글