NSOperation Tutorial in Swift

NSOperation in Swift

NSOperation and NSOperationQueue Tutorial in Swift

버튼을 탭하거나 텍스트 편집을 시작할 때 iOS/Mac앱이 반응을 멈추는 당혹스러운 경험을 해본적이 있을 것이다. Mac 앱이라면 마우스포인터(흔히 말하는 커서)가 형형색색의 비치볼로 변하는 것으로 지금 UI 반응을 할 수 없다는 것을 알려주는데, iOS앱에서는 이런 기제가 없으므로 사용자는 항상 UI에 반응할 수 있다고 기대하게 된다. 반응하지 않는 앱은 문제가 있거나 느리다고 느껴지고 리뷰에서 좋은 평가를 받기 힘들다.

앱이 항상 반응하도록 하는 것은 말처럼 쉽지 않다. 앱이 한가지 이상의 일을 동시에(사용자 터치에 반응하면서 다른 작업을 하는)해야 한다면 순식간에 여러가지 것들이 꼬이기 쉽다. 메인 런루프에서는 많은 작업을 처리할 시간이 없고 이는 오롯이 UI 반응에 집중해야 한다.

이제 불쌍한 개발자는 메인스레드에서 병렬작업으로 이행해야 한다. 병렬작업은 동시에 여러 개의 작업 스트림이 진행된다는 의미이며, 이를 통해 메인스레드는 항상 사용자의 터치에 반응하게 된다.

iOS에서 이런 작업을 수행하는 방법 중 하나는 NSOperation과 NSOperationQueue를 사용하는 것이다. 먼저 병렬작업을 사용하지 않은 앱을 만들어보자. 이 앱은 매우 버벅이고 느릴 것이다. 그리고 이 앱에 병렬작업을 추가하면 보다 반응이 좋은 UI를 제공하게 될 것이다.

샘플 프로젝트로부터 시작

샘플 프로젝트에는 테이블뷰가 있고, 테이블뷰의 각 셀은 스크롤을 통해 뷰 내로 진입하게 되면 이미지를 로딩해온다. 덕분에 각 셀이 화면이 진입한 직후부터 화면 업데이트가 멈춘다. 문제는 tableView(_:cellForRowAtIndexPath:) 메소드에 있다.

이 메소드 내에서 부하가 크게 걸리거나 처리 시간이 오래 걸리는 부분을 확인해보자.

  1. 웹에서 이미지를 로딩하는 작업. 이 작업은 어렵지 않은 작업이지만, 앱은 다운로드가 완료될 때까지 기다려야 한다.
  2. 이미지에 대해 코어 이미지 필터를 적용하는 작업. 이 작업은 좀 무겁다. (사실 빠른데…)

뿐만아니라 뷰 로딩 초기에 이미지 목록을 읽어오는 것도 인터넷 연결을 통한 작업이므로 이 시간동안 뷰가 멈추게 된다.

lazy var photos = NSDictionary(contentsOfURL:dataSourceURL)

이 모든 일들이 메인스레드에서 벌어지게 된다. 메인스레드가 바빠지면 UI를 업데이트할 틈이 없어진다. 이는 사용자 경험을 해치는 일이 된다. 이 문제는 Xcode의 게이지 뷰를 통해 확인할 수 있다.

태스크, 스레드, 프로세스

병렬작업과 관련한 기본 개념들을 짚고 넘어가자

  • 태스크: 작업. 뭔가 해야 하는 한가지 일이다. 이는 단순히 추상적인 “작업”의 개념으로 보면 된다.
  • 스레드: OS에 의해 제공되는 명령의 흐름. 하나의 스레드는 작업들을 하나씩 순차적으로 처리해 나간다.
  • 프로세스: 실행되는 코드 덩어리. 하나의 프로세스는 여러 스레드로 구성될 수 있다.

