Swift – BlockOperation 사용하기

Operation(NSOperation)은 특정한 작업을 수행하기 위한 코드와 데이터를 감싸는 객체로 해당 작업을 동기/비동기로 필요한 시점에 실행하기 위한 용도로 사용한다. NSOperation은 OSX 10.5에서도입되었는데, 이 시점의 Objective-C에서는 클래스의 외부에서 코드를 주입할 수 있는 언어적인 장치가 존재하지 않았기 때문에, 이를 사용하려면 무조건 NSOperation의 서브클래스를 만들어야 하는 불편한 점이 있었다. 따라서 간단한 코드 조각을 실행하기 위해서 NSOperation을 사용하는 것은 꽤나 불편한 일이었다.

OSX 10.6(스노우레퍼드)부터 GCC/Clang을 기본 컴파일러로 사용하게 되었고 이때부터 코드블럭 기능이 지원되었다. 즉 별도의 서브클래스를 작성하지 않더라도 코드 블럭의 형태로, 마치 클로저처럼 코드와 코드가 캡쳐링하는 데이터를 주입하여 오퍼레이션 인스턴스를 만드는 것이 가능해졌다. 실질적으로 NSOperation을 그대로 사용해야할 이유는 현재로서는 찾아보기 힘들다.

하지만 NSOperation은 NSBlockOperation(BlockOperation)과 NSInvocationOperation의 모태가 되는 추상클래스로, 각 클래스들이 공유하는 API를 제공하고 있으므로 몇가지 내용을 살펴보는 것이 좋겠다. 기본적으로 알고 있어야 할 것은 다음과 같은 것들이다.

참고로 NSOperation은 Swift의 Foundation으로 포팅되면서 NS- 접두사가 없어졌습니다.

  • addDependency(_:Operation) >> 현재 작업이 실행되기 전에 완료되어야 할 작업을 의존성으로 추가할 수 있다. 작업 큐에 들어가 있을 때, 특정한 작업들을 선행작업으로 지정할 수 있다. 의존성으로 설정된 작업을 제거하는 removeDependency(_:)도 사용할 수 있으며, dependencies 프로퍼티를 사용하면 의존하고 있는 작업을 [Operation] 타입으로 얻을 수 있다.
  • qualityOfService >> 해당 작업이 시스템 리소스를 사용할 우선순위를 지정할 수 있다.
  • queuePriority >> 작업 큐 내에서 가지는 우선순위
  • waitUntilFinished() >> 해당 작업이 완료될 때까지 현재 스레드의 흐름을 중지시킨다.
  • start() >> 작업을 시작한다.
  • completionBlock : (() -> Void)? >> 작업이 완료되었을 때 실행될 코드.
  • cancel() – 작업을 중지할 것을 요청한다. 단, 강제로 중지되지는 않고 작업의 상태를 취소됨으로 변경한다. 실제 중단의 책임은 작업 객체에게 있다.

BlockOperation

BlockOperation(NSBlockOperation)은 하나 혹은 그 이상의 블럭(클로저)을 비동기로 실행하고 관리하는 용도로 사용하는 클래스이다. init(block: () -> Void) 로 생성할 수 있으며, addExecutionBlock(() -> Void)를 사용해 블럭을 추가할 수 있다. 생성된 블럭에 대해서 start()를 호출하여 등록된 블럭들을 시작할 수 있으며, 작업 큐(NSOperationQueue)에 추가하여 스케줄링할 수 있다.

등록된 블럭들은 executionBlocks 프로퍼티를 통해서 액세스할 수 있다.

KVO 지원 속성

NSOperation의 일부 프로퍼티들은 KVO 호환으로 작동한다. 즉 작업 객체 외부에서 그 객체의 어떤 상태를 감지하는 것이 가능하다는 말이다. 다음과 같은 속성들이 변경될 때, 외부에서 옵저버를 통해 통지를 받을 수 있다.

  • isCancelled
  • isAsynchronous
  • isExecuting
  • isFinished
  • isReady
  • dependencies
  • queuePriority
  • completionBlock

