[iOS] GCD (10) - Asynchronous Operations

전성훈·2023년 11월 3일
0

iOS/Concurrency

목록 보기
10/11
post-thumbnail

주제: 비동기 작업을 위한 커스텀 Operation


Asynchronous operations

  • 동기적인 작업들은 Operation 클래스와 매우 잘 작동한다.
  • 동기적인 Operation 작업은, 작업이 isReady 상태로 전환되면 시스템은 사용 가능한 스레드를 찾기 시작한다. 스케줄라가 작업을 실행할 스레드를 찾으면 작업은 isExecuting 상태로 전환된다. 그 시점에서 코드가 실행되고 완료되면 상태는 isFinished가 된다.
  • 하지만, Operation 안에 작업이 비동기로 작동하게 될 때 문제가 발생하게 된다.
  • Operation이 작동 후 Operation 내부의 비동기 작업이 시작되고 Operation이 종료된다. 이 시점에서 비동기 메서드가 아직 완료되지 않았을 가능성이 있기 때문에 작업의 상태를 isFinished로 전환을 수동으로 해줘야 한다.
  • 모든 상태 속성값은 read-only여서 직접적으로 수정하는 것은 불가능하지만, 그래도 어느정도 간단하게 조정할 수 있다. 한 번 비동기 operation을 작성하면 이를 subclass화 하여 계속해서 사용할 수 있다.
AsyncOperation
  • Apple은 Operation 클래스를 통해 개발자에게 동기 및 비동기 작업에 대한 기본 틀을 제공하며, 이 클래스는 매우 일반적이고 다양한 사용 사례에 맞게 확장 및 조정이 가능하도록 설계되어져있다. 이렇게 설계하는 것이 개발자에게 더 많은 유연성과 커스터마이징 옵션을 제공할 수 있기 때문이다.
  • AsyncOperation은 비동기 작업에 대한 상속 클래스로 구현될 수 있으며, 이를 구현하는 것은 상대적으로 간단한 작업이다. 이러한 구현을 제공하지 않음으로써 Apple은 개발자에게 프로젝트 요구 사항에 따라 비동기 작업에 대한 더 세밀한 제어와 필요한 추가 기능을 구현할 수 있는 기회를 제공한다.
  • 또한 Operation 클래스를 직접 사용하면서 동기 또는 비동기 작업을 구현할 수 있기 때문에, Apple은 필요에 따라 개발자가 클래스를 확장하고 사용하는 데 필요한 도구를 제공하는 것으로 보여진다. 이것이 바로 Operation과 같은 고차원의 추상화를 제공하는 이유이다.

State tracking

  • operation의 상태가 read-only이기 때문에, 먼저 read-write 방식으로 변경을 할 수 있는 방법을 만들어야 한다.
  • 그러므로 State 열거형을 class의 맨 윗부분에 생성한다.
class AsyncOperation: Operation { 
	enum State: String { 
		case ready, executing, finished 
		
		// KVO notifications을 위한 keyPath 설정 
		fileprivate var keyPath: String { 
			return "is\(rawValue.capitalized)"
		}
	}
}
  • Operation 클래스는 KVO notification을 사용한다. 예를 들어, isExecuting 상태가 번경되면 KVO 알림이 전송된다. 그러나 위에서 설정한 상태 열거형은 'is' 접두사로 시작하지 않으며, Swift Style Guide에 따르면 열거형 항목은 소문자로 표시해야 한다.
  • Computed property KeyPath 는 앞서 언급한 KVO notification을 지원하는 데 도움이 된다. 현재 State 의 keyPath를 요청하면 상태 값의 첫 글자를 대문자로 변경하고 텍스트 is를 접두사로 사용한다. 따라서 실행 중인 경우 keyPath는 isExecuting을 반환하며, Operation 기본 클래스의 속성과 일치한다.
  • keyPath는 이 파일 전체에서 사용 가능해야 하지만 외부에서는 사용할 수 없어야 한다. private로 설정하면 열거형 자체 외부에서는 볼 수 없다.
  • 파일 전체 범위이므로, production code에서는 별도의 파일에 class를 배치해야 한다.
  • 이제 상태 유형을 생성했으므로, 상태를 보유할 변수가 필요하다. 값 변경 시 적절한 KVO notification을 보내야 하므로 property observers를 속성에 연결한다.
	var state = State.ready { 
		// 값이 변경되면,
		willSet { 
			willChangeValue(forKey: newValue.keyPath)
			willChangeValue(forKey: state.keyPath)
		}
		// 값이 변경된 후
		didSet { 
			didChangeValue(forKey: oldValue.keyPath)
			didChangeValue(forKey: state.keyPath)
		}
	}
  • 기본적으로 상태는 준비 상태이다. 상태 값을 변경할 때 실제로 4개의 KVO notification이 전송된다.
  • 만약 상태가 현재 준비상태이고 실행 중으로 업데이트하는 경우를 보자면, isReady는 false가 되고 isExecuting은 true가 된다. 다음과 같은 4개의 KVO notification이 전송된다.
    • will change for isReady
    • will change for isExecuting
    • Did change for isReady
    • Did change for isExecuting
  • Operation 기본 클래스는 isExecuting 및 isReady 속성이 변경되는 것을 알아야 한다.
  • 종료를 비동기로 하기 위해서는 값이 변경될 때 상태를 알려줘야 한다.