보통 애플리케이션은 한개 혹은 여러 개의 프로세스로 구성될 수 있다. 기본적인 앱은 한개의 프로세스로 이루어지며, 그 프로세스는 한 개의 스레드를 가진다. 따라서 시작부터 끝까지 순차적으로 작업들을 처리해 나가게 된다.

스레드가 추가되어 작업을 나눠서 하게된다면 메인 스레드는 UI 업데이트에만 관여하고 두번재 스레드는 시간이 많이 걸리는 작업을 하여 앱의 반ㅇ응을 높일 수 있다. 이런 작업에는 파일을 읽거나 네트워크를 액세스하는 일들이 포함된다.

NSOperation VS Grand Central Dispatch

GCD는 언어의 기능 + 런타임 라이브러리 + OS 수준의 지원으로 멀티코어 프로세서에서 동시작업의 효율을 높이는 방법이다. (보다 자세한 내용은 애플 개발자 라브러리를 참고) NSOperation과 NSOperationQueue는 GCD 위에 세워진 기능으로 이를 고수준의 객체지향 API로 만든 것이다.

  • GCD는 동시에 실행되는 작업의 단위를 표현하는 간단한 방법이다. 해야할 일을 블럭으로 만들어주면 나머지를 시스템이 알아서 처리한다. 블럭을 취소하거나 지연하는 일도 지원한다.
  • NSOperation은 GCD에 약간에 오버헤드를 더하지만, 재사용이 쉽고 각 작업간의 의존성을 간단히 설정할 수 있으며 취소나 지연 역시 가능하다.

이 글에서는 NSOperation을 이용하는 ㅂ아법을 사용할 것이다. 왜냐면 테이블의 스크롤에 따라 많은 양의 작업이 백그라운드로 처리될 텐데, 셀이 로딩중에 화면 밖으로 나가는 경우에는 작업을 취소해야 하기 때문이다.

모델 재정의하기

스레드를 사용하지 않을 때와 사용할 때 가장 큰 차이를 보여야 하는 부분은 의외로 데이터 모델이다. 데이터 모델의 상태를 나눠서 메인스레드의 부담을 줄이는 방법을 찾아보도록 하자.

병목을 제거하기 위해서 스레드 적용에 적합한 형태로 UI 반응 구조를 변경하고, 데이터소스 다운로드 하는 부분을 변경하고, 이미지 필터링 작업도 스레드에 맞게 변경해야 한다. 새로운 모델에서는 앱은 메인스레드로부터 시작해서 비어있는 테이블 뷰를 그려준다. 동시에 앱은 두 번째 스레드를 실행하여 데이터를 다운로드 받는다.

데이터가 다운로드되고나면 테이블 뷰를 다시 그린다. 이 작업은 메인스레드에서 이루어진다. 이 시점에 테이블 뷰는 몇개의 이미지가 있는지 알게 되고, 각 이미지의 주소를 알게되지만, 아직 이미지를 그려내지는 못한다. (이미지를 다운로드 받기 전이다) 이 시점에서 모든 이미지를 다운로드 받는다면 이 또한 매우 비효율적이다.

좀 더 나은 방식은 현재 보여지는 셀에서 필요한 이미지를 다운로드 받는 것이다. 따라서 지금 보여지는 셀이 어떤 것인지 테이블뷰에게 확인한 후, 그에 해당하는 이미지만 다운로드 프로세스에 추가한다. 또한 필터링 스레드는 이미지를 다운로드 완료한 이후에 시작할 수 있다.

따라서 다음과 같은 순서를 따르게 된다.

스레드1:  1       3 - 4      6    8
스레드2:    \ 2 /      \ 5 /    /
스레드3:                   \ 7 /

1: 빈 테이블 뷰 표시 
2: 데이터소스 다운로드
3: 테이블 뷰 업데이트
4: 보여지는 셀 선택
5: 보여지는 셀에 대해 이미지 다운로드 
6: 다운로드된 이미지를 보여주도록 UI 업데이트
7: 다운로드된 이미지에 대해 필터처리 
8: 필터처리된 이미지를 표시하도록 UI 업데이트 

