티스토리 뷰
Continuation을 이용하여 컴플리션 핸들러, 델리게이트 패턴을 async 함수로 변환하기 (Swift)
Sunghyun Kim 2022. 6. 27. 15:00iOS 개발을 하다 보면 비동기 처리를 위해 컴플리션 핸들러를 자주 사용하게 된다. 컴플리션 핸들러를 많이 사용하다보면 코드의 뎁스가 깊어지고 흐름을 알기가 어려워 디버깅이 복잡해지며 가독성이 떨어지는 문제점을 겪는다. 또한 다른 객체로 래핑하여 사용하려 할 때, 해당 함수를 사용하는 함수는 컴플리션을 또 상위에서 받아오도록 인터페이스를 작성해야 하는 불편함도 있다. 델리게이트 패턴 또한 코드의 흐름이 섞이게 되므로 비슷한 상황에 직면하게 된다.
스위프트에선 5.5 버전부터 async/await를 통해 비동기 처리를 지원하고 있고 애플의 많은 프레임워크들은 async/await에 대응하도록 인터페이스가 추가된 상태이다. 그러나 타사 프레임워크와 일부 애플 프레임워크들은 대부분 대응 업데이트가 되지 않아 컴플리션 핸들러 또는 델리게이트 패턴을 여전히 사용해야 한다. 이러한 프레임워크들을 컴바인, RxSwift 등으로 변환할때 직접 퍼블리셔/옵저버블을 생성하여 이용하는 경우가 많은데, 지속적인 값의 방출이 있는 것이 아닌 이상 이들을 구독하여 값을 사용하기보다 async/await를 이용해 코드를 더욱 간결하고 Swift 스타일로 만들 수가 있다. 이 게시글에선 다양한 비동기 처리를 위한 함수들을 스위프트에서 제공하는 Continuation을 사용해 async/await에 대응하도록 작성해볼 것이다.
컴플리션 핸들러 함수 변환하기
예시에선 컴플리션 핸들러로 제작된 구글 로그인(Sign In With Google) 프레임워크를 스위프트 async/await로 변환할 것이다. 먼저 아래 코드는 구글 로그인 프레임워크에서 제공하는 컴플리션 핸들러 스타일의 함수이다. 로그인이 완료되면 GIDGoogleUser에 값을, 실패 시 Error에 값을 삽입한 후 컴플리션을 실행한다.
GIDSignIn.sharedInstance.signIn(with: config, presenting: viewController) { gidUser, error in
guard error == nil else {
// 오류 처리
return
}
// GIDGoogleUser로 로그인 성공
}
우선 DispatchSemaphore를 이용해 동기함수로 변환해보겠다.
extension GIDSignIn {
func signIn(with config: GIDConfiguration,
presenting presentingViewController: UIViewController) throws -> GIDGoogleUser {
let semaphore = DispatchSemaphore(value: 0)
var (gidUser, error): (GIDGoogleUser?, Error?)
signIn(with: config, presenting: presentingViewController) { u, e in
gidUser = u
error = e
semaphore.signal()
}
semaphore.wait()
guard error == nil else {
throw error!
}
return gidUser!
}
}
컴플리션을 통해 에러도 방출하는 함수이기에 throws를 이용해 에러를 방출해주도록 작성하였다. 이 방식으로도 사용 시점에 직관적인 코드를 짤 수 있다. 다만 작성 시점에 세마포어를 선언해주고 사용해줘야하는 번거로움이 있다. 그리고 동기 함수이기 때문에 쓰레드에 대한 컨트롤은 함수의 실행이 끝날 때 까지 해당 함수가 보유하게 되어 해당 쓰레드에서 다른 동기 함수를 실행할 수 없게 된다.
그러면 이 함수에 그냥 async 키워드를 붙여 비동기 함수로 제작하면 되는 것이 아닌가 싶을 것이다. 스위프트 5.X 버전까지는 그리 사용해도 문제가 없다. 그러나 async 함수 내에서 세마포어를 호출하는 것은 스위프트 6부터 컴파일 오류이기 때문에 지양하는 것이 좋다.
이제 이 함수를 비동기 함수로 변환하겠다.
extension GIDSignIn {
func signIn(with config: GIDConfiguration,
presenting presentingViewController: UIViewController) async throws -> GIDGoogleUser {
return try await withCheckedThrowingContinuation { continuation in
self.signIn(with: config, presenting: presentingViewController) { gidUser, error in
guard error == nil else {
continuation.resume(throwing: error!)
return
}
continuation.resume(returning: gidUser!)
}
}
}
}
Continuation을 사용하면 조금 더 코드가 간결해지고 번거로운 작업을 할 필요가 없어지며 async 함수를 안전하게 제작할 수 있다.
let googleUser = try await googleSignIn.signIn(with: config, presenting: presentingViewController)
Continuation에 대하여
애플에선 Continuation을 생성하는 여러 함수들을 제공하고 있다.
- withCheckedContinuation(function:_:)
- withCheckedThrowingContinuation(function:_:)
- withUnsafeContinuation(_:)
- withUnsafeThrowingContinuation(_:)
위 함수들로 CheckedContinuation 및 UnsafeContinuation을 제작할 수 있는데, 이 함수들의 리턴값은 클로저에서 resume된 값으로 제작하려는 함수의 리턴에 바로 적으면 된다.
주의해야 할 점은 Continuation에는 resume이 반드시 한번만 호출되어야 한다. 만약 CheckedContinuation에서 두번 이상 resume이 호출될 경우 바로 앱이 강제 종료된다. 두번 resume을 호출하면 메모리 구조에 균열을 일으켜 또 다른 예상치 못한 버그를 발생시킬 수 있기에 해당 시점에 앱을 충돌시키는 것이 낫다고 판단한 듯 하다. 만약 반대로 한번도 호출되지 않는 경우 Continuation 누수에 대한 디버깅 로그가 발생한다.
Checked와 Unsafe의 차이점은, 위에서 언급한 앱 강제 종료 여부이다. Checked는 어떤 Continuation에서 resume을 두번 호출했는지 추적하고 앱을 중지해준다. 추적 때문에 성능 저하가 따라오므로 오버헤드를 피하고 싶다면 Unsafe를 사용하는 것이 좋겠다.
각 함수들의 또 다른점은 Throwing의 여부이다. 이는 단순히 해당 Continuation이 오류를 처리할 것인지 여부에 대한것으로 ThrowingContinuation을 사용할 때에는 try를 사용하여 호출하여야 한다.
델리게이트 패턴 변환하기
애플 로그인(Sign In With Apple)이 델리게이트 패턴으로 콜백을 처리하도록 작성되어 있다. 이 경우에도 Continuation을 적용해 편리한 인터페이스로 변환이 가능하다.
우선 기존의 코드를 확인해보겠다.
import AuthenticationServices
class AppleAuthManager: NSObject, ASAuthorizationControllerDelegate {
weak var presentationContextProvider: ASAuthorizationControllerPresentationContextProviding?
func signIn() {
performRequests(signInRequests())
}
private func signInRequests() -> [ASAuthorizationRequest] {
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.email]
return [request]
}
private func preparedAuthorizationController(
with requests: [ASAuthorizationRequest]
) -> ASAuthorizationController {
let authorizationController = ASAuthorizationController(authorizationRequests: requests)
authorizationController.delegate = self
authorizationController.presentationContextProvider = presentationContextProvider
return authorizationController
}
private func performRequests(_ requests: [ASAuthorizationRequest]) {
let controller = preparedAuthorizationController(with: requests)
controller.performRequests()
}
// MARK: - ASAuthorizationControllerDelegate
func authorizationController(
controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization
) {
let credential = authorization.credential as? ASAuthorizationAppleIDCredential
// ...
// 앱의 로그인 서버와 통신
// ...
}
func authorizationController(
controller: ASAuthorizationController,
didCompleteWithError error: Error
) {
// ...
// 오류 처리
// ...
}
}
signIn 함수에서 로그인 요청을 실행했는데 실질적인 처리 로직은 하단에 작성한 ASAuthorizationControllerDelegate에서 구현해야 한다. 예제의 경우 함수가 순서대로 배치되어 있어 코드의 가독성에 큰 문제가 없어보이지만 해당 클래스는 뷰컨트롤러도 아니고 실제 로그인에 필요한 로직은 삽입하지도 않은 상태이기 때문에 실제 사용에선 더욱 많은 함수들 사이에서 이 흐름을 따라가야 한다. 외부에서 오류 처리까지 해야한다고 가정했을 때 이 객체는 뷰컨트롤러가 아니기 때문에 오류를 어딘가에 또 저장한 뒤에 사용해야 하는 번거로움이 있다.
이러한 델리게이트 패턴을 Continuation을 사용해 비동기 함수 하나로 묶어보자.
class AppleAuthManager: NSObject, ASAuthorizationControllerDelegate {
weak var presentationContextProvider: ASAuthorizationControllerPresentationContextProviding?
// ∨ throws를 통해 이 함수를 호출하는 곳에서 오류 처리를 하기가 쉬워진다.
func signIn() async throws {
let credential = try await performRequests(signInRequests()) as? ASAuthorizationAppleIDCredential
// ...
// 앱의 로그인 서버와 통신
// ...
}
// 1. Credential을 받아와 사용할 Continuation 프로퍼티를 하나 선언한다.
private var currentContinuation: CheckedContinuation<ASAuthorizationCredential, Error>?
private func signInRequests() -> [ASAuthorizationRequest] {
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.email]
return [request]
}
private func preparedAuthorizationController(
with requests: [ASAuthorizationRequest]
) -> ASAuthorizationController {
let authorizationController = ASAuthorizationController(authorizationRequests: requests)
authorizationController.delegate = self
authorizationController.presentationContextProvider = presentationContextProvider
return authorizationController
}
@discardableResult
private func performRequests(
_ requests: [ASAuthorizationRequest]
) async throws -> ASAuthorizationCredential {
return try await withCheckedThrowingContinuation {
// 2. 클래스 프로퍼티에 생성한 Continuation을 저장한다.
currentContinuation = $0
let controller = preparedAuthorizationController(with: requests)
controller.performRequests()
}
}
// MARK: - ASAuthorizationControllerDelegate
func authorizationController(
controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization
) {
// 3. Continuation에 값을 포함하여 resume 한다.
currentContinuation?.resume(returning: authorization.credential)
currentContinuation = nil
}
func authorizationController(
controller: ASAuthorizationController,
didCompleteWithError error: Error
) {
currentContinuation?.resume(throwing: error)
currentContinuation = nil
}
}
델리게이트 패턴을 변환할 시에는 클래스 내부 프로퍼티에 Continuation을 선언하는 것이 중요하다. 그리고 with~Continuation 함수를 호출할 때 생성된 Continuation을 프로퍼티에 저장하고, 델리게이트 콜백 함수에서 생성된 값을 가져와 Continuation에 resume 해주면 된다.
이렇게 작성하면 뷰컨트롤러로부터 ASAuthorizationController를 분리하기 매우 쉬워지고 오류 처리도 원하는 위치에서 쉽게 할 수 있다.
'Swift 이론' 카테고리의 다른 글
| Swift에서 날짜를 다루는 다양한 방법 알아보기 (0) | 2022.08.24 |
|---|---|
| AsyncStream으로 콜백과 델리게이트 async-await 변환 (Swift) (0) | 2022.07.30 |
| DispatchSemaphore 사용하기 (Swift) (0) | 2022.05.01 |
| DispatchGroup 사용하기 (Swift) (0) | 2022.05.01 |
