안녕하세요~!!!

한달을 거의 꽉 채워서 포스트를 올리네요.. 헤헤.. 🥰

이번 포스트는 동아리 인원과 함께 스터디 하고있는 주제인 Concurrency !! 그중에서도 Continuation에 대해 알아보겠습니다!!

함께해주세요-!


기존의 Apple 플랫폼에서의 asynchronous programming은 두가지 패턴이 존재합니다.

Completion CallbackDelegate Pattern입니다.

Completion Callback

Completion CallBack은 메소드가 completion colsure를 가지고있고, 결과를 받게되면 해당 내용을 실행합니다.

이때, 결과를 받을때까지 다른 코드들이 실행 될 수 있습니다.

Delegate Pattern

Delegate Pattern은 delegate owner가 delegate를 통해 data를 delegate method에 넘겨주게되고, data가 전달 될 때 내용을 실행하므로 중간에 다른 코드들이 실행 될 수 있습니다.

이러한 두가지 방식은 거의 모든 프로젝트에서 사용되고 있을 것 입니다.

이러한 방식 대신 Modern Concurrency Model을 기존 코드에 손쉽게 적용을 하기 위해 Apple이 고안한 API가 Continuation입니다.

Adpot continuations

그렇다면 continuation을 이용해서 어떻게 기존의 코드들에 적용 할 수 있을까요?

continuations는 두가지 API를 제공합니다.

UnsafeContinuation

struct UnsafeContinuation<T, E: Error> {
  func resume(returning: T)
  func resume(throwing: E)
  func resume(with result: Result<T, E>)
}

extension UnsafeContinuation where T == Void {
  func resume() { resume(returning: ()) }
}

extension UnsafeContinuation where E == Error {
  // Allow covariant use of a `Result` with a stricter error type than
  // the continuation:
  func resume<ResultError: Error>(with result: Result<T, ResultError>)
}

func withUnsafeContinuation<T>(
    _ operation: (UnsafeContinuation<T, Never>) -> ()
) async -> T

func withUnsafeThrowingContinuation<T>(
    _ operation: (UnsafeContinuation<T, Error>) throws -> ()
) async throws -> T

CheckedContinuation

struct CheckedContinuation<T, E: Error> {
  func resume(returning: T)
  func resume(throwing: E)
  func resume(with result: Result<T, E>)
}

extension CheckedContinuation where T == Void {
  func resume()
}

extension CheckedContinuation where E == Error {
  // Allow covariant use of a `Result` with a stricter error type than
  // the continuation:
  func resume<ResultError: Error>(with result: Result<T, ResultError>)
}

func withCheckedContinuation<T>(
    _ operation: (CheckedContinuation<T, Never>) -> ()
) async -> T

func withCheckedThrowingContinuation<T>(
  _ operation: (CheckedContinuation<T, Error>) throws -> ()
) async throws -> T

각각의 특징은 다음과 같습니다.

UnsafeContinuation

  • misuse에 대한 check(leak, resource hold, thread-safe 등)를 하지 않음

💡 safety check at thread-safe ?
동시 실행시 단일 thread를 보장하여 thread-safe를 제공. thread-safe를 제공하게 되면 성능 저하가 따라오기 때문에 적절한 선택이 필요하다.

CheckedContinuation

  • misuse에 대한 check를 진행합니다.

공통적으로 가지고있는 resume method의 특징은 다음과 같습니다.

  • resume() : result를 전달하지 않고 suspend된 task만 resume
  • resume(returning: T) : 제공된 타입의 value 전달 및 resume
  • resume(throwing: E) : 제공된 Error 전달 및 resume
  • resume(with: Result<T, E>) : 제공된 Result<T, E> 전달 및 resume

또 각각 with~Continuation, with~Throwing~Continuation을 가지고 있는데,

각각은 closure를 wrapping하며, continuation을 전달합니다.

그리고 Error를 전달하지 않거나 전달하여 Error handling 필요에 따라 사용합니다.

continuation의 API도 살펴보았고, 실제 코드를 보면서 적용해보겠습니다.

Completion Callback

func beginOperation(completion: @escaping (OperationResult) -> Void) { }

completion callback을 사용하는 기존의 코드입니다.

보통 아래처럼 사용됩니다.

func boo() {
	beginOperation { result in 
		// blah blah ...
	}
}

이제 continuation을 사용하여 변경해보겠습니다.

먼저 wrapping func 인 operation을 작성합니다.

func operation() {
	
}

해당 함수는 async context를 포함하므로 async 키워드가 필요합니다.

또한 우리는 retrun 으로 OperationResult가 필요합니다.

func operation() async -> OperationResult {

}
func operation() async -> OperationResult {
	return beginOperation { result in
		
	}
}

위와같이 사용하면 좋겠지만... 구문이 맞지 않으므로 continuation 을 사용합니다.

