이 포스트는 다음 버전을 기준으로 작성되었습니다.

  • iOS 15.6
  • Swift 5.6.1
  • Xcode 13.4.1

Intro

오늘은 Continuation 이라는 것을 공부해 보겠습니다. 본격적으로 설명을 시작하기 전에 이것을 어디에 사용하는지를 알아야 공부에 대한 동기부여가 될테니, Continuation 이 언제 필요한지를 먼저 알아보도록 할게요.

우리는 이전 포스트에서 async/await 의 기본적인 사용법을 익혔습니다. 기존 @escaping 키워드를 사용해 콜백 스타일로 비동기 작업을 처리하는 함수를, 새로운 Async, Await API 를 적용시킨 함수로 리팩토링하여 훨씬 간결하고 가독성 좋은 비동기 함수를 만들 수 있었어요.

하지만 막상 새롭게 배운 내용들을 회사 프로젝트에 적용하려고 보니 다음과 같은 상황이 발생했다고 생각해보세요.

  • 기존 함수가 패키지 안에 감싸져 있어 우리가 임의로 수정할 수 없습니다.
  • 레거시 코드가 너무 복잡하게 엉켜있어 수정 시 크리티컬한 사이드 이펙트가 발생할 수 있습니다.

아쉽지만 회사에서 새로운 프로젝트를 시작할 때까지 async/await 사용을 포기해야 할까요?

아니요 포기는 배추를 셀 때나.. 바로 이러한 상황에서 사용할 수 있도록 위해 애플이 Continuation 을 준비해 두었습니다. 기존 콜백 스타일의 비동기 함수는 수정하지 않으면서, 브릿지 역할을 하는 Continuation 함수를 만들어 Async, Await API 를 적용하는 것 입니다.


Continuation 정의

Continuation 은 다음 두가지로 나뉘어 있습니다.

  • CheckedContinuation
  • UnsafeContinuation

그렇다면 둘 사이에 어떤 차이점이 있을까요? 여느 때처럼 공식 문서에서 한번 찾아보겠습니다.

Documentation CheckedContinuation

CheckedContinuation 은 동기, 비동기 코드 간의 인터페이스 메커니즘이라고 합니다.

음… 메커니즘…?? 개념적인 설명이라 그런지 조금 모호한 듯한 느낌이 있네요. 위에서 얘기했던 대로 콜백함수를 Async 함수로 변환하는 브릿지 역할을 한다는 것을 이렇게 표현한 걸까요?

다음 것도 읽어보면 좀 더 이해가 잘 갈 수도 있으니 UnsafeContinuation 의 설명을 보겠습니다.

Documentation UnsafeContinuation

또커니즘… 그래도 CheckedContinuation 과 다른 점을 찾았습니다. 메커니즘이 정확히 무엇을 의미하건 간에 정확성 검사의 유무라는 차이가 있네요. 그렇다면 정확성 검사가 무엇인지를 알아야겠죠.

정확성 검사

Continuation 은 사용 시 개발자가 반드시 지켜야 하는 두가지 규칙이 있습니다.

  • Continuation 클로저 안에서 반드시 resume 을 호출해야 합니다.
  • 그리고 resume 은 단 한 번만 호출 되어야 합니다.

우리가 작성한 코드가 이 두가지 규칙을 위배하는지에 대한 체크해 주는 것이 바로 정확성 검사입니다.

정확성 검사 조건 위배 시 발생하는 현상

조건을 위배한 코드를 작성하면 어떤 일이 일어날까요?

  • resume 이 단 한 번도 호출되지 않았다면 Continuation 이 실행되고 값이나 에러를 리턴하지 않게 됩니다. 즉 Continuation 이 무한 대기상태에 빠져 리소스 낭비가 발생하게 됩니다.
  • 두번 이상 resume 을 호출하면 앱에 크래시가 발생합니다.

사실 정확성 검사 기능을 제공하는 CheckedContinuation 을 사용한다고 스위프트 문법 검사를 하듯이 resume 규칙에 대한 위배를 미리 체크하고 막아주는 것은 아닙니다.

