[Swift] SWIFT TASK CONTINUATION MISUSE: leaked its continuation! 해결하기

서론

기존 iOS/macOS 개발자들은 GCD(Grand Central Dispatch)라 불리는 DispatchQueue를 통해 Thread를 직접 관리 및 사용해왔는데요, Apple이 Swift 5.5 이상부터 Concurrency 프레임워크를 추가하면서 async, await 문법을 사용할 수 있게 되었습니다.

특히 JavaScript 개발자분들이 자주 사용하던 문법(Promise)이기도 하고, 더 익숙하고 읽기 편하다보니(기존에 남용하던 DispatchQueue에 비해 성능적인 이점도 있습니다) 기존에 DispatchQueue + completionHandler(클로저)로 작성된 코드를 async + await 코드로 전환해가는 것 같습니다.

저 같은 경우에도 최근 작성하는 코드들은 Concurrency 코드로 전부 작성하고 있습니다.

하지만 이전에 사용하던 코드들은 지원하지 않는 경우가 많은데요, 이 경우에도 withCheckedContinuation (function:_:) 와 같은 Apple이 제공하는 API를 통해 Concurrency를 지원할 수 있습니다.

이번 글에서는 Concurrency를 지원하면서 생긴 이슈를 야매(?)로 해결하는 방법을 기록해보고자 합니다.

본론

기존에 completionHandler로 작성된 비동기 함수들은 대부분 아래와 같은 방법으로 결과값을 받아왔습니다.

Api.request(...) { result in
    switch result {
        ...
    }
}

async, await 문법을 지원하기 위해서는 아래와 같은 코드를 작성해야 합니다.

try await withCheckedThrowingContinuation { continuation in
    Api.request(...) { result in
        switch result {
        case .success(let data):
            continuation.resume(returning: result)
        case .failure(let error):
            continuation.resume(throwing: error)
        }
    }
}

여기서 withCheckedContinuation, withCheckedThrowingContinuation, withUnsafeContinuation, withUnsafeThrowingContinuation 등등이 있는데요, 상황에 따라 적합한 Continuation을 선택해야 합니다.

저 같은 경우에 자주 사용하는 Continuation은 withCheckedContinuation와 withCheckedThrowingContinuation 정도인데요, 위 두가지 함수를 통해 대부분의 상황에서는 Concurrency를 지원할 수 있습니다.

하지만 특정 일부 상황이라면 제목에 적힌 ‘SWIFT TASK CONTINUATION MISUSE’ 에러가 발생할 수 있습니다.

만약 개발자가 기존의 로그인 기능을 Concurrency를 지원하도록 리팩토링 작업을 하고 있었고, 로그인 방식중에 카카오 로그인이 있습니다. 그렇다면 코드는 아래와 같이 작성할 수 있습니다.

import Foundation
import KakaoSDKCommon
import KakaoSDKAuth
import KakaoSDKUser

public protocol LoginService {
    /// 카카오 톡으로 로그인
    @MainActor
    func loginWithKakaoTalk() async throws -> KakaoSDKAuth.OAuthToken?
    
    /// 카카오 계정으로 로그인
    @MainActor
    func loginWithKakaoAccount() async throws -> KakaoSDKAuth.OAuthToken?
}

public class LoginServiceImpl: LoginService {

    /// 카카오 톡으로 로그인
    @MainActor
    public func loginWithKakaoTalk() async throws -> KakaoSDKAuth.OAuthToken? {
        return try await withCheckedThrowingContinuation { continuation in
            UserApi.shared.loginWithKakaoTalk { authToken, error in
                if let error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: authToken)
                }
            }
        }
    }

    /// 카카오 계정으로 로그인
    @MainActor
    public func loginWithKakaoAccount() async throws -> KakaoSDKAuth.OAuthToken? {
        // ...
    }
}

그리고 loginWithKakaoTalk 함수는 단순히 API를 호출해서 결과를 끝내는 것이 아니라, 아래와 같은 플로우로 결과값을 받아옵니다.

  1. authorizeWithTalkCompletionHandler에 callbackUrl을 통해 얻은 값을 서버와 통신하여 토큰으로 교환하는 코드를 사전에 준비한다.
  2. UIApplication.shared.open을 통해 카카오톡 앱을 호출한다.
  3. 카카오톡 앱으로 전환하여 사용자가 서비스 이용을 허용하면 다시 원래 앱을 호출한다.
  4. 원래 앱에서는 딥링크로 카카오톡으로부터 받은 코드를 통해 1번에서 정의한 handler 실행을 통해 토큰을 획득한다.

위의 과정이 매끄럽게 진행된다면 문제가 없지만, 진행이 안되는 경우가 발생하여(로그인이 풀려있거나, 서비스 동의 화면에서 취소를 하고 앱에 돌아와서 다시 시도하는 경우) 다시 로그인을 시도할 시 아래와 같은 오류가 발생하게 됩니다.

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