참고로 KVC/KVO를 지원하는 속성이지만, 이를 코코아 바인딩에 적용해서는 안된다. 코코아 바인딩은 코코아 UI 요소와 상호작용하는데, 이는 반드시 메인스레드에서 이루어져야 하기 때문이다. 작업 객체가 실행되는 스레드가 메인스레드라는 보장이 없기 때문에 기본적으로 사용될 수 없다. (물론 특정 요건이 변경될 때, 메인스레드에서 수동으로 변경통지를 보내는 식으로 서브클래싱한다면 가능할지도 모르겠다.)

BlockOperation 사용 예제

다음 코드는 BlockOperation을 사용하여 간단한 연산을 수행하고 그 결과를 출력하는 예이다. 먼저 별도의 작업 큐 없이 BlockOperation에 두 개에 블럭을 추가하고 실행해보자.

import Foundation


let block = BlockOperation{
    // 1~10000까지의 합을 구한다.
    var res = 0
    for i in 1...10_000 {
        res += i
    }
    print(res)
}

block.addExecutionBlock {
    // 1~50000까지의 홀수의 합을 구한다.
    var res = 0
    for i in 1...50_000 {
        if i % 2 == 1 {
            res += i
        }
    }
    print(res)
}

// 블럭오퍼레이션 시작
block.start()
// 블럭들은 비동기로 실행되므로 결과를 출력할 때까지 대기한다.
block.waitUntilFinished() 

작업큐를 사용한 예제

작업 큐를 사용하면 여러 개의 작업을 순차적으로 혹은 병렬적으로 실행할 수 있다. 작업 큐는 내부적으로 GCD의 DispatchQueue를 사용한다. 많은 양의 작업이 추가되더라도 그만큼 많은 스레드를 생성하는 것이 아니라 지정된 개수의 스레드풀을 사용해서 적정한 수준으로 스레드 사용 개수를 제한할 수 있다. 동시에 실행 가능한 최대 스레드의 수는 OperationQueuemaxConcurrentOperationCount 프로퍼티를 변경하여 설정할 수 있다.

기본적으로 작업 큐는 큐에 추가된 순서대로 작업을 디스패치하려고 하지만, 블럭의 queuePriority가 높다면 이를 우선적으로 실행한다. 또 앞서 설명한바와 같이 특정 블럭이 다른 블럭에 의존하고 있다면 블럭의 시작 순서가 조정될 수 있다.

import Foundation

let block = OperationBlock{
    // 1~10000까지의 합을 구한다.
    var res = 0
    for i in 1...10_000 {
        res += i
    }
    print(res)
}


let block2 = OperationBlock {
    var res = 0
    for i in 1...50_000 {
        if i % 2 == 1 {
            res += i
        }
    }
    print(res)
}

let queue = OperationQueue()
queue.addOperations([block, block2], waitUntilFinished:true)

참고로 OperationQueue에 작업을 추가하는 방법으로는 다음 세 가지가 있다.

  • addOperation(_: Operation) – 만들어진 작업객체를 추가
  • addOperation(_: () -> Void) – 블럭을 사용해서 바로 작업을 생성/추가
  • addOperations(_:, waitUntilFinished:) – 작업 객체의 배열을 추가

작업 큐

작업큐(OperationQueue)는 GCD의 DispatchQueue를 둘러싸는 코코아 클래스로 Operation 객체를 추가하고 이를 실행하는 역할을 담당한다. 작업 큐에 추가된 작업들은 추가된 순서, 큐 우선순위, 준비 상태에 따라서 자동으로 실행이 시작된다.

GCD가 아닌 작업 큐를 사용하는 것은 약간의 오버헤드를 유발하지만, 그만큼 편리하게 세세한 조절이 가능하기도 하다. 앞서 말한 것과 같이 우선순위를 조정할 수 있으며, 각 작업 혹은 모든 작업에 대해서 작업 취소 시그널을 보낼 수 있다. 또 비동기로 디스패치되는 작업이 많은 경우에 세마포어와 같은 별도의 동시성 프로그래밍 프리미티브를 전혀 사용하지 않아도 스레드풀/프로세스 풀을 사용하는 것과 동일하게 동시 진행 작업의 수를 결정할 수 있다.