이 과정을 따르기 위해서는 이미지의 상태가 다운로드 중인지, 다운로드되었는지, 필터링이 적용되었는지 등을 결정해야 한다. 이 값에 따라 셀의 위치가 변할 때 작업을 취소하거나 지연/복구하는 작업을 해주어야 한다.

import UIKit
//
enum PhotoRecordState {
    case New, Downloaded, Filterd, Failed
}
// 
class PhotoRecord {
    let name:String
    let url:NSURL
    var state: PhotoRecord = .New
    var image = UIImage(named: "Placeholder")
    // init
    init(name:String, url:NSURL) {
        self.name = name
        self.url = url
    }
}

이 클래스는 단순히 앱에서 사용되는 각 사진의 정보를 표현한다. 다음은 각 작업의 상태를 추적하기 위한 클래스이다.

class PendingOperations {
    lazy var downloadsInProgress = Dictionary<NSIndexPath, NSOperation>()
    lazy var downloadQueue: NSOperationQueue = {
        var queue = NSOperationQueue()
        queue.name = "Download Queue"
        queue.maxConcurrentOperationCount = 1
        return queue
    }()
    lazy var filtertionInProgress = NSDictionary<NSIndexPath, NSOperation>()
    lazy var filterationQueue: NSOperationQueue = {
        var queue = NSOperationQueue()
        queue.name = "Image Fileteration Queue"
        queue.maxConcurrentOperationCount = 1
        return queue
    }()
}

다음은 이미지 다운로더

class ImageDownloader: NSOperation {
    let photoRecord: PhotoRecord
    init(photoRecord: PhotoRecord) {
        self.photoRecord = photoRecord
    }
    override func main() {
        autoreleasepool {
            if self.cancelled {
                return
            }
            let imageData = NSData(contentsOfURL:self.photoRecord.url)
            if self.cancelled {
                return
            }
            if imageData.length > 0 {
                self.photoRecord.image = UIImage(data:imageData)
                self.photoRecord.state = .Download
            } else {
                self.photoRecord.state = .Failed
                self.photoRecord.image = UIImage(named:"Failed")
            }
        }
    }
}

다운로더는 하나의 이미지를 다운로드 하는데, 시작할 때 취소여부를 검사하고, 이미지를 다운로드 받은 직후에 다시 취소여부를 검사한다. (이미지 다운로드 중에 검사하고 싶으면 NSURLConnection을 사용해야 한다.)

  1. 각 다운로더는 하나의 포토레코드에 대한 이미지를 다운로드한다.
  2. 따라서 다운로더는 초기화시에 포토레코드 객체를 필요로한다.
  3. main 함수는 반드시 오버라이드해야 하는데, 이는 스레드가 생성되었을 때 자동으로 실행된다. 오토릴리즈풀은 스레드마다 있어야 하므로 명시적으로 설치해준다.
  4. 스직하기 전에 취소 여부 확인
  5. 다운로드
  6. 다운로드 완료 후 취소 여부 재확인
  7. 데이터가 있으면 포토레코드 객체의 이미지 교체. 실패시에는 실패 이미지로 교체. 이 때 이미지 상태도 변경해준다.

비슷한 방식으로 이미지 필터 작업기도 만들어준다.