대신 앱이 실행되는 동안 규칙이 위배된 것을 발견하면 경고 메세지가 콘솔에 출력됩니다. 그 이유는 정확성 검사가 런타임에 이루어지기 때문이에요.

CheckedContinuation 의 문제점

지금까지 살펴보았듯 보다 안전한 코드를 작성하려면 CheckedContinuation 를 사용하면 됩니다. 그렇다면 안전하지 않은 옵션인 UnsafeContinuation 은 왜 만들어 놓았을까요?

그것은 바로 CheckedContinuation 의 정확성 검사가 런타임에 이루어지다보니 오버헤드가 발생한다는 단점이 있기 대문입니다. 안전한 대신 성능 상에서 손해를 보는 방식인 것 입니다.

어떤 것을 사용하는 것이 좋을까?

장단점도 알았으니 이제 어떤 것을 사용하는 것이 좀 더 좋을지만 결정하면 됩니다. 사실 둘 다 사용하라고 만들어 놓은 것이니 정답은 없어요. 그래도 저는 CheckedContinuation 을 사용을 권장합니다.

그 이유는 CheckedContinuation 으로 발생하는 오버헤드가 사용자 입장에서 체감할 수 있을만큼 심각한 성능저하를 야기하지 않을 것이 확실하고, UnsafeContinuation 사용으로 발생할 수 있는 잠재적 메모리 릭의 위험성이나 크래시 상황에서 디버깅에 대한 비용이 훨씬 크다고 생각하기 때문이에요.

하이브리드 옵션으로 개발 도중에는 CheckedContinuation 을 사용하고 충분한 테스트를 거쳐 안전하다고 판단되면, 출시 시점에 UnsafeContinuation 로 변경하는 것도 방법일 수 있습니다. 하지만 이 경우에도 업데이트를 위한 코드 수정을 거치다 보면, 다시 정확성 검사를 위해 CheckedContinuation 으로 변경해야 하는 수고로움과 시간적인 손해가 발생할 수 있다는 것을 알아두세요.

다시 한번 결론을 내드리면 저는 거의 모든 케이스에 CheckedContinuation 사용을 권장합니다.

사용 가능한 4가지 메서드

Continuation 의 종류 및 차이를 충분히 알아보았으니 기존 Callback 스타일 함수를 Async 하게 사용할 수 있게 도와주는 메서드에 어떤 종류가 있는지 살펴보겠습니다.

CheckedContinuation, UnsafeContinuation 은 코드 문맥상 에러가 발생할 여지가 있는지에 따라 throwing 을 하는 함수와 그렇지 않은 함수로 2가지로 나뉘어, 총 4가지 종류를 사용할 수 있습니다.

  • withCheckedContinuation(function:_:)
  • withCheckedThrowingContinuation(function:_:)
  • withUnsafeContinuation(_:)
  • withUnsafeThrowingContinuation(_:)

안전한 코드를 위해 CheckedContinuation 을 사용하려면 withCheckedContinuation 이나 withCheckedThrowingContinuation(function:_:) 중 상황에 적합한 것을 사용하면 됩니다. 기존 함수에 에러가 발생하는 상황이 있다면 Throwing 이 들어간 메서드를 사용하면 되겠죠?


Usage Examples

중요한 내용이 많다보니 설명이 길어졌네요. 이제 드디어 예제 코드를 작성하며 실습을 해 볼 시간입니다! 이번 예제에서는 @escaping 을 사용하는 Callback 함수를 Continuation 을 활용해 async/await API 를 활용할 수 있는 함수로 변경해 보겠습니다.

공통 부분

Concurrency - Async, Await, 그리고 Task 알아보기에서 사용한 코드와 동일합니다.

let url = URL(string: "https://api.quotable.io/random")

enum NetworkError: Error {
    case badUrl
    case badResponse
    case communicationError
    case decodeFailed
    case noData
}

struct Quote: Decodable {
    let content: String
}

Callback

이 부분도 Concurrency - Async, Await, 그리고 Task 알아보기에서 사용한 코드를 그대로 가져왔습니다.