(Safety Check가 필요없음을 가정하고 UnsafeContinuation을 사용하겠습니다.)

func operation() async -> OperationResult {
	return withUnsafeContinuation { continuation in
				
	}
}

이제 async code를 호출하기 위해 suspend 시점을 지정(await) 하고, async코드를 호출합니다.

func operation() async -> OperationResult {
		// suspend
	return await withUnsafeContinuation { continuation in
		// existing code
		beginOperation { result in
			// blah blah ...	
		}
	}
}

resume 시키기위해 continuation의 resume을 호출합니다.

이때, 저희는 OperationResult를 return으로 넘겨주어야 하기 때문에 resume(returning:) 을 사용 합니다.

func operation() async -> OperationResult {
	// suspend
	return await withUnsafeContinuation { continuation in
		// existing code
		beginOperation { result in
			// resume with return OperationResult
			continuation.resume(returning: result)
		}
	}
}

짠! 기존 코드에 Modern Concurrency를 적용하였습니다.

이제 실제 코드에서 어떻게 다르게 동작하는지 비교해보겠습니다.

func callBack(_ completion: @escaping(String) -> Void) {
    DispatchQueue.global().async {
        completion("CallBack 실행")
    }
}

print("111")
print("222")
callBack {
    print($0)
}
print("333")
print("444")
print("555")
print("666")

---------

func modernConcurrencyCallBack() async -> String {
    return await withUnsafeContinuation { continuation in
        callBack {
            continuation.resume(returning: $0)
        }
    }
}

Task {
    print("111")
    print("222")
    print(await modernConcurrencyCallBack())
    print("333")
    print("444")
    print("555")
    print("666")
}


CallBack


Continuation CallBack


Delegate Pattern

CLLocationManagerDelegate를 사용하는 기존 코드에 continuation을 사용하여 Modern Concurrency를 적용해보겠습니다.

CLLocationManagerDelegate는 locationManager(_:didUpdateLocations:)를 통해, 위치정보가 업데이트 될때, CLLocation 배열을 전달해줍니다.

따라서 기존 코드는 대부분 다음과 같이 작성합니다.

import CLLocation

final class ViewController: UIViewController {
	// ...
 	// ...
	private var locationManager: CLLocationManager?

	override func viewDidLoad() {
		super.viewDidLoad()
		locationManager = CLLocationManager()
		locationManager?.delegate = self
	}
}

extension ViewController: CLLocationManagerDelegate {
	func locationManager(
    _ manager: CLLocationManager,
    didUpdateLocations locations: [CLLocation]
  ) {
		// blah blah
  }
}

이때, locationManager(_:didUpdateLocations:) delegateMethod 안에서, locations가 전달되었을 때 실행할 내용들을 작성하게 됩니다.

extension ViewController: CLLocationManagerDelegate {
	func locationManager(
    _ manager: CLLocationManager,
    didUpdateLocations locations: [CLLocation]
  ) {
		// print updatedLocation or do somethings
		print(locations)
  }
}

어떻게 변경해야할지 막막하지만 천천히 따라해 보겠습니다. 🤨

먼저 LocationManagerDelegate를 wrapping하는 delegate class를 생성합니다.

이때 해당 class는 continuation을 property로 가지고 있어야 하며 initializer로 전달받습니다.

또한 내부적으로 delegate method를 통해 response를 전달받기 위한 manager instance를 생성하며, delegate를 채택합니다.

completion callback에서는 UnsafeContinutaion을 사용했므로, 이번 섹션에서는 CheckedContinuation을 사용해보겠습니다.

import CLLocation

final class LocationDelegate: CLLocationManagerDelegate {
	private let continuation: CheckedContinuation<CLLocation, Error>?
	private let manager = CLLocationManager()
	
	init(continuation: CheckedContinuation<CLLocation, Error>) {
		self.continuation = continuation
		manager.delegate = self
	} 
}

기존과 동일하게 delegate method를 작성하고,

import CLLocation

final class LocationDelegate: CLLocationManagerDelegate {
	private let continuation: CheckedContinuation<CLLocation, Error>?
	private let manager = CLLocationManager()
	
	init(continuation: CheckedContinuation<CLLocation, Error>) {
		self.continuation = continuation
		manager.delegate = self
	} 
}

extension LocationDelegate {
	func locationManager(
    _ manager: CLLocationManager,
    didUpdateLocations locations: [CLLocation]
  ) {
		// print updatedLocation or do somethings
		print(locations)
  }
}

delegate method에서 전달받은 locations를 continuation의 resume을 통해 전달합니다.

import CLLocation

final class LocationDelegate: CLLocationManagerDelegate {
	private let continuation: CheckedContinuation<CLLocation, Error>?
	private let manager = CLLocationManager()
	
	init(continuation: CheckedContinuation<CLLocation, Error>) {
			self.continuation = continuation
			manager.delegate = self
	} 
}