class ImageFilteration: NSOperation {
    let photoRecord: PhotoRecord
    init(photoRecord: PhotoRecord) {
        self.photoRecord = photoRecord
    }
    override func main() {
        autoreleasepool {
            if self.cancelled {
                return
            }
            if self.photoRecord.state != .Downloaded {
                return
            }
            if let filteredIamge = self.applySepiaFilter(self.photoRecord.image) {
                self.photoRecord.image = filteredImage
                self.photoRecord.state = .Filterd
            }
        }
    }
    func applySepiaFilter(image:UIImage) -> UIImage? {
        let inputImage = CIImage(data:UIImagePNGRepresentation(image))
        if self.cancelled {
            return nil
        }
        let context = CIContext(options:nil)
        let filter = CUFilter(name:"CISepiaTone")
        filter.setValue(inputImage, forKey:KCIInputImageKey)
        filter.setValue(0.8, forKey:"inputIntensity")
        let outImage = filter.outImage
        if self.cancelled {
            return nil
        }
        let returnImage = context.createCGImage(outImage, fromRect:outImage.extent())
        return UIImage(CGImage:returnImage) 
    }
}

이제 리스트 뷰(테이블 뷰)에서 데이터 모델을 변경하자. 단순히 lazy var photos = [UIImage]() 였던 부분을 다음과 같이 변경한다.

var photos = [PhotoRecord]()
let pendingOperations = PendingOperations()

그리고 먼저 데이터소스를 다운로드받자.

func fetchPhotoDetails() {
    let request = NSURLRequest(URL:dataSourceURL)
    UIApplication.sharedApplication().networkActivityIndicatorVisible = true
    NSURLConnection.sendAsynchronousRequest(request, queue:NSOperationQueue.mainQueue()) {
        response, data, error in 
            if data != nil {
                let datasourceDictionary = NSPropertyListSerialization.propertyListFromData(data, mutablityOption: .Immutable, format: nil, errorDescription: nil) as NSDictionary
                //
                for (key: AnyObject, value: AnyObject) in datasourceDictionary {
                    let name = key as? String
                    let urlString = value as? String
                    if name != nil && urlString != nil {
                        let photoRecord = PhotoRecord(name:name!, url:NSURL(string:urlString!))
                        self.photos.append(photoRecord)
                    }
                }
                self.tableView.reloadData()
            }
            if error != nil {
                let alert = UIAlertView(title:"Ooops!", message:error.localizedDescription, delegate:nil, cancelButtonTitle:"OK")
                alert.show()
            }
            UIApplication.sharedApplication().networkActivityIndicatorVisible = false
    }
}

위 함수는 뷰가 로드되었을 때 호출되어야 하므로 viewDidLoad(){ .. }에 들어가야 한다.

이번에는 테이블 뷰의 셀을 그리는 메소드를 재정의한다.

override func tableView(tableView: UITableView, cellForRowAtIndexPath: NSIndexPath) -> UITableViewCell {
    tableView.dequeueReusableCellWithIdentifier("cell", forIndexPath: indexPath) as UITableViewCell
    if cell.accessoryView == nil {
        let indicator = UIActivityIndicatorView(activityIndicatorStyle: .Gray)
        cell.accessoryView = indicator
    }
    let indicator = cell.accessoryView as UIActivityIndicatorView
    // 사진 레코드의 상태에 따라 작업 구분 
    let photoDetails = photos[indexPath.row]
    cell.textLabel?.text = photoDetails.name
    cell.imageView?.image = photoDetails.image
    switch (photoDetails.state) {
        case .Filtered:
            indicator.stopAnimating()
        case .Failed:
            indicator.stopAnimating()
            cell.textLabel?.text = "Failed to load"
        case .New, .Downloaded:
            indicator.startAnimating()
    }
    self.startOperationsForPhotoRecord(photoDetails, indexPath: indexPath)
    return cell
}

사진 작업에 대해서는 아래와 같이 다운로드 되지 않은 상태에서는 다운로드를, 완료된 건에 대해서는 필터링 작업을 건다.

func startOperationsForPhotoRecord(photoDetails: PhotoRecord, indexPath: NSIndexPath) {
    switch photoDetails.state {
    case .New:
        startDownloadForRecord(photoDetails, indexPath: indexPath)
    case .Downloaded:
        startFilterationForRecord(photoDetails, indexPath: indexPath)
    default:
        NSLog("do nothing")

    }
}