func getQuote(from url: URL?, completion: @escaping (Result<Quote, NetworkError>) -> Void) {
    guard let url = url else {
        completion(.failure(.badUrl))
        return
    }
    URLSession.shared.dataTask(with: url,
                               completionHandler: { data, response, error in
        if let error = error {
            completion(.failure(.communicationError))
            print(error)
            return
        }
        if let response = response as? HTTPURLResponse,
           !(200..<300).contains(response.statusCode) {
            completion(.failure(.badResponse))
            return
        }
        guard let data = data else {
            completion(.failure(.noData))
            return
        }
        do {
            let quote = try JSONDecoder().decode(Quote.self, from: data)
            completion(.success(quote))
        } catch {
            completion(.failure(.decodeFailed))
        }
    }).resume()
}

Continuation

이제 Continuation 을 사용헤 기존 콜백함수를 Wrapping 해보겠습니다. resume(returning:) 을 사용해 값을 전달하고 에러는 resume(throwing:) 으로 처리하겠습니다.

func getQuote(from url: URL?) async throws -> Quote {
    return try await withCheckedThrowingContinuation({ continuation in
        getQuote(from: url,
                 completion: { result in
            switch result {
            case .success(let quote):
                continuation.resume(returning: quote)
            case .failure(let networkError):
                continuation.resume(throwing: networkError)
            }
        })
    })
}

에러 발생에 대한 처리를 이후로 미루려면 resume(with:) 를 사용해 Result 타입을 그대로 전달할 수도 있습니다.

func getQuoteResult(from url: URL?) async throws -> Quote {
    return try await withCheckedThrowingContinuation({ continuation in
        getQuote(from: url,
                 completion: { result in
            continuation.resume(with: result)
        })
    })
}

resume 을 호출하지 않은 경우

마지막으로 resume 규칙을 위배하는 케이스들에 대한 테스트를 해보겠습니다. 먼저 resume한 번도 호출하지 않으면 어떻게 되는지 볼게요.

func getQuote(from url: URL?) async throws -> Quote {
    return try await withCheckedThrowingContinuation({ continuation in
        getQuote(from: url,
                 completion: { result in
        })
    })
}

위 코드를 실행해 보면 다음과 같은 경고 메세지가 출력됩니다.

SWIFT TASK CONTINUATION MISUSE: getQuote(from:) leaked its continuation!

Continuation 을 잘못 사용해서 메모리 릭이 발생했다고 하네요. 이것은 우리가 CheckedContinuation 을 사용했기 때문에 메세지가 출력되는 것이고 UnsafeContinuation 을 사용한 경우에는 다른 메세지 없이 조용히 메모리 릭이 발생하게 됩니다.

resume 을 두번 이상 호출하는 경우

resume 을 호출하지 않는 경우에 메모리 릭이 발생하는 것은 확인했고, 그렇다면 두번 이상 호출하면 어떻게 될까요?

func getQuote(from url: URL?) async throws -> Quote {
    return try await withCheckedThrowingContinuation({ continuation in
        getQuote(from: url,
                 completion: { result in
            continuation.resume(with: result)
            continuation.resume(with: result)
        })
    })
}

앱에 크래시가 발생하며 다음과 같은 경고 메세지가 출력되었습니다.

Fatal error: SWIFT TASK CONTINUATION MISUSE: getQuote(from:) tried to resume its continuation more than once, returning ()!

이것 역시 CheckedContinuation 을 사용했기 때문에 출력되는 메세지 입니다. UnsafeContinuation 역시 앱 크래시가 발생하지만 이유를 명확하게 알려주지 않습니다.

이렇게 Continuation 의 규칙 위배는 크리티컬한 상황을 초래하니 Continuation 을 사용할 때는 resume 사용의 2가지 규칙을 꼭 기억해주세요!


Wrap Up

Continuation 으로 이제 기존 모든 CallBack 스타일 함수를 Async 하게 바꿀 수 있게 되었어요 ㅎㅎ 다만 아직도 async / await 에 관해 배울 내용들이 많이 남았습니다. 다음 포스트 async let 으로 또 찾아오겠습니다 :)


References

Wrapping existing asynchronous code in async/await in Swift
The difference between checked and unsafe continuations in Swift