작업 큐는 작업(NSOperation) 클래스와 마찬가지로 몇가지 속성에 대해서 KVO를 지원한다. 따라서 작업 큐의 상태가 바뀌는 것에 대해 별도의 옵저버를 설정해서 통지를 받을 수 있다. 다음 목록은 작업 큐에서 KVO를 지원하는 프로퍼티의 목록이다.

  • operations
  • operationCount
  • maxConcurrentOperationCount
  • isSuspended
  • name

작업의 중단과 취소

작업큐 객체의 isSuspended 프로퍼티를 true로 변경하면 큐의 상태가 중단됨으로 변경된다. 기본적으로 이 값은 false로 설정되어 있는데, 그렇다면 큐에 추가되고 실행될 준비가 된 작업들은 큐에 의해 자동으로 디스패치하여 시작한다. 이 값이 true로 변경되는 시점부터는 새롭게 시작되는 작업은 없으며, 이미 시작된 작업들은 실행을 계속한다. 중단된 상태에서도 큐에 작업을 계속해서 추가할 수는 있다.

큐에 추가되어 시작된 작업이 완료되면 해당 작업은 상태가 ‘완료됨‘으로 변경되고, 큐에서 제거된다. (큐가 해당 작업에 대해 가지고 있던 참조가 제거된다.) 그런데 추가된 작업이 일단 제거되려면 먼저 시작되고 이어서 완료되어야 하기 때문에, 큐가 중단되는 시점에 아직 시작하지 못한 작업이 남아있거나 중단된 이후에 추가되는 작업이 있다면 이들은 큐에서 제거되지 않는다. 이런 상황이 반복되면 메모리릭이 발생한다.

cancelAllOperations()를 호출하면 큐에 남아있는 모든 작업에 대해서 cancel()을 호출하게 된다. 즉 모든 작업이 ‘취소됨’ 상태로 마크된다. 하지만 작업을 취소했다는 것은 상태값을 변경했다는 것 뿐이므로, 작업을 취소했다고 그 작업이 큐에서 제거되지는 않는다. (시작 -> 완료를 거쳐야 한다.) 대신에 큐에 들어있는 작업이 취소되는 경우, 해당 작업은 의존하는 다른 작업들을 무시한다. 즉 아직 완료되지 않은 선행작업이 있더라도 큐에 의해서 ‘취소된’ 상태로 작업을 시작할 수 있는 것이다. 작업 내부의 코드에서 취소여부 상태를 체크하여 실행이 종료되면 그제서야 큐에서 제거되게 된다.

작업을 취소하려면

앞서 NSOperation을 서브클래싱할 필요가 크게 없을것이라 했는데, 긴 실행주기를 가지고 있거나 중간에 취소될 수 있는 작업이라면 서브 클래스를 만들어야 한다. BlockOperation의 경우 실행되는 코드 블럭은 자신이 추가될 작업 객체에 대한 어떤 정보도 알지 못하므로 (옵저버를 통해서 알아내는 방법도 있긴 하겠으나) 작업 자체에 대한 참조를 얻는 방법은 이를 서브클래싱하는 방법밖에 없겠다.

취소 가능한 작업을 만들려면 main()을 오버라이드하는 과정에서 취소 여부를 결정할 수 있거나 결정해야 할 시점에 isCancelled 프로퍼티 값을 체크한다. 작업의 실행이 취소된다는 것은 실행중이 아닌 상태로 전환되는 것이므로 isExecuting 프로퍼티를 false로 바꾸는 작업이 (비록 해당 작업이 시작되기 전이더라도) 반드시 수반되어야 한다. (레퍼런스 문서에는 해당 과정이 필요하다고 언급되어 있지만, 실제로 이 프로퍼티는 읽기 전용이라 수정이 불가능하다.)

다음은 취소가 가능한 작업을 만드는 예시이다.

import Foundation