extension LocationDelegate {
	func locationManager(
    _ manager: CLLocationManager,
    didUpdateLocations locations: [CLLocation]
  ) {
		guard let location = locations.first else { return }
		continuation?.resume(returning: location)
  }
}

어때요? 참 쉽죠? 🤩

이렇게 wrapper delegate class는 완성 되었고, 해당 delegate를 사용해보겠습니다.

아주 간단합니다.

let locationDelegate: LocationDelegate?
locationDelegate = self
...
...
...
// implement delegate method

일반적으로는 위와 같이 사용하겠죠?

이번엔 반대로 생각해봅시다. 😗

delegate에서는 CLLocation을 return합니다.
그렇다면, CLLocation을 받아야할 변수가 있어야겠죠?

let location: CLLocation

location은 delegate에서 continuation을 통해 받아옵니다.

해당 continuation은 현재 context에서 전달해야합니다. but, 어떻게? ☹️

let locationDelegate: LocationDelegate?
let location: CLLocation = LocationDelegate? // ??
func withCheckedThrowingContinuation<T>(
  _ operation: (CheckedContinuation<T, Error>) throws -> ()
) async throws -> T

위에서 보았던 withCheckedThrowingContinuation 함수가 있습니다.

operation은 (CheckedContinuation<T, Error>) throws -> () 타입의 closure를 전달해야합니다.

다음과 같이 사용됩니다.

let location: CLLocation = try await withCheckedThrowingContinuation { continuation in
	// can use continuation
}

try 키워드는 함수가 throws이기 때문에 필요하며, async context이기 때문에 await이 필요합니다.

(그렇다면 withCheckedContinuation 에서는 당연히 try가 필요없겠죠?)

continuation이 전달되므로 해당 continuation을 사용하여 delegate를 설정할 수 있을 것 같습니다.

let location: CLLocation = try await withCheckedThrowingContinuation { [weak self] continuation in
	self?.locationDelegate = LocationDelegate(continuation: continuation)
}

continuation에서 resume을 통해 location이 전달되면, location에 할당됩니다.

또 해당 context는 async context이기 때문에, Task로 감싸줍니다.

Task {
  let location: CLLocation = try await withCheckedThrowingContinuation { [weak self] continuation in
      self?.locationDelegate = LocationDelegate(continuation: continuation)
  }
}

다 마무리 된것 같죠?

여기에는 함정이 숨어있습니다. 🤭

위 방식을 참고하여 delegate를 변경하고, 코드를 실행하면

SWIFT TASK CONTINUATION MISUSE: shareLocation() leaked its continuation!

위와 같은 에러가 console에 찍히는것을 볼 수 있습니다.

왜 발생하느냐!

resume은 코드 실행 경로에서 딱 한번 호출되어야 하기 때문입니다.

resume이 두번이상 실행된다면 데이터 오류를 일으킬 수 있습니다.

이전에 작성했던 LocaitonDelegate 의 코드를 보면 다음과 같은데요.

extension LocationDelegate {
	func locationManager(
    _ manager: CLLocationManager,
    didUpdateLocations locations: [CLLocation]
  ) {
		guard let location = locations.first else { return }
		continuation?.resume(returning: location)
  }
}

이 코드는 delegate method가 실행 될 때마다 resume을 실행합니다.

따라서 location이 업데이트 될 때마다 resume을 실행하게 되므로 에러를 발생시킵니다.

해결 방법은 resume을 한 뒤에 continuation을 nil로 할당합니다. 🥰

extension LocationDelegate {
	func locationManager(
    _ manager: CLLocationManager,
    didUpdateLocations locations: [CLLocation]
  ) {
		guard let location = locations.first else { return }
		continuation?.resume(returning: location)
		continuation = nil
  }
}

자, 이것으로 기존에 사용되던 asynchronous programming을 continuation으로 모두 대체 할 수 있게 되었습니다. 👏👏👏


마무리하며

이번 포스트는 ...

갑자기?!

기초인 async/await도 공부 안하고 Concurrency부터 ?! 라고 생각하실 수 있는데..

async/await에 대한 내용은 raywenderlich의 modern-concurrency 내용을 보면서 깃허브에 따로 정리한것이 있어 포스팅 하지 않았습니다. 🥰 (자연스럽게 깃허브 노출..)

이어서 다음 포스트는 제가 TDD에 한참 관심이 있었었고, 프로덕트에서도 async task에 대한 테스트를 어떻게 작성해야 하는가에 대한 고민을 하고있어서!!

Asynchronous Code Test에 대한 포스트를 작성할 예정입니다~!! 😎

기대해 주세요 ☺️

그럼 이번달은 짧게 마무리 하도록 하겠습니다.

질문이나 지적은 언제든 환영입니다!! 🙇🏻‍♂️

읽어주셔서 감사합니다!!


출처

profile
hello, iOS

0개의 댓글