Base properties

  • 이제 상태 변경을 추적하고 변경이 실제로 수행되었음을 알리는 방법을 갖추었으므로, 기본 클래스의 해당 메서드 인스턴스 대신 사용하여 상태를 사용하도록 오버라이드 해야 한다.
override var isReady: Bool { 
	return super.isReady && state == .ready
}

override var isExecuting: Bool { 
	return state == .executing
}

override var isFinished: Bool { 
	return state == .finished 
}
  • 스케줄러가 사용할 스레드를 찾을 준비가 되었는지 여부를 결정하는 동안 코드가 진행되는 모든 것을 인식하지 못하기 때문에 기본 클래스의 isReady 메서드를 확인하는 것이 중요하다.
  • 마지막으로 오바러이드 할 속성은 비동기 작업을 사용하고 있다는 것을 명시하는 것이다.
override var isAsynchronous: Bool { 
	return true
}

Starting the operation

  • 이제 할 일은 start 메서드를 구현하는 것이다. 작업을 수동으로 실행하든 작업 대기열이 실행 하도록 하든 상관없이, 먼저 start 메서드가 호출되고 나서 main 메서드를 호출해야 한다.
override func start() { 
	main()
	state = .executing
}
  • start 메서드를 오버라이드할 때는 어떤 경우에도 super를 호출하지 말아야 한다.
  • 위 main() 과 state = .executing은 아마 거꾸로 보일 것이다. 하지만 그렇지 않다. 비동기 작업을 수행하므로 main 메서드는 거의 즉시 반환된다.
  • 따라서 수동으로 상태를 .executing으로 변경하여 작업이 진행 중 임을 알려야 한다.
  • 이렇게 작성된 class를 직접 사용하기 보다는 항상 AsyncOperation을 상속하는 클래스로 사용해야 한다.
class AsyncSumOperation: AsyncOperation {
  let rhs: Int
  let lhs: Int
  var result: Int?
  
  init(lhs: Int, rhs: Int) {
    self.lhs = lhs
    self.rhs = rhs
    
    super.init()
  }
  
  override func main() {
    DispatchQueue.global().async {
      Thread.sleep(forTimeInterval: 2)
      self.result = self.lhs + self.rhs
      self.state = .finished
    }
  }
}

let queue = OperationQueue()
let pairs = [(2, 3), (5, 3), (1, 7), (12, 34), (99, 99)]

pairs.forEach { pair in
  let op = AsyncSumOperation(lhs: pair.0, rhs: pair.1)
  op.completionBlock = {
    guard let result = op.result else { return }
    print("\(pair.0) + \(pair.1) = \(result)")
  }
  
  queue.addOperation(op)
}

비동기함수를 오퍼레이션으로 감싸는 이유는?

  • 이러한 AsyncOperation을 만들면서 까지 operation안 에다가 비동기 함수를 넣는 이유는 무엇일까
  • 그것은 비동기 작업간의 순서 연결이 가능하기 때문이다. 즉, 순서 설정이 가능하기 때문이다.
  • 이러한 행위는 "pyramid of doom"을 피할 수 있다.
loadData { (data) in
	loadImages (data, callback: { (images) in process Images (images, callback: { (result) in
		secondaryProcessing (result, callback: { (output) in
			DispatchQueue.main.async {
				print("This is your processed data:")
				for value in output {
					print (value)
					}
				}
			})
		})
	})
}
  • 이러한 코드를 간결하고 직관적으로 변경할 수 있다.