// 취소 가능한 작업 클래스
class Job: Operation {
  // 각 작업을 구분하기 위한 값
  let seq: Int
  
  init(seq: Int) {
    self.seq = seq
    super.init()
  }
  
  override func main() {
    // 1~99 사이의 난수를 누적하면서 출력한다.
    // 매 루프는 0.05초씩 대기한다.
    var v = 0
    let s = String(repeating:">", count: seq + 1)
    for _ in 0..<10 {
      if isCancelled {
        return
      }
      v += Int.random(in: 1..<100)
      print("\(s)\(seq): \(v)")
      Thread.sleep(forTimeInterval: 0.05)
    }
  }
}

// 5개의 작업을 만들고, 큐를 이용해 시작한다.
let jobs = (0..<5).map{ Job(seq: $0) }
let queue = OperationQueue()
queue.addOperations(jobs, waitUntilFinished: false)
// 0.2초 후에 첫번째 작업을 중지한다.
Thread.sleep(forTimeInterval: 0.2)
queue.operations.first?.cancel()
queue.waitUntilAllOperationsAreFinished()

이 코드를 실행하면 다음과 같은 결과가 출력된다. 0번 작업은 4번 실행된 후 취소되기 때문에 더 이상 실행되지 않는다. 이후 3번 작업이 개시되는 것으로 보아, 작업 큐의 기본 동시 실행 개수가 3으로 지정되어 있는 것을 알 수 있다.

>0: 16
>>1: 71
>>>2: 29
>0: 95
>>1: 159
>>>2: 96
>0: 167
>>1: 251
>>>2: 157
>0: 205      | ---> 0.05초가 4번 경과했고, 이제 0번 작업은 취소된다.
>>1: 334
>>>2: 165
>>1: 352
>>>2: 181
>>>>3: 42    | ---> 0번 작업 취소 후 3번작업이 시작됐다.
>>1: 353
>>>2: 254
>>>>3: 50
>>1: 405
>>>2: 257
>>>>3: 132
>>1: 502
>>>2: 353
>>>>3: 141
>>1: 519
>>>2: 358
>>>>3: 221
>>1: 601
>>>2: 415
>>>>3: 233
>>>>>4: 33
>>>>3: 268
>>>>>4: 129
>>>>3: 344
>>>>>4: 138
>>>>3: 409
>>>>>4: 234
>>>>3: 495
>>>>>4: 272
>>>>>4: 315
>>>>>4: 346
>>>>>4: 385
>>>>>4: 431
>>>>>4: 444

작업 자체를 비동기적으로 시작하도록 할 수도 있지만 그렇게 하기 위해서는 start() 메소드를 오버라이드하여 해당 작업이 비동기로 시작하는 매커니즘을 직접 구현해야 한다. 또한 isExecuting, isFinished와 같은 상태 프로퍼티를 KVO 호환방식 및 스레드 안전한 방식으로 조정해야 하는 복잡함이 따른다. 하지만 작업 큐를 사용하면 그러한 부담 없이 main()만 오버라이드 한 작업을 손쉽게 비동기로 작동시킬 수 있다.

작업 큐는 그 자체로 스레드 안전하므로 어떤 스레드에서든 안심하고 액세스할 수 있다.

참고로 서두에서 살짝 언급만하였던 NSInvocationOperation은 타깃과 셀렉터 정보를 결합한 invocation을 작업화한 것으로 Objective-C 런타임에서만 동작 가능하며, 실질적으로 Objective-C에서만 사용이 가능하다.

보너스 – 메인 큐

OperationQueue.main 을 통해 메인 큐에 액세스할 수 있다. 메인 큐는 별도 스레드가 아닌 메인 스레드에서 실행되며, 한 번에 하나의 작업을 처리하는 시리얼 큐이다. 참고로 코코아 UI 클래스들은 메인스레드에서 동작하는 것을 전제로 하고 있으므로, OperationQueue를 사용해서 작업을 백그라운드에서 실행하고 그 결과를 UI에 적용하고 싶다면, 메인 큐를 통해 적용하는 것이 좋다.