위의 오류를 보기 싫다면 withUnsafeContinuation 또는 withUnsafeThrowingContinuation를 통해 구현해주면 되는데요, 하지만 메모리 상에서 계속 남아있게 되어 아래와 같이 Memory Leak이 발생하게 됩니다.

그래서 저 같은 경우에는 임시로 아래와 같은 새로운 클래스를 생성해서 사용하고 있습니다.

#if canImport(_Concurrency) && canImport(Foundation)
import Foundation

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public class SafeCheckedContinuation<T, E> where E: Error {
    public let continuation: CheckedContinuation<T, E>
    public private(set) var isResumed: Bool
    private let lock = NSLock()
    
    public init(_ continuation: CheckedContinuation<T, E>) {
        self.continuation = continuation
        self.isResumed = false
    }
    
    public func resume(returning value: T) {
        lock.lock()
        defer { lock.unlock() }
        
        guard !isResumed else { return }
        continuation.resume(returning: value)
        isResumed = true
    }

    public func resume(throwing error: E) {
        lock.lock()
        defer { lock.unlock() }
        
        guard !isResumed else { return }
        continuation.resume(throwing: error)
        isResumed = true
    }
    
    public func resume<Er>(with result: Result<T, Er>) where E == Error, Er : Error {
        lock.lock()
        defer { lock.unlock() }
        
        guard !isResumed else { return }
        continuation.resume(with: result)
        isResumed = true
    }

    public func resume(with result: Result<T, E>) {
        lock.lock()
        defer { lock.unlock() }
        
        guard !isResumed else { return }
        continuation.resume(with: result)
        isResumed = true
    }

    public func resume() where T == () {
        lock.lock()
        defer { lock.unlock() }
        
        guard !isResumed else { return }
        continuation.resume()
        isResumed = true
    }
}

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
@_unsafeInheritExecutor
@inlinable
public func withSafeCheckedContinuation<T>( _ body: (SafeCheckedContinuation<T, Never>) -> Void) async -> T {
    return await withCheckedContinuation { body(SafeCheckedContinuation($0)) }
}

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
@_unsafeInheritExecutor
@inlinable
public func withSafeCheckedThrowingContinuation<T>(_ body: (SafeCheckedContinuation<T, Error>) -> Void) async throws -> T {
    return try await withCheckedThrowingContinuation { body(SafeCheckedContinuation($0)) }
}

#endif

isResumed 플래그와 CheckedContinuation를 갖고있는 클래스인데요, Continuation은 무조건 ‘한 번’만 resume 해줘야만 하기에 플래그 값을 추가해줬습니다. 추가적으로 SafeCheckedContinuation을 더 쉽게 사용할 수 있도록 withSafeCheckedContinuation와 withSafeCheckedThrowingContinuation도 추가했습니다.

@_unsafeInheritExecutor의 경우 사용하는 비동기 함수가 호출자의 Actor를 상속받기 위해 사용했습니다. (여기선 MainActor를 사용할 수 있도록 했습니다. 코드내에 UIApplication 접근 코드가 있어서…) 애플 문서에서는 안전하지 않은 방법이라니 주의해서 사용해야할 것 같습니다. #

이제 기존에 문제가 있던 코드를 아래와 같이 작성할 수 있습니다.

public class LoginServiceImpl: LoginService {
    
    var continuation: SafeCheckedContinuation<KakaoSDKAuth.OAuthToken?, Error>? = nil {
        willSet {
            guard let continuation else { return }
            continuation.resume(returning: nil)
        }
    }

    @MainActor
    public func loginWithKakaoTalk() async throws -> KakaoSDKAuth.OAuthToken? {
        return try await withSafeCheckedThrowingContinuation { continuation in
            self.continuation = continuation
            UserApi.shared.loginWithKakaoTalk { [weak self] authToken, error in
                if let error {
                    self?.continuation?.resume(throwing: error)
                } else {
                    self?.continuation?.resume(returning: authToken)
                }
            }
        }
    }

    ...
}

간단하게 설명드리자면 로그인 함수 호출 시 SafeCheckedContinuation를 변수에 할당하고, 할당하기 전에 이전에 남아있는 continuation이 있는지 확인하고, 만약 있다면 resume을 1회 실행해주는 코드입니다.

위와 같이 처리하면 기존에 발생하던 Continuation leak 이슈는 해결됩니다. 하지만 위의 방법은 임시방편에 불과하고 특수한 에러 상황에서만 사용하는게 좋습니다.

결론

Continuation leak 문제는 위처럼 임시방편으로 해결할 수 있지만 자주 사용할만한 방법은 아니다.

참고

https://github.com/apple/swift/blob/main/stdlib/public/Concurrency/CheckedContinuation.swift#L264

https://github.com/apple/swift/blob/main/docs/ReferenceGuides/UnderscoredAttributes.md#_unsafeinheritexecutor