class LoadDataOperation: AsyncOperation { 
	var data: Data?
	
	override func main() { 
		loadData { loadedData in 
			self.data = loadedData
			self.state = .finished
		}
	}
}

class LoadImagesOperation: AsyncOperation { 
	var data: Data? 
	var image: [UIImage]? 
	
	override func main() { 
		guard let data = data else { return }
		loadImages(data) { loadedImage in 
			self.images = loadedImages
			self.state = .finished
		}
	}
}
  • 이러한 Operation을 사용하여 원래의 콜백 기반 코드를 재구성할 수 있다.
let loadDataOperation = LoadDataOperation()
let loadImagesOperation = LoadImagesOperation() 
let processImagesOperation = ProcessImagesOperation() 
let secondaryProcessingOperation = SecondaryProcessingOperation()

loadImagesOperation.addDependency(loadDataOperation)
processImagesOpeartion.addDependency(loadImagesOperation)
secondaryProcessingOperation.addDependency(processImagesOperation)

let operations = [loadImagesOperation, processImagesOpeartion, secondaryProcessingOperation, secondaryProcessingOperation]

let queue  = OperationQueue() 

queue.addOperation(operations, waitUntilFinished: false)

queue.addOperation {
    DispatchQueue.main.async {
        print("This is your processed data:")
        for value in secondaryProcessingOperation.output {
            print(value)
        }
    }
}

Networked TiltShift

  • 8. Operations9. Operation Queues에서 보면 하드코딩 된 이미지 목록을 사용했다. 이미지를 로딩을 비동기 작업인 네트워크를 활용해서 불러오는 코드를 작성해보겠다.

NetworkImageOperation

  • NetworkImageOperation.swift라는 새로운 Swift 파일을 만든다. 이 프로젝트에서 필요한 것보다 더 많은 작업을 수행하지만, 이렇게 하면 다른 프로젝트에서도 재사용 가능한 구성 요소를 갖게 된다.
  • 작업에 대한 일반적인 요구 사항을 다음과 같다.
    1. URL을 나타내는 문자열이나 실제 URL 중 하나를 사용해야 한다.
    2. 지정된 URL에서 데이터를 다운로드해야 한다.
    3. URLSession 유형의 완료 핸들러가 제공되면 디코딩 대신 사용한다.
    4. 성공적으로 완료되고 이미지가 있는 경우, 선택적으로 UIImage 값을 설정해야 한다.
  • 첫 번째와 두 번째 요구 사항은 명확해야 한다. 세 번째와 네 번째 요구 사항은 호출자에게 최대한의 유연성을 제공학하기 위한 것이다.
  • 이 프로젝트와 같은 경우에서는 디코딩된 UIImage를 가져와서 작업을 완료하려고 한다. 그러나 다른 프로젝트에서는 사용자 정의 처리가 필요할 수 있다. 예를 들어, 구체적인 오류가 무엇인지, HTTP 헤더에 유효한 Content-Type 헤더가 있는지 등에 관심이 있을 수 있다.
  • AsyncOperation을 상속 받고 클래스에 필요한 변수를 선언하여 시작한다.
import UIKit 

typealias ImageOperationCompletion = ((Data?, URLResponse?, Error?) -> Void)? 

final class NetworkImageOperation: AsyncOperation { 
	var image: UIImage? 
	
	private let url: URL 
	private let completion: ImageOperationCompletion
}
  •  completion은  URLSession  메서드에서 사용된 것과 동일하지만 선택적으로 사용된다.
  • 요구 사항 1과 2를 충족시키려면 이에 맞춰 초기화를 정의해야 한다.
init(url: URL, completion: ImageOperationCompletion = nil) { 
	self.url = url 
	self.completion = completion 
	
	super.init()
}

convenience init?(string: String, completion: ImageOperationCompletion = nil) { 
	guard let url = URL(string: string) else { return nil }
	self.init(url: url, completion: completion)
}
  • HTTP 반환 데이터 자체를 명시적으로 처리하려고 하는 것은 일반적인 경우가 아니므로 completion handler가 nil로 설정하는 것이 타당하다.
  • 실제 URL을 전달하는 것이 "designated initializer"이므로, 문자열을 대신 사용하는 convenience init을 선언하는 것이다.
  • 문자열을 URL로 변환할 수 없으면 생성자가 nil을 반환한다.
  • main 메서드를 재정의하고 URLSession 작업을 시작한다.