다운로드 작업은 완료되지 않은 건에 대해서만 걸어주게 된다. 이를 위해서 진행중인 작업들을 사전 객체에 담아둔다. 다행히, 여기서는 indexPath를 통해서 각 객체를 구분할 수 있다.

func startDownloadForRecord(photoDetails: PhotoRecord, indexPath: NSIndexPath) {
    if let downloadOperation = pendingOperations.downloadsInProgress[indexPath] {
        return
    }
    let downloader = ImageDownloader(photoRecord: photoDetails)
    downloader.completionBlock = {
        if downloader.cancelled {
            return
        }
        dispatch_async(dispatch_get_main_queue(), {
            self.pendingOperations.downloadsInProgress.removeValueForKey(indexPath)
            self.tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
        })
    }
    pendingOperations.downloadsInProgress[indexPath] = downloader
    pendingOperations.downloadQueue.addOperation(downloader)
}

다운로드는 NSOperation 객체이고, 큐에 넣는 순간 바로 대기 상태가 되고, 큐의 맨 앞에서 알아서 시작된다. completionBlock? 객체는 작업이 완료되었을 때 메인스레드에서 호출되는 속성인데, 이를 지정하여 다운로드가 완료되었을 때 셀을 업데이트 하도록 했다.

같은 방식으로 필터링 작업도 처리하도록 한다.

func startFilterationForRecord(photoRecord: photoDetails, indexPath: NSIndexPath) {
    if let filterOperation = pendingOperations.filterationsInProgress[indexPath] {
        return
    }
    let filterer = ImageFilteration(photoRecord: photoDetails)
    filterer.completionBlock = {
        if filterer.cancelled {
            return
        }
        dispatch_async(dispatch_get_main_queue(), {
            self.pendingOperations.filterationsInProgress.removeValueForKey(indexPath)
            self.tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)
        })
    }
    pendingOperations.filterationsInProgress[indexPath] = filterer
    pendingOperations.filterationQueue.addOperation(filterer)
}

세부 튜닝

이상의 작업에 대해서 몇 가지 튜닝할 소지가 있다. 현재 뷰에 보이지 않는 셀에 대해서도 이미지 다운로드나 필터링 작업이 백그라운드에서 진행된다. 만약 스크롤을 빠르게 움직이면 보이지 않는 셀을 다운로드하고 처리하느라 바빠지게 되어 아래에 있는 셀의 이미지를 표시하는데는 시간이 많이 걸릴 것이다. 이상적으로는 오프스크린 셀에 대해서는 작업을 중지하고 현재 보이는 셀 위주로 작업을 처리하도록 해야 할 것이다.

각 셀을 처리하는 부분에서 다음을 추가한다.

if !tableView.dragging && !tableView.decelerating {
    self.startOperationsForPhotoRecord(photoDetails, indexPath: indexPath)
}

이렇게 하면 스크롤이 완료되었을 때에만 현재 화면에 들어온 셀에 대해 작업을 시작하게 된다. 또한 작업 중간에 스크롤이 시작된다면 이를 중지할 방법도 필요하다. 테이블 뷰는 스크롤뷰의 자손이므로 UIScrollViewDelegate 메소드를 통해서 이를 제어해보자.

override func scrollViewWillBeginDragging(scrollView: UIScrollView) {
    suspendAllOperations()
}

override func scrollViewDidEndDragging(scrollView:UIScrollView, willDecelerat decelerate: Bool)  {
    if !decelerate {
        loadImagesForOnScreensCells()
        resumeAllOperations()
    }    
}

override func scrollViewDidEndDecelerting(scrollView: UIScrollView) {
    loadImagesForOnScreensCells()
    resumeAllOperations()
}

이상의 세개의 델리게이트 메소드에서 스크롤이 시작될 때 작업을 중지하고 스크롤이 끝날 때 화면에 보여질 셀을 로드하고 작업 큐를 다시 시작한다.