override func main() { 
	URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
		guard let self = self else { return }
		
		defer { self.state = .finished }
		
		if let completion = self.completion { 
			completion(data, response, error)
			return 
		}
		
		guard error == nil, let data = data else { return }
		
		self.image = UIImage(data: data) 
	}.resume()
}
  • 비동기 작업이기 때문에 이는 항상 데이터 다운로드 전에 객체가 제거될 수 있다.
  • 그러므로 weak 캡쳐를 해야 한다.
  • 만약 강한 참조였다면 비동기 작업을 수행하는 동안 객체가 메모리에서 해제될 때 누수가 발생할 수 있다. 이를 방지하기 위해 약한 참조를 해야 한다.
  • defer를 사용하면 작업이 최종적으로 완료되도록 보장한다. 현재 메서드에 이미 3개의 가능한 종료 경로가 있으며, 향후 어떤 변경 사항이 있을지 모르니, defer 문을 맨 앞에 두면 코드가 더욱더 견고하게 된다.
  • 요구 사항 3과 4를 충족시키기 위해 호출자가 완료 핸들러를 제공했는지 여부를 확인하기만 하면 된다. 완료 핸들러를 제공했다면 처리 책임을 전달하고 종료한다. 그렇지 않으면 이지미를 디코딩한다.
  • 예외를 발생시키거나 오류 조건을 반환할 필요가 없다. 실패한 경우 image 속성이 nil이고 호출자는 문제가 발생했음을 알 수 있다.

Using NetworkImageFilter

  • TiltShiftTableViewController.swift로 돌아가서 표시할 수 있는 URL 목록을 가져오려면 viewController 시작 부분에 아래와 같은 코드를 추가해야 한다.
private var urls: [URL] = [] 

override func viewDidLoad() { 
	super.viewDidLoad()
	
	guard let plist = Bundle.main.url(forResource: "Photos", withExtension: "plist"),
				let contents = try? Data(contentsOf: plist),
				let serial = try? PropertyListSerialization.propertyList(from: contents, format: nil),
				let serialUrls = serial as? [String] else { 
					print("에러")
					return
				}
	urls = serialUrls.compactMap(URL.init)
}
  • 이는 .plist 파일의 내용을 읽고 문자열을 실제 URL 객체로 변환하는 표준 Swift 메커니즘이다.
  • compactMapmap처럼 작동하며, nil을 제외하고 unwrapped, non-optional 값만 반환한다.
  • tableView(_: cellForRowAt:) 에서 다음 두 줄만 바꾸면 된다.
let image = UIImage(name: "\(indexPath.row).png")!
let op = TiltShiftOperation(image: image)
  • 아래 처럼 변경한다.
let op = NetworkImageOperation(url: urls[indexPath.row])
  • 스크롤이 부드럽게 움직이며, 네트워크 작업 동안 UI가 어느 지점에서도 정지하지 않는다.

Asynchronous Operation

// 추상 비동기 오퍼레이션의 정의==============================
class AsyncOperation: Operation {
    // Enum 생성
    enum State: String {
        case ready, executing, finished
        
        // KVO notifications을 위한 keyPath설정
        fileprivate var keyPath: String {
            return "is\(rawValue.capitalized)"
        } // isReady/isExecuting/isFinished
    }
    
    // 직접 관리하기 위한 상태 변수 생성
    var state = State.ready {
        willSet {
            willChangeValue(forKey: newValue.keyPath)
            willChangeValue(forKey: state.keyPath)

        }
        didSet {
            didChangeValue(forKey: oldValue.keyPath)
            didChangeValue(forKey: state.keyPath)
        }
    }
}

extension AsyncOperation {
    // 상태속성은 모두 read-only
    override var isReady: Bool {
        return super.isReady && state == .ready
    }
    
    override var isExecuting: Bool {
        return state == .executing
    }
    
    override var isFinished: Bool {
        return state == .finished
    }
    
    override var isAsynchronous: Bool {  // 무조건 true로 리턴
        return true
    }
    
    override func start() {
        if isCancelled {
            state = .finished
            return
        }
        main()
        state = .executing
    }
    
    override func cancel() {
        super.cancel()
        state = .finished
    }
}

출처(참고문헌)

제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.

감사합니다.

0개의 댓글