작업 큐를 다시 시작하는 부분의 로직은 조금 복잡하다.

func loadImagesForOnScreensCells() {
    // 화면에 표시되는 셀들에 대해서 
    if let pathsArray = tableView.indexPathsForVisibleRows() {
        // 모든 다운로드 작업
        let allPendingOperations = NSMutableSet(array:pendingOperations.downloadsInProgress.keys.array)
        // 모든 필터링 작업 추가 
        allPendingOperations.addObjectFromArray(pendingOperations.filterationsInProgress.keys.array)
        let toBeCancelled = allPendingOperations.mutableCopy() as NSMutableSet
        let visiblePaths = NSSet(array: pathsArray)
        // 그 합집합에 대해 현재 셀에 표시되는 사진에 대한 작업을 제외 
        toBeCancelled.minusSet(visiblePaths)
        // 새로 시작할 시작할 작업 선정. 시작할 작업은 현재 보이는 셀에 대한 인덱스 중, 대기 중인 것을 제외함.
        let toBeStarted = visiblePaths.mutableCopy() as NSMutableSet
        toBeStarted.minusSet(allPendingOperations)
        for indexPaths in toBeCancelled {
            let indexPath = indexPath as NSIndexPath
            if let pendingDownload = pendingOperations.downloadsInProgress[indexPath] {
                pendingDownload.cancel()
            }
            pendingOperations.downloadsInProgress.removeValueForKey(indexPath)
            if let pendingFilteration = pendingOperations.filterationsInProgress[indexPath] {
                pendingFilteration.cancel()
            }
            pendingOperations.filterationsInProgress.removeValueForKey(indexPath)
        }
        for indexPath in toBeStarted {
            let indexPath - indexPath as NSIndexPath
            let recordToProcess = self.photos[indexPath.row]
            startOperationsForPhotoRecord(recordToProcess, indexPath: indexPath)        
        }
    }
}
  1. 먼저 현재 표시되는 셀들에 대한 인덱스패스를 수집한다.
  2. 큐에 들어있는 모든 작업들을 구한다. 여기에는 다운로드 및 필터링 작업을 합한것이다.
  3. 취소될 작업들의 인덱스 퍠스를 구한다. 2에서 구한 모든 작업 중에서 1의 표시되는 인덱스 패스를 제외한다. 그러고나면 화면에 보이지 않는 셀에 대한 작업만 남게 된다.
  4. 시작이 필요한 인덱스 페스를 구한다. 이는 아직 큐에 들어있지 않은 새로운 작업 단위가 된다. 이는 1에서 2를 제외한 나머지가 될 것이다.
  5. 루프를 돌면서 취소될 작업들을 취소하고 작업 큐로부터 뺀다. 이는 다운로드 큐와 필터링 큐에 대해 각각 지정해야 한다.
  6. 이번에는 신규로 추가해야 할 사진 레코드를 가지고 작업을 추가해준다.

여기까지 간략한 구현의 개요가 끝났다. 본 글에서는 설명하지 않았지만, 두 개의 큐에 대해서 다운로드가 끝난 후에 필터링이 시작되도록 큐에 집어넣는 방식을 사용했는데, 이외에 다른 구현방법이 있다.

NSOperation은 ‘의존성’이라는 신묘한 기능을 가지고 있는데, 의존성이 있는 작업이 설정되면, 의존하는 작업이 취소되거나 종료되어야 해당 작업이 시작되게 된다. 이를 테면

let downloadOperation = MyDownloadOperation()
let filterOperation = MyFilterOperation()
filterOperation.addDependency(downloadOperation)

이렇게 하면 다운로드가 종료하면 자동적으로 필터가 시작될 수 있어서 보다 간단한 코드를 작성할 수도 있다. (물론 의존성이 서로 꼬이면 데드락을 경험하게 되는 문제가 생긴다.)